feat(i18n): localize Settings Plugins, Account and the SSH/executors tree (#2249)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -172,6 +173,7 @@ function useGitIdentityState(isRemote: boolean, profile: ExecutorProfile) {
|
||||
}
|
||||
|
||||
export default function ProfileEditPage({ profileId }: { profileId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const result = useProfileFromStore(profileId);
|
||||
|
||||
@@ -179,9 +181,9 @@ export default function ProfileEditPage({ profileId }: { profileId: string }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Profile not found</p>
|
||||
<p className="text-muted-foreground">{t("executors:profileNotFound")}</p>
|
||||
<Button className="mt-4 cursor-pointer" onClick={() => router.push(EXECUTORS_ROUTE)}>
|
||||
Back to Executors
|
||||
{t("executors:backToExecutors")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -194,6 +196,7 @@ export default function ProfileEditPage({ profileId }: { profileId: string }) {
|
||||
}
|
||||
|
||||
function useProfilePersistence(executor: Executor, profile: ExecutorProfile) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const executors = useAppStore((state) => state.executors.items);
|
||||
@@ -217,18 +220,22 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) {
|
||||
try {
|
||||
const updated = await updateExecutorProfile(executor.id, profile.id, data);
|
||||
setSaveStatus("success");
|
||||
toast({ title: "Profile saved", variant: "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 : "Failed to save profile";
|
||||
const message = err instanceof Error ? err.message : t("executors:failedToSaveProfile");
|
||||
setError(message);
|
||||
setSaveStatus("error");
|
||||
toast({ title: "Failed to save profile", description: message, variant: "error" });
|
||||
toast({
|
||||
title: t("executors:failedToSaveProfile"),
|
||||
description: message,
|
||||
variant: "error",
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[executor, profile.id, executors, setExecutors, toast],
|
||||
[executor, profile.id, executors, setExecutors, toast, t],
|
||||
);
|
||||
|
||||
const remove = useCallback(
|
||||
@@ -257,6 +264,7 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) {
|
||||
}
|
||||
|
||||
function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(profile.name);
|
||||
const [mcpPolicy, setMcpPolicy] = useState(profile.mcp_policy ?? "");
|
||||
const [prepareScript, setPrepareScript] = useState(profile.prepare_script ?? "");
|
||||
@@ -272,7 +280,7 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
|
||||
const flags = useRemoteExecutorFlags(executor.type);
|
||||
const remoteAuth = useRemoteAuthState(profile);
|
||||
const gitIdentity = useGitIdentityState(flags.isRemote, profile);
|
||||
const mcpPolicyError = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]);
|
||||
const mcpPolicyErrorKey = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]);
|
||||
|
||||
useEffect(() => {
|
||||
listScriptPlaceholders()
|
||||
@@ -289,8 +297,8 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
|
||||
}, [envVarRows, flags.isSprites, spritesSecretId]);
|
||||
|
||||
const prepareDesc = flags.isRemote
|
||||
? "Runs inside the execution environment before the agent starts. Type {{ to see available placeholders."
|
||||
: "Runs on the host machine before the agent starts.";
|
||||
? t("executors:prepareScriptDescriptionRemote", { trigger: "{{" })
|
||||
: t("executors:prepareScriptDescriptionLocal");
|
||||
|
||||
return {
|
||||
name,
|
||||
@@ -332,7 +340,7 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
|
||||
isDocker: flags.isDocker,
|
||||
isSprites: flags.isSprites,
|
||||
isSSH: executor.type === "ssh",
|
||||
mcpPolicyError,
|
||||
mcpPolicyErrorKey,
|
||||
buildEnvVars,
|
||||
prepareDesc,
|
||||
};
|
||||
@@ -478,8 +486,29 @@ function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSe
|
||||
onUpdate={form.updateEnvVar}
|
||||
onRemove={form.removeEnvVar}
|
||||
/>
|
||||
<ProfileScriptCards executor={executor} profile={profile} form={form} />
|
||||
<McpPolicyCard
|
||||
mcpPolicy={form.mcpPolicy}
|
||||
baselinePolicy={profile.mcp_policy ?? ""}
|
||||
mcpPolicyErrorKey={form.mcpPolicyErrorKey}
|
||||
onPolicyChange={form.setMcpPolicy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The prepare/cleanup script pair, split out to keep ProfileEditSections
|
||||
* under the max-lines-per-function budget. */
|
||||
function ProfileScriptCards({
|
||||
executor,
|
||||
profile,
|
||||
form,
|
||||
}: Omit<ProfileEditSectionsProps, "secrets">) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<ScriptCard
|
||||
title="Prepare Script"
|
||||
title={t("executors:prepareScript")}
|
||||
description={form.prepareDesc}
|
||||
value={form.prepareScript}
|
||||
baselineValue={profile.prepare_script ?? ""}
|
||||
@@ -490,8 +519,8 @@ function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSe
|
||||
/>
|
||||
{form.isRemote && (
|
||||
<ScriptCard
|
||||
title="Cleanup Script"
|
||||
description="Runs after the agent session ends for cleanup tasks."
|
||||
title={t("executors:cleanupScript")}
|
||||
description={t("executors:runsAfterTheAgentSessionEnds")}
|
||||
value={form.cleanupScript}
|
||||
baselineValue={profile.cleanup_script ?? ""}
|
||||
onChange={form.setCleanupScript}
|
||||
@@ -500,17 +529,12 @@ function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSe
|
||||
executorType={executor.type}
|
||||
/>
|
||||
)}
|
||||
<McpPolicyCard
|
||||
mcpPolicy={form.mcpPolicy}
|
||||
baselinePolicy={profile.mcp_policy ?? ""}
|
||||
mcpPolicyError={form.mcpPolicyError}
|
||||
onPolicyChange={form.setMcpPolicy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileEditForm({ executor, profile }: { executor: Executor; profile: ExecutorProfile }) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { items: secrets } = useSecrets();
|
||||
const persistence = useProfilePersistence(executor, profile);
|
||||
@@ -527,7 +551,7 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E
|
||||
data-testid="ssh-connection-settings-link"
|
||||
>
|
||||
<IconShieldLock className="mr-1.5 h-4 w-4" />
|
||||
Connection Settings
|
||||
{t("executors:connectionSettings")}
|
||||
</Button>
|
||||
) : undefined;
|
||||
|
||||
@@ -555,15 +579,15 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E
|
||||
setSavedRevision(submittedRevision);
|
||||
};
|
||||
let invalidReason: string | undefined;
|
||||
if (!form.name.trim()) invalidReason = "Profile name is required.";
|
||||
else if (form.mcpPolicyError) invalidReason = form.mcpPolicyError;
|
||||
else if (spritesTokenMissing) invalidReason = "Sprites token is required.";
|
||||
if (!form.name.trim()) invalidReason = t("executors:profileNameIsRequired");
|
||||
else if (form.mcpPolicyErrorKey) invalidReason = t(form.mcpPolicyErrorKey);
|
||||
else if (spritesTokenMissing) invalidReason = t("executors:spritesTokenIsRequired");
|
||||
useSettingsSaveContributor({
|
||||
id: `executor-profile:${profile.id}`,
|
||||
revision: saveRevision,
|
||||
isDirty: baselineReady && saveRevision !== savedRevision,
|
||||
canSave:
|
||||
baselineReady && Boolean(form.name.trim()) && !form.mcpPolicyError && !spritesTokenMissing,
|
||||
baselineReady && Boolean(form.name.trim()) && !form.mcpPolicyErrorKey && !spritesTokenMissing,
|
||||
invalidReason,
|
||||
save: handleSave,
|
||||
discard: () => undefined,
|
||||
@@ -596,7 +620,7 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E
|
||||
<ProfileEditSections executor={executor} profile={profile} form={form} secrets={secrets} />
|
||||
</fieldset>
|
||||
{spritesTokenMissing && (
|
||||
<p className="text-sm text-destructive">Sprites API key is required.</p>
|
||||
<p className="text-sm text-destructive">{t("executors:spritesApiKeyIsRequired")}</p>
|
||||
)}
|
||||
{persistence.error && <p className="text-sm text-destructive">{persistence.error}</p>}
|
||||
<ProfileFormActions onDelete={() => persistence.setDeleteDialogOpen(true)} />
|
||||
|
||||
@@ -1,42 +1,60 @@
|
||||
// Registry of executor types presented in the "new executor profile" flow.
|
||||
// Keep entries here (not inline in the page) so the page file stays under
|
||||
// the 600-line lint cap and new types can be added without touching layout.
|
||||
//
|
||||
// The map keys (`local`, `worktree`, `local_docker`, `remote_docker`,
|
||||
// `sprites`, `ssh`) are the persisted executor enum and the create route's
|
||||
// path segment, and `executorId` is a backend row id — none of them is copy.
|
||||
// Labels and descriptions travel as catalog keys and resolve at render;
|
||||
// `brandLabel` carries a brand/protocol name that reads the same in every
|
||||
// locale, so it is a value rather than a message.
|
||||
|
||||
export type ExecutorTypeInfo = {
|
||||
executorId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
/** Rendered verbatim. Set on brand/protocol names only. */
|
||||
brandLabel?: string;
|
||||
/** Catalog key for the label. Set when `brandLabel` is not. */
|
||||
labelKey?: string;
|
||||
descriptionKey: string;
|
||||
};
|
||||
|
||||
export const EXECUTOR_TYPE_MAP: Record<string, ExecutorTypeInfo> = {
|
||||
local: {
|
||||
executorId: "exec-local",
|
||||
label: "Local",
|
||||
description: "Runs agents directly in the repository folder.",
|
||||
labelKey: "executors:typeLocal",
|
||||
descriptionKey: "executors:descriptionLocalPc",
|
||||
},
|
||||
worktree: {
|
||||
executorId: "exec-worktree",
|
||||
label: "Worktree",
|
||||
description: "Creates git worktrees for isolated agent sessions.",
|
||||
labelKey: "executors:typeWorktree",
|
||||
descriptionKey: "executors:descriptionWorktree",
|
||||
},
|
||||
local_docker: {
|
||||
executorId: "exec-local-docker",
|
||||
label: "Docker",
|
||||
description: "Runs Docker containers on this machine.",
|
||||
brandLabel: "Docker",
|
||||
descriptionKey: "executors:descriptionLocalDocker",
|
||||
},
|
||||
remote_docker: {
|
||||
executorId: "exec-remote-docker",
|
||||
label: "Remote Docker",
|
||||
description: "Connects to a remote Docker host.",
|
||||
labelKey: "executors:remoteDocker",
|
||||
descriptionKey: "executors:descriptionRemoteDocker",
|
||||
},
|
||||
sprites: {
|
||||
executorId: "exec-sprites",
|
||||
label: "Sprites.dev",
|
||||
description: "Runs agents in Sprites.dev cloud sandboxes.",
|
||||
brandLabel: "Sprites.dev",
|
||||
descriptionKey: "executors:descriptionSprites",
|
||||
},
|
||||
// `CreateProfilePage` short-circuits `ssh` to `SSHCreatePage`, which renders
|
||||
// its own header — this entry exists so the unknown-type guard accepts the
|
||||
// route, and its label/description are never rendered.
|
||||
ssh: {
|
||||
executorId: "exec-ssh",
|
||||
label: "SSH",
|
||||
description: "Connects to a remote host over SSH and runs agentctl there.",
|
||||
brandLabel: "SSH",
|
||||
descriptionKey: "executors:descriptionSshCreate",
|
||||
},
|
||||
};
|
||||
|
||||
/** The label for a type card: a brand name verbatim, or the translated label. */
|
||||
export function executorTypeLabel(info: ExecutorTypeInfo, t: (key: string) => string): string {
|
||||
return info.brandLabel ?? (info.labelKey ? t(info.labelKey) : "");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
@@ -44,7 +45,7 @@ import {
|
||||
import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api";
|
||||
import type { Executor, ExecutorType, ProfileEnvVar } from "@/lib/types/http";
|
||||
|
||||
import { EXECUTOR_TYPE_MAP } from "./executor-types";
|
||||
import { EXECUTOR_TYPE_MAP, executorTypeLabel, type ExecutorTypeInfo } from "./executor-types";
|
||||
import { SSHCreatePage } from "./ssh-create-page";
|
||||
|
||||
const EXECUTORS_ROUTE = "/settings/executors";
|
||||
@@ -72,28 +73,22 @@ export default function CreateProfilePage({ executorType }: { executorType: stri
|
||||
}
|
||||
|
||||
function InvalidTypeFallback() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">Unknown executor type</p>
|
||||
<p className="text-muted-foreground">{t("executors:unknownExecutorType")}</p>
|
||||
<Button className="mt-4 cursor-pointer" onClick={() => router.push(EXECUTORS_ROUTE)}>
|
||||
Back to Executors
|
||||
{t("executors:backToExecutors")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateProfileHeader({
|
||||
type,
|
||||
label,
|
||||
description,
|
||||
}: {
|
||||
type: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}) {
|
||||
function CreateProfileHeader({ type, typeInfo }: { type: string; typeInfo: ExecutorTypeInfo }) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
@@ -101,12 +96,14 @@ function CreateProfileHeader({
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExecutorTypeIcon type={type} />
|
||||
<h2 className="text-2xl font-bold">New {label} Profile</h2>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{t("executors:newTypeProfile", { type: executorTypeLabel(typeInfo, t) })}
|
||||
</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getExecutorLabel(type)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t(typeInfo.descriptionKey)}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -114,7 +111,7 @@ function CreateProfileHeader({
|
||||
onClick={() => router.push(EXECUTORS_ROUTE)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Back to Executors
|
||||
{t("executors:backToExecutors")}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
@@ -280,6 +277,7 @@ function useCreateGitIdentityState(isRemote: boolean) {
|
||||
}
|
||||
|
||||
function useCreateProfileFormState(executorType: ExecutorType) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(() => (executorType === "local_docker" ? "Docker" : ""));
|
||||
const [mcpPolicy, setMcpPolicy] = useState("");
|
||||
const [prepareScript, setPrepareScript] = useState("");
|
||||
@@ -293,7 +291,7 @@ function useCreateProfileFormState(executorType: ExecutorType) {
|
||||
const [builtDockerImage, setBuiltDockerImage] = useState<DockerBuildSuccess | null>(null);
|
||||
const flags = useCreateRemoteFlags(executorType);
|
||||
const gitIdentity = useCreateGitIdentityState(flags.isRemote);
|
||||
const mcpPolicyError = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]);
|
||||
const mcpPolicyErrorKey = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]);
|
||||
|
||||
useEffect(() => {
|
||||
listScriptPlaceholders()
|
||||
@@ -323,8 +321,8 @@ function useCreateProfileFormState(executorType: ExecutorType) {
|
||||
builtDockerImage?.imageTag === imageTag.trim());
|
||||
|
||||
const prepareDesc = flags.isRemote
|
||||
? "Runs inside the execution environment before the agent starts. Type {{ to see available placeholders."
|
||||
: "Runs on the host machine before the agent starts.";
|
||||
? t("executors:prepareScriptDescriptionRemote", { trigger: "{{" })
|
||||
: t("executors:prepareScriptDescriptionLocal");
|
||||
|
||||
return {
|
||||
name,
|
||||
@@ -364,13 +362,14 @@ function useCreateProfileFormState(executorType: ExecutorType) {
|
||||
isRemote: flags.isRemote,
|
||||
isDocker: flags.isDocker,
|
||||
isSprites: flags.isSprites,
|
||||
mcpPolicyError,
|
||||
mcpPolicyErrorKey,
|
||||
buildEnvVars,
|
||||
prepareDesc,
|
||||
};
|
||||
}
|
||||
|
||||
function useCreateProfileSave(executorId: string) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const executors = useAppStore((state) => state.executors.items);
|
||||
const setExecutors = useAppStore((state) => state.setExecutors);
|
||||
@@ -390,13 +389,13 @@ function useCreateProfileSave(executorId: string) {
|
||||
);
|
||||
runWithNavigationBlockerBypassed(() => router.push(`/settings/executors/${profile.id}`));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create profile");
|
||||
setError(err instanceof Error ? err.message : t("executors:failedToCreateProfile"));
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[executorId, executors, setExecutors, router],
|
||||
[executorId, executors, setExecutors, router, t],
|
||||
);
|
||||
|
||||
return { saving, error, handleSave };
|
||||
@@ -411,6 +410,7 @@ function CreateProfileSections({
|
||||
form: ReturnType<typeof useCreateProfileFormState>;
|
||||
secrets: ReturnType<typeof useSecrets>["items"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<ProfileDetailsCard name={form.name} baselineName="" onNameChange={form.setName} />
|
||||
@@ -471,7 +471,7 @@ function CreateProfileSections({
|
||||
onRemove={form.removeEnvVar}
|
||||
/>
|
||||
<ScriptCard
|
||||
title="Prepare Script"
|
||||
title={t("executors:prepareScript")}
|
||||
description={form.prepareDesc}
|
||||
value={form.prepareScript}
|
||||
baselineValue=""
|
||||
@@ -482,8 +482,8 @@ function CreateProfileSections({
|
||||
/>
|
||||
{form.isRemote && (
|
||||
<ScriptCard
|
||||
title="Cleanup Script"
|
||||
description="Runs after the agent session ends for cleanup tasks."
|
||||
title={t("executors:cleanupScript")}
|
||||
description={t("executors:runsAfterTheAgentSessionEnds")}
|
||||
value={form.cleanupScript}
|
||||
baselineValue=""
|
||||
onChange={form.setCleanupScript}
|
||||
@@ -495,26 +495,28 @@ function CreateProfileSections({
|
||||
<McpPolicyCard
|
||||
mcpPolicy={form.mcpPolicy}
|
||||
baselinePolicy=""
|
||||
mcpPolicyError={form.mcpPolicyError}
|
||||
mcpPolicyErrorKey={form.mcpPolicyErrorKey}
|
||||
onPolicyChange={form.setMcpPolicy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getCreateDisabledReason(
|
||||
// Returns a catalog key (or null when the form is submittable) so the reason
|
||||
// resolves at render rather than at module load.
|
||||
function getCreateDisabledReasonKey(
|
||||
form: ReturnType<typeof useCreateProfileFormState>,
|
||||
spritesTokenMissing: boolean,
|
||||
saving: boolean,
|
||||
) {
|
||||
if (saving) return "Creating profile...";
|
||||
if (!form.name.trim()) return "Enter a profile name.";
|
||||
if (form.mcpPolicyError) return form.mcpPolicyError;
|
||||
if (spritesTokenMissing) return "Add a Sprites API key before creating the profile.";
|
||||
if (saving) return "executors:creatingProfile";
|
||||
if (!form.name.trim()) return "executors:enterAProfileName";
|
||||
if (form.mcpPolicyErrorKey) return form.mcpPolicyErrorKey;
|
||||
if (spritesTokenMissing) return "executors:addASpritesApiKeyBeforeCreating";
|
||||
if (form.isDocker) {
|
||||
if (!form.imageTag.trim()) return "Enter an image tag before creating the profile.";
|
||||
if (!form.dockerfile.trim()) return "Add Dockerfile content before creating the profile.";
|
||||
if (!form.dockerImageBuilt) return "Build this Docker image before creating the profile.";
|
||||
if (!form.imageTag.trim()) return "executors:enterAnImageTagBeforeCreating";
|
||||
if (!form.dockerfile.trim()) return "executors:addDockerfileContentBeforeCreating";
|
||||
if (!form.dockerImageBuilt) return "executors:buildThisDockerImageBeforeCreating";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -548,37 +550,34 @@ function CreateProfileForm({
|
||||
typeInfo,
|
||||
}: {
|
||||
executorType: ExecutorType;
|
||||
typeInfo: { executorId: string; label: string; description: string };
|
||||
typeInfo: ExecutorTypeInfo;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { items: secrets } = useSecrets();
|
||||
const form = useCreateProfileFormState(executorType);
|
||||
const { saving, error, handleSave } = useCreateProfileSave(typeInfo.executorId);
|
||||
const spritesTokenMissing = form.isSprites && !form.spritesSecretId;
|
||||
const disabledReason = getCreateDisabledReason(form, spritesTokenMissing, saving);
|
||||
const disabledReasonKey = getCreateDisabledReasonKey(form, spritesTokenMissing, saving);
|
||||
const savePayload = buildCreateProfilePayload(form);
|
||||
const saveRevision = serializeSettingsRevision(savePayload);
|
||||
useSettingsSaveContributor({
|
||||
id: `executor-profile:new:${typeInfo.executorId}`,
|
||||
revision: saveRevision,
|
||||
isDirty: true,
|
||||
canSave: !disabledReason,
|
||||
invalidReason: disabledReason ?? undefined,
|
||||
canSave: !disabledReasonKey,
|
||||
invalidReason: disabledReasonKey ? t(disabledReasonKey) : undefined,
|
||||
save: () => handleSave(savePayload),
|
||||
discard: () => undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<CreateProfileHeader
|
||||
type={executorType}
|
||||
label={typeInfo.label}
|
||||
description={typeInfo.description}
|
||||
/>
|
||||
<CreateProfileHeader type={executorType} typeInfo={typeInfo} />
|
||||
<fieldset disabled={saving} className="space-y-8">
|
||||
<CreateProfileSections executorType={executorType} form={form} secrets={secrets} />
|
||||
</fieldset>
|
||||
{spritesTokenMissing && (
|
||||
<p className="text-sm text-destructive">Sprites API key is required.</p>
|
||||
<p className="text-sm text-destructive">{t("executors:spritesApiKeyIsRequired")}</p>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -80,6 +81,7 @@ export function SSHCreatePage() {
|
||||
}
|
||||
|
||||
function SSHCreateHeader() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
@@ -87,13 +89,13 @@ function SSHCreateHeader() {
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<IconTerminal2 className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">New SSH Executor</h2>
|
||||
<h2 className="text-2xl font-bold">{t("executors:newSshExecutor")}</h2>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{getExecutorLabel("ssh")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Connect to a remote Linux amd64 or macOS host and run agentctl there.
|
||||
{t("executors:sshCreateDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -102,7 +104,7 @@ function SSHCreateHeader() {
|
||||
onClick={() => router.push(EXECUTORS_ROUTE)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Back to Executors
|
||||
{t("executors:backToExecutors")}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
@@ -44,25 +45,42 @@ function useAllProfiles(): ProfileWithExecutor[] {
|
||||
|
||||
const DefaultIcon = EXECUTOR_ICON_MAP.local;
|
||||
|
||||
const EXECUTOR_TYPES = [
|
||||
{ type: "local", label: "Local", description: "Run agents directly in the repository folder." },
|
||||
// `type` is the persisted executor enum and is also the create route's path
|
||||
// segment — never copy. `brandLabel` is a brand/protocol name rendered
|
||||
// verbatim; every other label and description travels as a catalog key and
|
||||
// resolves at render. These sentences are imperative and deliberately differ
|
||||
// from the third-person ones the create and edit headers show; they are not
|
||||
// twins of `executors:description*` and must not be folded into them.
|
||||
type ExecutorTypeCard = {
|
||||
type: string;
|
||||
labelKey?: string;
|
||||
brandLabel?: string;
|
||||
descriptionKey: string;
|
||||
};
|
||||
|
||||
const EXECUTOR_TYPES: readonly ExecutorTypeCard[] = [
|
||||
{
|
||||
type: "local",
|
||||
labelKey: "executors:typeLocal",
|
||||
descriptionKey: "executors:hubDescriptionLocal",
|
||||
},
|
||||
{
|
||||
type: "worktree",
|
||||
label: "Worktree",
|
||||
description: "Create git worktrees for isolated agent sessions.",
|
||||
labelKey: "executors:typeWorktree",
|
||||
descriptionKey: "executors:hubDescriptionWorktree",
|
||||
},
|
||||
{
|
||||
type: "local_docker",
|
||||
brandLabel: "Docker",
|
||||
descriptionKey: "executors:hubDescriptionDocker",
|
||||
},
|
||||
{ type: "local_docker", label: "Docker", description: "Run Docker containers on this machine." },
|
||||
{
|
||||
type: "sprites",
|
||||
label: "Sprites.dev",
|
||||
description: "Run agents in Sprites.dev cloud sandboxes.",
|
||||
brandLabel: "Sprites.dev",
|
||||
descriptionKey: "executors:hubDescriptionSprites",
|
||||
},
|
||||
{
|
||||
type: "ssh",
|
||||
label: "SSH",
|
||||
description: "Connect to a remote host over SSH and run agentctl there.",
|
||||
},
|
||||
] as const;
|
||||
{ type: "ssh", brandLabel: "SSH", descriptionKey: "executors:hubDescriptionSsh" },
|
||||
];
|
||||
|
||||
function ExecutorIconBadge({ type }: { type: string }) {
|
||||
const Icon = EXECUTOR_ICON_MAP[type] ?? DefaultIcon;
|
||||
@@ -80,6 +98,9 @@ function ProfileCard({
|
||||
profile: ProfileWithExecutor;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
// Subscribes so the `getExecutorLabel` badge below re-renders on a locale
|
||||
// switch; the helper resolves at call time but does not itself notify React.
|
||||
useTranslation();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Card
|
||||
@@ -114,9 +135,10 @@ function CreateTypeCard({
|
||||
execType,
|
||||
onClick,
|
||||
}: {
|
||||
execType: (typeof EXECUTOR_TYPES)[number];
|
||||
execType: ExecutorTypeCard;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer ring-primary/40 transition-colors hover:bg-muted/50"
|
||||
@@ -125,8 +147,8 @@ function CreateTypeCard({
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<ExecutorIconBadge type={execType.type} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium">{execType.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{execType.description}</p>
|
||||
<p className="font-medium">{execType.brandLabel ?? t(execType.labelKey as string)}</p>
|
||||
<p className="text-xs text-muted-foreground">{t(execType.descriptionKey)}</p>
|
||||
</div>
|
||||
<IconPlus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</CardContent>
|
||||
@@ -147,18 +169,19 @@ function DeleteProfileDialog({
|
||||
onDelete: () => void;
|
||||
deleting: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogTitle>{t("executors:deleteProfile")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{profileName}"? This action cannot be undone.
|
||||
{t("executors:deleteProfileConfirm", { name: profileName ?? "" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -166,7 +189,7 @@ function DeleteProfileDialog({
|
||||
disabled={deleting}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
{deleting ? t("executors:deleting") : t("executors:delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -175,6 +198,7 @@ function DeleteProfileDialog({
|
||||
}
|
||||
|
||||
export default function ExecutorsHubPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const allProfiles = useAllProfiles();
|
||||
const executors = useAppStore((state) => state.executors.items);
|
||||
@@ -207,15 +231,12 @@ export default function ExecutorsHubPage() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Executors</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Executor profiles define how and where agents run. Each profile configures an execution
|
||||
environment with scripts, environment variables, and MCP policies.
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold">{t("common:executors")}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("executors:hubDescription")}</p>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Create New Profile</h3>
|
||||
<h3 className="text-lg font-semibold">{t("executors:createNewProfile")}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{EXECUTOR_TYPES.map((execType) => (
|
||||
<CreateTypeCard
|
||||
@@ -228,7 +249,7 @@ export default function ExecutorsHubPage() {
|
||||
</div>
|
||||
{allProfiles.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Profiles</h3>
|
||||
<h3 className="text-lg font-semibold">{t("executors:profiles")}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{allProfiles.map((profile) => (
|
||||
<ProfileCard key={profile.id} profile={profile} onDelete={setDeleteProfileId} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -34,27 +35,31 @@ export default function SSHExecutorPage({ executorId }: { executorId: string })
|
||||
}
|
||||
|
||||
function SSHExecutorPageContent({ executorId }: { executorId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { executor, loading, error, reload } = useExecutor(executorId);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
Loading executor...
|
||||
{t("executors:loadingExecutor")}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (error || !executor) {
|
||||
return <NotFoundCard message={error ?? "Executor not found"} />;
|
||||
return <NotFoundCard message={error ?? t("executors:executorNotFound")} />;
|
||||
}
|
||||
if (executor.type !== "ssh") {
|
||||
return <NotFoundCard message={`Executor ${executor.id} is not an SSH executor`} />;
|
||||
// The executor id is an identifier the user may need to look up, so it is
|
||||
// interpolated as a value rather than written into the message.
|
||||
return <NotFoundCard message={t("executors:notAnSshExecutor", { id: executor.id })} />;
|
||||
}
|
||||
return <SSHExecutorView executor={executor} onSaved={reload} />;
|
||||
}
|
||||
|
||||
function useExecutor(executorId: string) {
|
||||
const { t } = useTranslation();
|
||||
const [executor, setExecutor] = useState<LoadedExecutor | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -72,14 +77,14 @@ function useExecutor(executorId: string) {
|
||||
}
|
||||
} catch (e) {
|
||||
if (generation === requestGeneration.current) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load executor");
|
||||
setError(e instanceof Error ? e.message : t("executors:failedToLoadExecutor"));
|
||||
}
|
||||
} finally {
|
||||
if (generation === requestGeneration.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [executorId]);
|
||||
}, [executorId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -92,13 +97,14 @@ function useExecutor(executorId: string) {
|
||||
}
|
||||
|
||||
function NotFoundCard({ message }: { message: string }) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-muted-foreground">{message}</p>
|
||||
<Button className="mt-4 cursor-pointer" onClick={() => router.push(EXECUTORS_ROUTE)}>
|
||||
Back to Executors
|
||||
{t("executors:backToExecutors")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -134,6 +140,7 @@ function SSHExecutorView({
|
||||
}
|
||||
|
||||
function SSHExecutorHeader({ executorName }: { executorName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
@@ -147,8 +154,7 @@ function SSHExecutorHeader({ executorName }: { executorName: string }) {
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Edit the connection settings or re-trust the host. Existing sessions keep their snapshot
|
||||
of the previous config.
|
||||
{t("executors:sshExecutorPageDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -157,7 +163,7 @@ function SSHExecutorHeader({ executorName }: { executorName: string }) {
|
||||
onClick={() => router.push(EXECUTORS_ROUTE)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Back to Executors
|
||||
{t("executors:backToExecutors")}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DestinationRows } from "@/components/navigation/destination-rows";
|
||||
import { resolveDestinations } from "@/lib/navigation/resolve-destinations";
|
||||
import { NO_WORKSPACE_CONTEXT } from "@/lib/navigation/surface-policy";
|
||||
@@ -19,6 +20,7 @@ type MobilePluginNavSectionProps = {
|
||||
* `MobileIntegrationsSection`.
|
||||
*/
|
||||
export function MobilePluginNavSection({ onNavigate }: MobilePluginNavSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const registry = usePluginRegistry();
|
||||
// Resolved directly rather than through `useStaticDestinations`: this group's
|
||||
// hrefs are static plugin paths, so it needs neither workspace context nor the
|
||||
@@ -34,7 +36,7 @@ export function MobilePluginNavSection({ onNavigate }: MobilePluginNavSectionPro
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="mobile-plugin-nav-section">
|
||||
<div className="text-sm font-medium">Plugins</div>
|
||||
<div className="text-sm font-medium">{t("common:plugins")}</div>
|
||||
<DestinationRows
|
||||
destinations={destinations}
|
||||
onNavigate={onNavigate}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PluginErrorBoundaryProps = {
|
||||
/** Identifies what threw in the console log, e.g. `slot "task-sidebar"` or `route "/plugins/hello"`. */
|
||||
/**
|
||||
* Identifies what threw in the console log, e.g. `slot "task-sidebar"` or
|
||||
* `route "/plugins/hello"`. A developer diagnostic that never reaches the
|
||||
* DOM — `context` is in the guard's `jsx-attributes.exclude` for that reason.
|
||||
*/
|
||||
context: string;
|
||||
/** Rendered in place of `children` after a throw. Defaults to nothing (silent). */
|
||||
fallback?: ReactNode;
|
||||
@@ -42,9 +47,10 @@ export class PluginErrorBoundary extends Component<
|
||||
|
||||
/** Shared fallback for a plugin-owned top-level or settings route that threw during render. */
|
||||
export function PluginRouteFallback() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
|
||||
This plugin page failed to load.
|
||||
{t("plugins:pluginPageFailedToLoad")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ function PluginModalInstance({ modal }: { modal: OpenPluginModal }) {
|
||||
onEscapeKeyDown={guardClose}
|
||||
onInteractOutside={guardClose}
|
||||
>
|
||||
{/* `options.title` is supplied by the plugin author, so it is
|
||||
third-party data rather than host copy. */}
|
||||
{options.title && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{options.title}</DialogTitle>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* Byte-for-byte English for the two Account route headers, as `SETTINGS_ROUTES`
|
||||
* in `src/settings-routes.tsx` rendered them before this migration. They were
|
||||
* the last two inline English entries in that table.
|
||||
*
|
||||
* Same reasoning as `components/settings/system/system-route-copy.test.ts`:
|
||||
* every gate in the repo verifies that a key *resolves*, not that it resolves
|
||||
* to the sentence the route used to render, so a plausible-looking key reuse
|
||||
* can rewrite shipping copy with nothing failing. This table is the check.
|
||||
*/
|
||||
const ROUTE_COPY: Array<{ route: string; titleKey: string; title: string; description: string }> = [
|
||||
{
|
||||
route: "security",
|
||||
titleKey: "account:securityPageTitle",
|
||||
title: "Profile & password",
|
||||
description: "Change your password and review devices signed in to your account.",
|
||||
},
|
||||
{
|
||||
route: "tokens",
|
||||
titleKey: "account:tokensPageTitle",
|
||||
title: "API tokens",
|
||||
description: "Personal access tokens for scripts and CLIs acting as you.",
|
||||
},
|
||||
];
|
||||
|
||||
describe("account route copy", () => {
|
||||
it.each(ROUTE_COPY)("renders /settings/account/$route unchanged", (entry) => {
|
||||
expect(t(entry.titleKey)).toBe(entry.title);
|
||||
expect(t(`account:${entry.route}PageDescription`)).toBe(entry.description);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import {
|
||||
@@ -18,8 +19,10 @@ import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { listTokens, mintToken, revokeToken, type ApiToken } from "@/lib/api/domains/auth-api";
|
||||
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
|
||||
import { formatDateTime } from "@/lib/i18n/formats";
|
||||
|
||||
function useTokensList() {
|
||||
const { t } = useTranslation();
|
||||
const [tokens, setTokens] = useState<ApiToken[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -31,9 +34,9 @@ function useTokensList() {
|
||||
setTokens(res.tokens);
|
||||
setLoaded(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Failed to load tokens.");
|
||||
setError(err instanceof ApiError ? err.message : t("account:failedToLoadTokens"));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
@@ -53,13 +56,12 @@ function MintTokenResult({
|
||||
onCopy: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Token created</DialogTitle>
|
||||
<DialogDescription>
|
||||
This is shown only once — copy it now and store it securely.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("account:tokenCreated")}</DialogTitle>
|
||||
<DialogDescription>{t("account:tokenShownOnlyOnce")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
@@ -80,7 +82,7 @@ function MintTokenResult({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button className="cursor-pointer" onClick={onDone} data-dialog-default-action>
|
||||
Done
|
||||
{t("account:done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@@ -102,18 +104,16 @@ function MintTokenForm({
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create API token</DialogTitle>
|
||||
<DialogDescription>
|
||||
Grants the same access as your account to any script or CLI that uses it. Give it a name
|
||||
so you can recognize it later.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("account:createApiToken")}</DialogTitle>
|
||||
<DialogDescription>{t("account:createApiTokenDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="api-tokens-name" className="text-xs text-muted-foreground">
|
||||
Name
|
||||
{t("account:name")}
|
||||
</label>
|
||||
<Input
|
||||
id="api-tokens-name"
|
||||
@@ -129,7 +129,7 @@ function MintTokenForm({
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="cursor-pointer" onClick={onCancel}>
|
||||
Cancel
|
||||
{t("account:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
className="cursor-pointer"
|
||||
@@ -137,7 +137,7 @@ function MintTokenForm({
|
||||
onClick={onSubmit}
|
||||
data-testid="api-tokens-mint-submit"
|
||||
>
|
||||
{submitting ? "Creating..." : "Create token"}
|
||||
{submitting ? t("account:creating") : t("account:createToken")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
@@ -153,6 +153,7 @@ function MintTokenDialog({
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -174,7 +175,7 @@ function MintTokenDialog({
|
||||
setRawToken(res.token);
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Could not create token.");
|
||||
setError(err instanceof ApiError ? err.message : t("account:couldNotCreateToken"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -216,6 +217,7 @@ function MintTokenDialog({
|
||||
}
|
||||
|
||||
export function ApiTokens() {
|
||||
const { t } = useTranslation();
|
||||
const { tokens, loaded, error, reload } = useTokensList();
|
||||
const [mintOpen, setMintOpen] = useState(false);
|
||||
|
||||
@@ -228,7 +230,7 @@ export function ApiTokens() {
|
||||
<Card data-testid="api-tokens-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconKey className="h-4 w-4" /> API tokens
|
||||
<IconKey className="h-4 w-4" /> {t("account:apiTokens")}
|
||||
</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -236,14 +238,11 @@ export function ApiTokens() {
|
||||
onClick={() => setMintOpen(true)}
|
||||
data-testid="api-tokens-create"
|
||||
>
|
||||
New token
|
||||
{t("account:newToken")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Personal access tokens authenticate scripts and CLIs as you. Revoke a token immediately if
|
||||
it may have leaked.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("account:apiTokensBlurb")}</p>
|
||||
{error && (
|
||||
<p className="text-xs text-destructive" data-testid="api-tokens-error">
|
||||
{error}
|
||||
@@ -251,28 +250,26 @@ export function ApiTokens() {
|
||||
)}
|
||||
{!loaded && !error && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" /> Loading tokens...
|
||||
<Spinner className="size-4" /> {t("account:loadingTokens")}
|
||||
</div>
|
||||
)}
|
||||
{loaded && tokens.length > 0 && (
|
||||
<Table data-testid="api-tokens-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Last used</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead>{t("account:name")}</TableHead>
|
||||
<TableHead>{t("account:created")}</TableHead>
|
||||
<TableHead>{t("account:lastUsed")}</TableHead>
|
||||
<TableHead className="text-right">{t("account:actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tokens.map((token) => (
|
||||
<TableRow key={token.id} data-testid="api-tokens-row">
|
||||
<TableCell className="text-xs">{token.name}</TableCell>
|
||||
<TableCell className="text-xs">{formatDateTime(token.created_at)}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{new Date(token.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : "Never"}
|
||||
{token.last_used_at ? formatDateTime(token.last_used_at) : t("account:never")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
@@ -282,7 +279,7 @@ export function ApiTokens() {
|
||||
onClick={() => void onRevoke(token.id)}
|
||||
data-testid="api-tokens-revoke"
|
||||
>
|
||||
Revoke
|
||||
{t("account:revoke")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -292,7 +289,7 @@ export function ApiTokens() {
|
||||
)}
|
||||
{loaded && tokens.length === 0 && !error && (
|
||||
<p className="text-sm text-muted-foreground" data-testid="api-tokens-empty">
|
||||
No tokens yet.
|
||||
{t("account:noTokensYet")}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
@@ -15,8 +16,10 @@ import {
|
||||
revokeSession,
|
||||
type AuthSession,
|
||||
} from "@/lib/api/domains/auth-api";
|
||||
import { formatDateTime } from "@/lib/i18n/formats";
|
||||
|
||||
function ChangePasswordCard() {
|
||||
const { t } = useTranslation();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -34,7 +37,7 @@ function ChangePasswordCard() {
|
||||
setNext("");
|
||||
setSuccess(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Could not change password.");
|
||||
setError(err instanceof ApiError ? err.message : t("account:couldNotChangePassword"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -44,14 +47,14 @@ function ChangePasswordCard() {
|
||||
<Card data-testid="account-security-password-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconKey className="h-4 w-4" /> Password
|
||||
<IconKey className="h-4 w-4" /> {t("account:password")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col gap-3 max-w-sm" onSubmit={(e) => void onSubmit(e)}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="account-current-password" className="text-xs text-muted-foreground">
|
||||
Current password
|
||||
{t("account:currentPassword")}
|
||||
</label>
|
||||
<Input
|
||||
id="account-current-password"
|
||||
@@ -63,7 +66,7 @@ function ChangePasswordCard() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label htmlFor="account-new-password" className="text-xs text-muted-foreground">
|
||||
New password
|
||||
{t("account:newPassword")}
|
||||
</label>
|
||||
<Input
|
||||
id="account-new-password"
|
||||
@@ -81,7 +84,7 @@ function ChangePasswordCard() {
|
||||
)}
|
||||
{success && (
|
||||
<p className="text-xs text-muted-foreground" data-testid="account-password-success">
|
||||
Password updated.
|
||||
{t("account:passwordUpdated")}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
@@ -90,7 +93,7 @@ function ChangePasswordCard() {
|
||||
disabled={submitting}
|
||||
data-testid="account-password-submit"
|
||||
>
|
||||
{submitting ? "Saving..." : "Change password"}
|
||||
{submitting ? t("account:saving") : t("account:changePassword")}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -99,6 +102,7 @@ function ChangePasswordCard() {
|
||||
}
|
||||
|
||||
function useSessionsList() {
|
||||
const { t } = useTranslation();
|
||||
const [sessions, setSessions] = useState<AuthSession[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -110,9 +114,9 @@ function useSessionsList() {
|
||||
setSessions(res.sessions);
|
||||
setLoaded(true);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Failed to load sessions.");
|
||||
setError(err instanceof ApiError ? err.message : t("account:failedToLoadSessions"));
|
||||
}
|
||||
}, []);
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
@@ -122,6 +126,7 @@ function useSessionsList() {
|
||||
}
|
||||
|
||||
function SessionsCard() {
|
||||
const { t } = useTranslation();
|
||||
const { sessions, loaded, error, reload } = useSessionsList();
|
||||
|
||||
const onRevoke = async (id: string) => {
|
||||
@@ -133,7 +138,7 @@ function SessionsCard() {
|
||||
<Card data-testid="account-sessions-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<IconDevices className="h-4 w-4" /> Active sessions
|
||||
<IconDevices className="h-4 w-4" /> {t("account:activeSessions")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
@@ -144,17 +149,17 @@ function SessionsCard() {
|
||||
)}
|
||||
{!loaded && !error && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner className="size-4" /> Loading sessions...
|
||||
<Spinner className="size-4" /> {t("account:loadingSessions")}
|
||||
</div>
|
||||
)}
|
||||
{loaded && sessions.length > 0 && (
|
||||
<Table data-testid="account-sessions-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Device</TableHead>
|
||||
<TableHead>IP</TableHead>
|
||||
<TableHead>Last seen</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
<TableHead>{t("account:device")}</TableHead>
|
||||
<TableHead>{t("account:ipAddress")}</TableHead>
|
||||
<TableHead>{t("account:lastSeen")}</TableHead>
|
||||
<TableHead className="text-right">{t("account:actions")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -164,14 +169,12 @@ function SessionsCard() {
|
||||
{session.user_agent}
|
||||
{session.current && (
|
||||
<Badge variant="default" className="ml-2 text-[10px]">
|
||||
This device
|
||||
{t("account:thisDevice")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{session.ip}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{new Date(session.last_seen_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">{formatDateTime(session.last_seen_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{!session.current && (
|
||||
<Button
|
||||
@@ -181,7 +184,7 @@ function SessionsCard() {
|
||||
onClick={() => void onRevoke(session.id)}
|
||||
data-testid="account-sessions-revoke"
|
||||
>
|
||||
Sign out
|
||||
{t("account:signOut")}
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { activateLocale, i18n, t } from "@/lib/i18n";
|
||||
import { getExecutorLabel } from "@/lib/executor-icons";
|
||||
import {
|
||||
EXECUTOR_TYPE_MAP,
|
||||
executorTypeLabel,
|
||||
} from "@/app/settings/executors/new/[type]/executor-types";
|
||||
import { validateMcpPolicy } from "@/components/settings/profile-edit/mcp-policy-card";
|
||||
import { getExecutorDescription } from "./executor-description";
|
||||
|
||||
/**
|
||||
* Byte-for-byte English for the executor type labels and descriptions, as they
|
||||
* rendered before this migration.
|
||||
*
|
||||
* This exists because the copy is duplicated across THREE surfaces that had
|
||||
* already drifted from each other:
|
||||
*
|
||||
* - `/settings/executors` (the hub cards) uses the imperative mood
|
||||
* ("Run agents directly in the repository folder.");
|
||||
* - `/settings/executors/new/:type` (the create header) uses the third
|
||||
* person ("Runs agents directly in the repository folder.");
|
||||
* - `/settings/executors/:profileId` (the edit header) uses the third person
|
||||
* too, and shares those five sentences — but its SSH sentence is a
|
||||
* different one again.
|
||||
*
|
||||
* Folding them onto one key set would have rewritten user-visible English on
|
||||
* two of the three, and every gate would still have passed. The identical
|
||||
* sentences share a key; the ones that differ keep their own. This table is
|
||||
* what says so after the reviewer leaves.
|
||||
*
|
||||
* De-duplicating the three tables is a behaviour change and belongs in its own
|
||||
* PR — see the guard comment in eslint.i18n.options.mjs.
|
||||
*/
|
||||
const SPRITES_DEV = "Sprites.dev";
|
||||
const SSH = "SSH";
|
||||
|
||||
const HUB_CARD_COPY: Array<{ type: string; label: string; description: string }> = [
|
||||
{ type: "local", label: "Local", description: "Run agents directly in the repository folder." },
|
||||
{
|
||||
type: "worktree",
|
||||
label: "Worktree",
|
||||
description: "Create git worktrees for isolated agent sessions.",
|
||||
},
|
||||
{
|
||||
type: "local_docker",
|
||||
label: "Docker",
|
||||
description: "Run Docker containers on this machine.",
|
||||
},
|
||||
{
|
||||
type: "sprites",
|
||||
label: SPRITES_DEV,
|
||||
description: "Run agents in Sprites.dev cloud sandboxes.",
|
||||
},
|
||||
{
|
||||
type: "ssh",
|
||||
label: SSH,
|
||||
description: "Connect to a remote host over SSH and run agentctl there.",
|
||||
},
|
||||
];
|
||||
|
||||
const HUB_CARD_KEYS: Record<string, { labelKey?: string; brand?: string; descriptionKey: string }> =
|
||||
{
|
||||
local: { labelKey: "executors:typeLocal", descriptionKey: "executors:hubDescriptionLocal" },
|
||||
worktree: {
|
||||
labelKey: "executors:typeWorktree",
|
||||
descriptionKey: "executors:hubDescriptionWorktree",
|
||||
},
|
||||
local_docker: { brand: "Docker", descriptionKey: "executors:hubDescriptionDocker" },
|
||||
sprites: { brand: SPRITES_DEV, descriptionKey: "executors:hubDescriptionSprites" },
|
||||
ssh: { brand: SSH, descriptionKey: "executors:hubDescriptionSsh" },
|
||||
};
|
||||
|
||||
describe("executors hub card copy", () => {
|
||||
it.each(HUB_CARD_COPY)("renders $type unchanged", ({ type, label, description }) => {
|
||||
const entry = HUB_CARD_KEYS[type];
|
||||
expect(entry.brand ?? t(entry.labelKey as string)).toBe(label);
|
||||
expect(t(entry.descriptionKey)).toBe(description);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create-page executor type registry", () => {
|
||||
const CREATE_COPY: Array<{ type: string; label: string; description: string }> = [
|
||||
{
|
||||
type: "local",
|
||||
label: "Local",
|
||||
description: "Runs agents directly in the repository folder.",
|
||||
},
|
||||
{
|
||||
type: "worktree",
|
||||
label: "Worktree",
|
||||
description: "Creates git worktrees for isolated agent sessions.",
|
||||
},
|
||||
{
|
||||
type: "local_docker",
|
||||
label: "Docker",
|
||||
description: "Runs Docker containers on this machine.",
|
||||
},
|
||||
{
|
||||
type: "remote_docker",
|
||||
label: "Remote Docker",
|
||||
description: "Connects to a remote Docker host.",
|
||||
},
|
||||
{
|
||||
type: "sprites",
|
||||
label: SPRITES_DEV,
|
||||
description: "Runs agents in Sprites.dev cloud sandboxes.",
|
||||
},
|
||||
{
|
||||
type: "ssh",
|
||||
label: SSH,
|
||||
description: "Connects to a remote host over SSH and runs agentctl there.",
|
||||
},
|
||||
];
|
||||
|
||||
it.each(CREATE_COPY)("renders $type unchanged", ({ type, label, description }) => {
|
||||
const info = EXECUTOR_TYPE_MAP[type];
|
||||
expect(executorTypeLabel(info, t)).toBe(label);
|
||||
expect(t(info.descriptionKey)).toBe(description);
|
||||
});
|
||||
|
||||
it("keeps the persisted executor enum and backend ids out of the catalog", () => {
|
||||
expect(Object.keys(EXECUTOR_TYPE_MAP).sort()).toEqual([
|
||||
"local",
|
||||
"local_docker",
|
||||
"remote_docker",
|
||||
"sprites",
|
||||
"ssh",
|
||||
"worktree",
|
||||
]);
|
||||
expect(EXECUTOR_TYPE_MAP.ssh.executorId).toBe("exec-ssh");
|
||||
expect(EXECUTOR_TYPE_MAP.local_docker.executorId).toBe("exec-local-docker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExecutorDescription", () => {
|
||||
it.each([
|
||||
["local_pc", "Runs agents directly in the repository folder."],
|
||||
["worktree", "Creates git worktrees for isolated agent sessions."],
|
||||
["local_docker", "Runs Docker containers on this machine."],
|
||||
["remote_docker", "Connects to a remote Docker host."],
|
||||
["sprites", "Runs agents in Sprites.dev cloud sandboxes."],
|
||||
["ssh", "Runs agents on a trusted Linux amd64 or macOS host over SSH."],
|
||||
] as const)("renders %s unchanged", (type, expected) => {
|
||||
expect(getExecutorDescription(type)).toBe(expected);
|
||||
});
|
||||
|
||||
it("falls back to the custom-executor sentence", () => {
|
||||
expect(getExecutorDescription("something-else" as never)).toBe("Custom executor.");
|
||||
});
|
||||
|
||||
it("differs from the create page on SSH, which is why they do not share a key", () => {
|
||||
expect(getExecutorDescription("ssh")).not.toBe(t(EXECUTOR_TYPE_MAP.ssh.descriptionKey));
|
||||
});
|
||||
});
|
||||
|
||||
describe("getExecutorLabel", () => {
|
||||
it("returns brand and protocol names verbatim rather than from the catalog", () => {
|
||||
expect(getExecutorLabel("sprites")).toBe(SPRITES_DEV);
|
||||
expect(getExecutorLabel("ssh")).toBe(SSH);
|
||||
});
|
||||
|
||||
it("renders the translated labels unchanged", () => {
|
||||
expect(getExecutorLabel("local")).toBe("Local");
|
||||
expect(getExecutorLabel("worktree")).toBe("Worktree");
|
||||
expect(getExecutorLabel("local_docker")).toBe("Local Docker");
|
||||
expect(getExecutorLabel("remote_docker")).toBe("Remote Docker");
|
||||
});
|
||||
|
||||
it("echoes an unmapped wire value", () => {
|
||||
expect(getExecutorLabel("does-not-exist")).toBe("does-not-exist");
|
||||
});
|
||||
|
||||
// The label used to be a module-scope constant. It resolves from the catalog
|
||||
// now, so this pins that it resolves at CALL time rather than at import — the
|
||||
// property every consumer's re-render depends on.
|
||||
it("follows a locale switch", async () => {
|
||||
const before = getExecutorLabel("local");
|
||||
await activateLocale("pseudo");
|
||||
expect(getExecutorLabel("local")).not.toBe(before);
|
||||
expect(getExecutorLabel("local")).toBe(t("executors:typeLocal"));
|
||||
// Brand names are not catalog values, so they are unaffected.
|
||||
expect(getExecutorLabel("ssh")).toBe(SSH);
|
||||
await activateLocale("en");
|
||||
expect(getExecutorLabel("local")).toBe(before);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (i18n.language !== "en") await activateLocale("en");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateMcpPolicy", () => {
|
||||
it("returns null for empty and valid policies", () => {
|
||||
expect(validateMcpPolicy(undefined)).toBeNull();
|
||||
expect(validateMcpPolicy(" ")).toBeNull();
|
||||
expect(validateMcpPolicy('{"allow_stdio":true}')).toBeNull();
|
||||
});
|
||||
|
||||
it("returns a catalog key rather than English for unparseable JSON", () => {
|
||||
const key = validateMcpPolicy("{oops");
|
||||
expect(key).toBe("executors:invalidJson");
|
||||
expect(t(key as string)).toBe("Invalid JSON");
|
||||
});
|
||||
|
||||
it("returns a catalog key rather than English for a non-object policy", () => {
|
||||
for (const raw of ["[1,2]", '"nope"', "42"]) {
|
||||
const key = validateMcpPolicy(raw);
|
||||
expect(key).toBe("executors:mcpPolicyMustBeAJsonObject");
|
||||
expect(t(key as string)).toBe("MCP policy must be a JSON object");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,20 @@
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { ExecutorType } from "@/lib/types/http";
|
||||
|
||||
/**
|
||||
* Resolves at call time rather than module load, so the description follows a
|
||||
* locale switch. The five non-SSH sentences are byte-identical to the ones the
|
||||
* `/settings/executor/:id` route already renders, so they share its keys;
|
||||
* `descriptionSsh` is this surface's own sentence and deliberately differs from
|
||||
* the one `app/settings/executors/new/[type]/executor-types.ts` shows on the
|
||||
* create page — see the guard comment in eslint.i18n.options.mjs.
|
||||
*/
|
||||
export function getExecutorDescription(type: ExecutorType): string {
|
||||
if (type === "local_pc") return "Runs agents directly in the repository folder.";
|
||||
if (type === "worktree") return "Creates git worktrees for isolated agent sessions.";
|
||||
if (type === "local_docker") return "Runs Docker containers on this machine.";
|
||||
if (type === "remote_docker") return "Connects to a remote Docker host.";
|
||||
if (type === "sprites") return "Runs agents in Sprites.dev cloud sandboxes.";
|
||||
if (type === "ssh") return "Runs agents on a trusted Linux amd64 or macOS host over SSH.";
|
||||
return "Custom executor.";
|
||||
if (type === "local_pc") return t("executors:descriptionLocalPc");
|
||||
if (type === "worktree") return t("executors:descriptionWorktree");
|
||||
if (type === "local_docker") return t("executors:descriptionLocalDocker");
|
||||
if (type === "remote_docker") return t("executors:descriptionRemoteDocker");
|
||||
if (type === "sprites") return t("executors:descriptionSprites");
|
||||
if (type === "ssh") return t("executors:descriptionSsh");
|
||||
return t("executors:descriptionCustom");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -40,6 +41,7 @@ export function InstallPluginDialog({
|
||||
onSubmitUrl,
|
||||
onSubmitFile,
|
||||
}: InstallPluginDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<InstallTab>("url");
|
||||
const [url, setUrl] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
@@ -79,9 +81,11 @@ export function InstallPluginDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg" data-testid="install-plugin-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Install plugin</DialogTitle>
|
||||
<DialogTitle>{t("plugins:installPlugin")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Install a plugin package from a URL, or upload a .tar.gz file directly.
|
||||
{/* `.tar.gz` is a file extension, interpolated so the
|
||||
pseudo-locale cannot transliterate it. */}
|
||||
{t("plugins:installPluginDescription", { extension: ".tar.gz" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -92,14 +96,14 @@ export function InstallPluginDialog({
|
||||
className="cursor-pointer"
|
||||
data-testid="install-plugin-tab-url"
|
||||
>
|
||||
From URL
|
||||
{t("plugins:fromUrl")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="upload"
|
||||
className="cursor-pointer"
|
||||
data-testid="install-plugin-tab-upload"
|
||||
>
|
||||
Upload file
|
||||
{t("plugins:uploadFile")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="url" className="pt-3">
|
||||
@@ -123,7 +127,7 @@ export function InstallPluginDialog({
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
{t("plugins:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -132,7 +136,7 @@ export function InstallPluginDialog({
|
||||
disabled={install.disabled}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Install
|
||||
{t("plugins:install")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
@@ -141,9 +145,10 @@ export function InstallPluginDialog({
|
||||
}
|
||||
|
||||
function UrlTab({ url, setUrl }: { url: string; setUrl: (v: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="install-plugin-url">Package URL</Label>
|
||||
<Label htmlFor="install-plugin-url">{t("plugins:packageUrl")}</Label>
|
||||
<Input
|
||||
id="install-plugin-url"
|
||||
data-testid="install-plugin-url-input"
|
||||
@@ -157,6 +162,7 @@ function UrlTab({ url, setUrl }: { url: string; setUrl: (v: string) => void }) {
|
||||
}
|
||||
|
||||
function UploadTab({ file, setFile }: { file: File | null; setFile: (file: File | null) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
@@ -202,7 +208,7 @@ function UploadTab({ file, setFile }: { file: File | null; setFile: (file: File
|
||||
{file ? (
|
||||
<span className="font-mono text-foreground">{file.name}</span>
|
||||
) : (
|
||||
"Drag and drop a .tar.gz package here, or click to browse"
|
||||
t("plugins:dropzoneHint", { extension: ".tar.gz" })
|
||||
)}
|
||||
</span>
|
||||
<input
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import { useMarketplace } from "@/hooks/domains/plugins/use-marketplace";
|
||||
import type { CatalogQuery } from "@/lib/api/domains/marketplace-api";
|
||||
import type { MarketplaceCatalog, MarketplaceEntry } from "@/lib/types/plugins";
|
||||
import type { MarketplaceCatalog, MarketplaceEntry, MarketplaceSource } from "@/lib/types/plugins";
|
||||
import { MarketplaceEntryRow } from "./marketplace-entry-row";
|
||||
import { MarketplaceSourcesDialog } from "./marketplace-sources-dialog";
|
||||
|
||||
@@ -107,10 +108,11 @@ type ToolbarProps = {
|
||||
};
|
||||
|
||||
function MarketplaceToolbar(props: ToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
placeholder="Search plugins…"
|
||||
placeholder={t("plugins:searchPlugins")}
|
||||
value={props.text}
|
||||
onChange={(e) => props.onText(e.target.value)}
|
||||
className="max-w-xs"
|
||||
@@ -118,10 +120,10 @@ function MarketplaceToolbar(props: ToolbarProps) {
|
||||
/>
|
||||
<Select value={props.category} onValueChange={props.onCategory}>
|
||||
<SelectTrigger className="w-40" data-testid="marketplace-category">
|
||||
<SelectValue placeholder="Category" />
|
||||
<SelectValue placeholder={t("plugins:category")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_CATEGORIES}>All categories</SelectItem>
|
||||
<SelectItem value={ALL_CATEGORIES}>{t("plugins:allCategories")}</SelectItem>
|
||||
{props.categories.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat}
|
||||
@@ -134,15 +136,16 @@ function MarketplaceToolbar(props: ToolbarProps) {
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stars">Most stars</SelectItem>
|
||||
<SelectItem value="recent">Recently updated</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
{/* The `value`s are the catalog API's sort tokens, not copy. */}
|
||||
<SelectItem value="stars">{t("plugins:sortMostStars")}</SelectItem>
|
||||
<SelectItem value="recent">{t("plugins:sortRecentlyUpdated")}</SelectItem>
|
||||
<SelectItem value="name">{t("plugins:sortName")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={props.onRefresh} className="cursor-pointer">
|
||||
<IconRefresh className="h-4 w-4" />
|
||||
Refresh
|
||||
{t("plugins:refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -150,7 +153,7 @@ function MarketplaceToolbar(props: ToolbarProps) {
|
||||
className="cursor-pointer"
|
||||
data-testid="marketplace-manage-sources"
|
||||
>
|
||||
Sources
|
||||
{t("plugins:sources")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,15 +169,29 @@ function DegradedSourcesBanner({ catalog }: { catalog: MarketplaceCatalog }) {
|
||||
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400 space-y-1"
|
||||
>
|
||||
{degraded.map((s) => (
|
||||
<div key={s.id}>
|
||||
<span className="font-medium">{s.name}</span> is unreachable
|
||||
{s.error ? `: ${s.error}` : ""} — its plugins are hidden.
|
||||
</div>
|
||||
<DegradedSourceLine key={s.id} source={s} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The source name comes from its index.json and `detail` is the backend's own
|
||||
* network diagnostic, so both are interpolated as values rather than written
|
||||
* into the message.
|
||||
*/
|
||||
function DegradedSourceLine({ source }: { source: MarketplaceSource }) {
|
||||
const detail = source.error ? `: ${source.error}` : "";
|
||||
return (
|
||||
<div>
|
||||
<Trans i18nKey="plugins:sourceUnreachableDetail" values={{ name: source.name, detail }}>
|
||||
<span className="font-medium">{source.name}</span> is unreachable
|
||||
{detail} — its plugins are hidden.
|
||||
</Trans>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ListProps = {
|
||||
entries: MarketplaceEntry[];
|
||||
loading: boolean;
|
||||
@@ -192,6 +209,7 @@ function MarketplaceList({
|
||||
installingId,
|
||||
onInstall,
|
||||
}: ListProps) {
|
||||
const { t } = useTranslation();
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-sm text-destructive">
|
||||
@@ -202,23 +220,21 @@ function MarketplaceList({
|
||||
if (loading && entries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
Loading marketplace…
|
||||
{t("plugins:loadingMarketplace")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-sm text-muted-foreground">
|
||||
{filtered
|
||||
? "No plugins match your search. Try clearing the filters."
|
||||
: "No plugins available yet. Check back soon, or add a marketplace source with your own."}
|
||||
{filtered ? t("plugins:noPluginsMatchSearch") : t("plugins:noPluginsAvailable")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{entries.length} {entries.length === 1 ? "plugin" : "plugins"} available
|
||||
{t("plugins:pluginsAvailable", { count: entries.length })}
|
||||
</p>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{entries.map((entry) => (
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { IconArrowUpCircle, IconCheck, IconStar } from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { formatNumber } from "@/lib/i18n/formats";
|
||||
import type { MarketplaceEntry } from "@/lib/types/plugins";
|
||||
import { PluginRepoLink } from "./plugin-repo-link";
|
||||
|
||||
@@ -48,9 +50,17 @@ export function MarketplaceEntryRow({ entry, busy, onInstall }: MarketplaceEntry
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<IconStar className="h-3.5 w-3.5" />
|
||||
{entry.stars === null ? "—" : entry.stars.toLocaleString()}
|
||||
{entry.stars === null ? "—" : formatNumber(entry.stars)}
|
||||
</span>
|
||||
{entry.author && <span>by {entry.author}</span>}
|
||||
{/* The name, description, author and categories all come from the
|
||||
catalog's index.json — third-party data, not our copy. */}
|
||||
{entry.author && (
|
||||
<span>
|
||||
<Trans i18nKey="plugins:byAuthor" values={{ author: entry.author }}>
|
||||
by {entry.author}
|
||||
</Trans>
|
||||
</span>
|
||||
)}
|
||||
{entry.categories.map((cat) => (
|
||||
<Badge key={cat} variant="secondary" className="text-[10px] font-normal">
|
||||
{cat}
|
||||
@@ -92,6 +102,7 @@ function PluginTile({ entry }: { entry: MarketplaceEntry }) {
|
||||
}
|
||||
|
||||
function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowProps) {
|
||||
const { t } = useTranslation();
|
||||
if (entry.install_state === "installed") {
|
||||
return (
|
||||
<Badge
|
||||
@@ -100,7 +111,7 @@ function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowP
|
||||
className="shrink-0 gap-1 text-muted-foreground"
|
||||
>
|
||||
<IconCheck className="h-3.5 w-3.5" />
|
||||
Installed
|
||||
{t("plugins:installed")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -115,7 +126,7 @@ function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowP
|
||||
className="shrink-0 gap-1 cursor-pointer"
|
||||
>
|
||||
<IconArrowUpCircle className="h-4 w-4" />
|
||||
{busy ? "Updating…" : "Update"}
|
||||
{busy ? t("plugins:updating") : t("plugins:update")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -128,7 +139,7 @@ function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowP
|
||||
data-testid={`marketplace-install-${entry.id}`}
|
||||
className="shrink-0 cursor-pointer"
|
||||
>
|
||||
{busy ? "Installing…" : "Install"}
|
||||
{busy ? t("plugins:installing") : t("plugins:install")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { toast } from "@/lib/toast/sonner";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -39,14 +40,15 @@ export function MarketplaceSourcesDialog({
|
||||
onOpenChange,
|
||||
onChanged,
|
||||
}: MarketplaceSourcesDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Marketplace sources</DialogTitle>
|
||||
<DialogTitle>{t("plugins:marketplaceSources")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a team or corporate registry to browse its plugins alongside the official catalog.
|
||||
Each source serves an index.json document.
|
||||
{/* `index.json` is the document name a source must serve. */}
|
||||
{t("plugins:marketplaceSourcesDescription", { document: "index.json" })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -63,6 +65,7 @@ export function MarketplaceSourcesDialog({
|
||||
}
|
||||
|
||||
function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChanged: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const toggle = async (enabled: boolean) => {
|
||||
@@ -71,7 +74,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
|
||||
await updateMarketplaceSource(source.id, { enabled });
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update source");
|
||||
toast.error(err instanceof Error ? err.message : t("plugins:failedToUpdateSource"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -83,7 +86,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
|
||||
await deleteMarketplaceSource(source.id);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to remove source");
|
||||
toast.error(err instanceof Error ? err.message : t("plugins:failedToRemoveSource"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -99,12 +102,12 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
|
||||
<span className="text-sm font-medium truncate">{source.name}</span>
|
||||
{source.builtin && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Official
|
||||
{t("plugins:sourceOfficial")}
|
||||
</Badge>
|
||||
)}
|
||||
{source.healthy === false && (
|
||||
<Badge variant="destructive" className="text-[10px]">
|
||||
Unreachable
|
||||
{t("plugins:sourceUnreachable")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -119,7 +122,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
|
||||
disabled={busy}
|
||||
onClick={remove}
|
||||
className="cursor-pointer"
|
||||
aria-label={`Remove ${source.name}`}
|
||||
aria-label={t("plugins:removeSource", { name: source.name })}
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -130,6 +133,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
|
||||
}
|
||||
|
||||
function AddSourceForm({ onChanged }: { onChanged: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -143,7 +147,7 @@ function AddSourceForm({ onChanged }: { onChanged: () => void }) {
|
||||
setUrl("");
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to add source");
|
||||
toast.error(err instanceof Error ? err.message : t("plugins:failedToAddSource"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -152,7 +156,7 @@ function AddSourceForm({ onChanged }: { onChanged: () => void }) {
|
||||
return (
|
||||
<div className="space-y-2 border-t border-border/60 pt-3">
|
||||
<Input
|
||||
placeholder="Source name (e.g. Acme Internal)"
|
||||
placeholder={t("plugins:sourceNamePlaceholder")}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
data-testid="marketplace-add-source-name"
|
||||
@@ -170,7 +174,7 @@ function AddSourceForm({ onChanged }: { onChanged: () => void }) {
|
||||
className="cursor-pointer shrink-0"
|
||||
data-testid="marketplace-add-source-submit"
|
||||
>
|
||||
Add
|
||||
{t("plugins:add")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
@@ -58,6 +59,8 @@ type ConfigFieldRowProps = {
|
||||
};
|
||||
|
||||
function ConfigFieldRow({ field, value, isDirty, disabled, onChange }: ConfigFieldRowProps) {
|
||||
// `field.label` and `field.description` come from the plugin's manifest —
|
||||
// third-party data, not our copy.
|
||||
const inputId = `plugin-config-${field.name}`;
|
||||
return (
|
||||
<div className="space-y-1.5" data-testid={`plugin-config-field-${field.name}`}>
|
||||
@@ -88,6 +91,7 @@ function ConfigFieldControl({
|
||||
disabled,
|
||||
onChange,
|
||||
}: ConfigFieldControlProps) {
|
||||
const { t } = useTranslation();
|
||||
if (field.type === "boolean") {
|
||||
return (
|
||||
<div>
|
||||
@@ -117,7 +121,7 @@ function ConfigFieldControl({
|
||||
className="max-w-md cursor-pointer"
|
||||
data-settings-dirty={isDirty}
|
||||
>
|
||||
<SelectValue placeholder="Select..." />
|
||||
<SelectValue placeholder={t("plugins:selectPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!field.required && (
|
||||
@@ -125,7 +129,7 @@ function ConfigFieldControl({
|
||||
value={ENUM_UNSET_SENTINEL}
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
>
|
||||
Not set
|
||||
{t("plugins:notSet")}
|
||||
</SelectItem>
|
||||
)}
|
||||
{(field.enumValues ?? []).map((option) => (
|
||||
@@ -176,11 +180,13 @@ function UtilityAgentSelect({
|
||||
disabled,
|
||||
onChange,
|
||||
}: ConfigFieldControlProps) {
|
||||
const { t } = useTranslation();
|
||||
const { agents, loading, error } = useUtilityAgents();
|
||||
const selectedID = typeof value === "string" ? value : "";
|
||||
const selectedAgent = agents.find((agent) => agent.id === selectedID);
|
||||
const selectedLabel = selectedAgent?.name ?? selectedUtilityAgentFallback(selectedID, loading);
|
||||
const placeholder = utilityAgentPlaceholder(loading, error);
|
||||
const selectedLabelKey = selectedUtilityAgentFallbackKey(selectedID, loading);
|
||||
const selectedLabel = selectedAgent?.name ?? (selectedLabelKey ? t(selectedLabelKey) : undefined);
|
||||
const placeholder = t(utilityAgentPlaceholderKey(loading, error));
|
||||
|
||||
return (
|
||||
<Select
|
||||
@@ -199,7 +205,7 @@ function UtilityAgentSelect({
|
||||
value={UTILITY_AGENT_UNSET_SENTINEL}
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
>
|
||||
Not set
|
||||
{t("plugins:notSet")}
|
||||
</SelectItem>
|
||||
)}
|
||||
{agents.map((agent) => (
|
||||
@@ -217,15 +223,20 @@ function UtilityAgentSelect({
|
||||
);
|
||||
}
|
||||
|
||||
function selectedUtilityAgentFallback(selectedID: string, loading: boolean): string | undefined {
|
||||
// These two return catalog keys rather than messages: a plain function that
|
||||
// returns copy is invisible to the guard, and resolving at the call site keeps
|
||||
// the text following a locale switch.
|
||||
function selectedUtilityAgentFallbackKey(selectedID: string, loading: boolean): string | undefined {
|
||||
if (selectedID === "") return undefined;
|
||||
return loading ? "Loading selected utility agent..." : "Selected utility agent unavailable";
|
||||
return loading
|
||||
? "plugins:loadingSelectedUtilityAgent"
|
||||
: "plugins:selectedUtilityAgentUnavailable";
|
||||
}
|
||||
|
||||
function utilityAgentPlaceholder(loading: boolean, error: Error | null): string {
|
||||
if (loading) return "Loading utility agents...";
|
||||
if (error) return "Utility agents unavailable";
|
||||
return "Select a utility agent...";
|
||||
function utilityAgentPlaceholderKey(loading: boolean, error: Error | null): string {
|
||||
if (loading) return "plugins:loadingUtilityAgents";
|
||||
if (error) return "plugins:utilityAgentsUnavailable";
|
||||
return "plugins:selectAUtilityAgent";
|
||||
}
|
||||
|
||||
function inputType(field: PluginConfigField): string {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
@@ -83,6 +84,7 @@ type PluginDetailHeaderProps = {
|
||||
};
|
||||
|
||||
function PluginDetailHeader({ plugin }: PluginDetailHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Link
|
||||
@@ -90,7 +92,7 @@ function PluginDetailHeader({ plugin }: PluginDetailHeaderProps) {
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<IconArrowLeft className="h-4 w-4" />
|
||||
Plugins
|
||||
{t("common:plugins")}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
@@ -102,7 +104,7 @@ function PluginDetailHeader({ plugin }: PluginDetailHeaderProps) {
|
||||
variant="outline"
|
||||
className="border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[11px]"
|
||||
>
|
||||
unsigned
|
||||
{t("plugins:unsigned")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -129,10 +131,11 @@ type PluginSettingsCardProps = {
|
||||
};
|
||||
|
||||
function PluginSettingsCard({ plugin, form, busy }: PluginSettingsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsCard isDirty={form.isDirty} data-testid="plugin-settings-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Settings</CardTitle>
|
||||
<CardTitle className="text-base">{t("plugins:settings")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PluginSettingsBody plugin={plugin} form={form} busy={busy} />
|
||||
@@ -142,10 +145,14 @@ function PluginSettingsCard({ plugin, form, busy }: PluginSettingsCardProps) {
|
||||
}
|
||||
|
||||
function PluginSettingsBody({ plugin, form, busy }: PluginSettingsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
if (form.fields.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This plugin does not declare any settings (no <code>config_schema</code> in its manifest).
|
||||
{/* `config_schema` is a manifest key — the contract, not copy. */}
|
||||
<Trans i18nKey="plugins:noDeclaredSettings">
|
||||
This plugin does not declare any settings (no <code>config_schema</code> in its manifest).
|
||||
</Trans>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -153,7 +160,7 @@ function PluginSettingsBody({ plugin, form, busy }: PluginSettingsCardProps) {
|
||||
return <p className="text-sm text-destructive">{form.configError}</p>;
|
||||
}
|
||||
if (form.configLoading) {
|
||||
return <p className="text-sm text-muted-foreground">Loading settings...</p>;
|
||||
return <p className="text-sm text-muted-foreground">{t("plugins:loadingSettings")}</p>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -165,9 +172,7 @@ function PluginSettingsBody({ plugin, form, busy }: PluginSettingsCardProps) {
|
||||
onChange={form.handleChange}
|
||||
/>
|
||||
{plugin.status === "active" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Saving restarts the plugin so the new settings take effect.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("plugins:savingRestartsPlugin")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -179,6 +184,7 @@ type PluginDangerZoneProps = {
|
||||
};
|
||||
|
||||
function PluginDangerZone({ plugin, actions }: PluginDangerZoneProps) {
|
||||
const { t } = useTranslation();
|
||||
const busy = actions.busyId === plugin.id;
|
||||
const canEnable =
|
||||
plugin.status === "disabled" || plugin.status === "registered" || plugin.status === "error";
|
||||
@@ -194,7 +200,7 @@ function PluginDangerZone({ plugin, actions }: PluginDangerZoneProps) {
|
||||
disabled={busy}
|
||||
onClick={() => actions.handleEnable(plugin)}
|
||||
>
|
||||
Enable
|
||||
{t("plugins:enable")}
|
||||
</Button>
|
||||
)}
|
||||
{canDisable && (
|
||||
@@ -205,7 +211,7 @@ function PluginDangerZone({ plugin, actions }: PluginDangerZoneProps) {
|
||||
disabled={busy}
|
||||
onClick={() => actions.handleDisable(plugin)}
|
||||
>
|
||||
Disable
|
||||
{t("plugins:disable")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -215,13 +221,14 @@ function PluginDangerZone({ plugin, actions }: PluginDangerZoneProps) {
|
||||
disabled={busy}
|
||||
onClick={() => actions.openUninstall(plugin)}
|
||||
>
|
||||
Uninstall
|
||||
{t("plugins:uninstall")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginNotFound({ pluginId }: { pluginId: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Link
|
||||
@@ -229,10 +236,13 @@ function PluginNotFound({ pluginId }: { pluginId: string }) {
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<IconArrowLeft className="h-4 w-4" />
|
||||
Plugins
|
||||
{t("common:plugins")}
|
||||
</Link>
|
||||
<div className="rounded-md border border-dashed p-6 text-sm text-muted-foreground">
|
||||
No installed plugin with id <span className="font-mono">{pluginId}</span>.
|
||||
{/* The plugin id is an identifier the user typed or followed, never copy. */}
|
||||
<Trans i18nKey="plugins:noInstalledPluginWithId" values={{ id: pluginId }}>
|
||||
No installed plugin with id <span className="font-mono">{pluginId}</span>.
|
||||
</Trans>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { formatDateTime } from "@/lib/i18n/formats";
|
||||
import type { PluginRecord } from "@/lib/types/plugins";
|
||||
|
||||
/**
|
||||
@@ -21,7 +22,7 @@ export function PluginErrorDiagnostic({ plugin }: { plugin: PluginRecord }) {
|
||||
dateTime={plugin.last_error_at}
|
||||
className="mt-1 block text-[11px] text-destructive/70"
|
||||
>
|
||||
{new Date(plugin.last_error_at).toLocaleString()}
|
||||
{formatDateTime(plugin.last_error_at)}
|
||||
</time>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { formatDateTime } from "@/lib/i18n/formats";
|
||||
import type { PluginRecord } from "@/lib/types/plugins";
|
||||
|
||||
/**
|
||||
@@ -12,34 +14,43 @@ import type { PluginRecord } from "@/lib/types/plugins";
|
||||
* rows don't surface.
|
||||
*/
|
||||
export function PluginManifestCard({ plugin }: { plugin: PluginRecord }) {
|
||||
const { t } = useTranslation();
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
|
||||
return (
|
||||
<Card data-testid="plugin-manifest-card">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">Manifest</CardTitle>
|
||||
<CardTitle className="text-base">{t("plugins:manifest")}</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowRaw((v) => !v)}
|
||||
>
|
||||
{showRaw ? "Hide raw" : "View raw"}
|
||||
{showRaw ? t("plugins:hideRaw") : t("plugins:viewRaw")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||
{/* Row VALUES are manifest data — the plugin id, its version, its
|
||||
author — and are never translated. Only the row labels are copy. */}
|
||||
<ManifestRow label="ID" value={plugin.id} mono />
|
||||
<ManifestRow label="Version" value={plugin.version} mono />
|
||||
<ManifestRow label="API version" value={String(plugin.api_version)} />
|
||||
<ManifestRow label="Author" value={plugin.author || "—"} />
|
||||
<ManifestRow label="Signed" value={plugin.signed ? "yes" : "no"} />
|
||||
<ManifestRow label="Installed" value={formatInstalledAt(plugin.installed_at)} />
|
||||
<ManifestRow label={t("plugins:manifestVersion")} value={plugin.version} mono />
|
||||
<ManifestRow label={t("plugins:manifestApiVersion")} value={String(plugin.api_version)} />
|
||||
<ManifestRow label={t("plugins:manifestAuthor")} value={plugin.author || "—"} />
|
||||
<ManifestRow
|
||||
label={t("plugins:manifestSigned")}
|
||||
value={plugin.signed ? t("plugins:yes") : t("plugins:no")}
|
||||
/>
|
||||
<ManifestRow
|
||||
label={t("plugins:manifestInstalled")}
|
||||
value={formatInstalledAt(plugin.installed_at)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CapabilityBadges plugin={plugin} />
|
||||
<DeclarationList
|
||||
label="Webhooks"
|
||||
label={t("plugins:manifestWebhooks")}
|
||||
items={(plugin.webhooks ?? []).map((w) => ({ key: w.key, text: w.key }))}
|
||||
/>
|
||||
|
||||
@@ -66,6 +77,7 @@ function ManifestRow({ label, value, mono }: { label: string; value: string; mon
|
||||
}
|
||||
|
||||
function CapabilityBadges({ plugin }: { plugin: PluginRecord }) {
|
||||
const { t } = useTranslation();
|
||||
const caps = plugin.capabilities ?? {};
|
||||
const badges: string[] = [
|
||||
...(caps.events ?? []).map((e) => `events:${e}`),
|
||||
@@ -77,7 +89,9 @@ function CapabilityBadges({ plugin }: { plugin: PluginRecord }) {
|
||||
if (badges.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-sm text-muted-foreground">Capabilities</div>
|
||||
{/* The badges are permission scopes (`events:*`, `read:*`) — the
|
||||
contract the plugin declared, so they stay verbatim. */}
|
||||
<div className="text-sm text-muted-foreground">{t("plugins:manifestCapabilities")}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{badges.map((badge) => (
|
||||
<Badge key={badge} variant="secondary" className="text-[11px] font-mono">
|
||||
@@ -129,5 +143,5 @@ function manifestOnly(plugin: PluginRecord): Record<string, unknown> {
|
||||
|
||||
function formatInstalledAt(installedAt: string): string {
|
||||
const date = new Date(installedAt);
|
||||
return Number.isNaN(date.getTime()) ? installedAt : date.toLocaleString();
|
||||
return Number.isNaN(date.getTime()) ? installedAt : formatDateTime(date);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IconExternalLink } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,7 @@ export function isHttpUrl(url: string | undefined | null): boolean {
|
||||
* link markup live in one place.
|
||||
*/
|
||||
export function PluginRepoLink({ url, className }: { url?: string | null; className?: string }) {
|
||||
const { t } = useTranslation();
|
||||
if (!isHttpUrl(url)) return null;
|
||||
return (
|
||||
<a
|
||||
@@ -33,7 +35,7 @@ export function PluginRepoLink({ url, className }: { url?: string | null; classN
|
||||
)}
|
||||
>
|
||||
<IconExternalLink className="h-3.5 w-3.5" />
|
||||
Repo
|
||||
{t("plugins:repo")}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IconArrowUpCircle } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Switch } from "@kandev/ui/switch";
|
||||
@@ -43,6 +44,7 @@ export function PluginRow({
|
||||
onUpdate,
|
||||
onSetAutoUpdate,
|
||||
}: PluginRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const canEnable =
|
||||
plugin.status === "disabled" || plugin.status === "registered" || plugin.status === "error";
|
||||
const canDisable = plugin.status === "active" || plugin.status === "error";
|
||||
@@ -69,7 +71,7 @@ export function PluginRow({
|
||||
variant="outline"
|
||||
className="border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[11px]"
|
||||
>
|
||||
unsigned
|
||||
{t("plugins:unsigned")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -135,16 +137,17 @@ function PluginAutoUpdateRow({
|
||||
busy: boolean;
|
||||
onSetAutoUpdate: (plugin: PluginRecord, value: boolean | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isOverridden = plugin.auto_update !== null && plugin.auto_update !== undefined;
|
||||
const effective = isOverridden ? (plugin.auto_update as boolean) : autoUpdateDefault;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/50 pt-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>Auto-update</span>
|
||||
<span>{t("plugins:autoUpdate")}</span>
|
||||
{isOverridden && (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
override
|
||||
{t("plugins:override")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -153,17 +156,17 @@ function PluginAutoUpdateRow({
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`plugin-auto-update-reset-${plugin.id}`}
|
||||
aria-label={`Reset auto-update for ${plugin.display_name} to the default`}
|
||||
aria-label={t("plugins:resetAutoUpdateFor", { name: plugin.display_name })}
|
||||
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline cursor-pointer disabled:opacity-50"
|
||||
disabled={busy}
|
||||
onClick={() => onSetAutoUpdate(plugin, null)}
|
||||
>
|
||||
Reset
|
||||
{t("plugins:reset")}
|
||||
</button>
|
||||
)}
|
||||
<Switch
|
||||
data-testid={`plugin-auto-update-${plugin.id}`}
|
||||
aria-label={`Auto-update for ${plugin.display_name}`}
|
||||
aria-label={t("plugins:autoUpdateFor", { name: plugin.display_name })}
|
||||
checked={effective}
|
||||
disabled={busy}
|
||||
onCheckedChange={(value) => onSetAutoUpdate(plugin, value)}
|
||||
@@ -201,6 +204,7 @@ function PluginRowActions({
|
||||
onUninstall,
|
||||
onUpdate,
|
||||
}: PluginRowActionsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
||||
{update && onUpdate && (
|
||||
@@ -213,7 +217,7 @@ function PluginRowActions({
|
||||
onClick={() => onUpdate(update)}
|
||||
>
|
||||
<IconArrowUpCircle className="h-4 w-4" />
|
||||
{busy ? "Updating…" : `Update to v${update.version}`}
|
||||
{busy ? t("plugins:updating") : t("plugins:updateToVersion", { version: update.version })}
|
||||
</Button>
|
||||
)}
|
||||
{canEnable && (
|
||||
@@ -224,7 +228,7 @@ function PluginRowActions({
|
||||
disabled={busy}
|
||||
onClick={() => onEnable(plugin)}
|
||||
>
|
||||
Enable
|
||||
{t("plugins:enable")}
|
||||
</Button>
|
||||
)}
|
||||
{canDisable && (
|
||||
@@ -235,7 +239,7 @@ function PluginRowActions({
|
||||
disabled={busy}
|
||||
onClick={() => onDisable(plugin)}
|
||||
>
|
||||
Disable
|
||||
{t("plugins:disable")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -245,7 +249,7 @@ function PluginRowActions({
|
||||
disabled={busy}
|
||||
onClick={() => onUninstall(plugin)}
|
||||
>
|
||||
Uninstall
|
||||
{t("plugins:uninstall")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import type { PluginStatus } from "@/lib/types/plugins";
|
||||
|
||||
const STATUS_LABEL: Record<PluginStatus, string> = {
|
||||
active: "Active",
|
||||
error: "Error",
|
||||
disabled: "Disabled",
|
||||
registered: "Registered",
|
||||
uninstalled: "Uninstalled",
|
||||
// The map keys are the backend's wire status; only the labels are copy, and
|
||||
// they travel as catalog keys so they resolve at render.
|
||||
const STATUS_LABEL_KEY: Record<PluginStatus, string> = {
|
||||
active: "plugins:statusActive",
|
||||
error: "plugins:statusError",
|
||||
disabled: "plugins:statusDisabled",
|
||||
registered: "plugins:statusRegistered",
|
||||
uninstalled: "plugins:statusUninstalled",
|
||||
};
|
||||
|
||||
// green=active, red=error, gray=disabled, amber=registered, per task-20 acceptance.
|
||||
@@ -21,9 +24,10 @@ const STATUS_CLASS: Record<PluginStatus, string> = {
|
||||
};
|
||||
|
||||
export function PluginStatusBadge({ status }: { status: PluginStatus }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Badge variant="outline" className={STATUS_CLASS[status]}>
|
||||
{STATUS_LABEL[status]}
|
||||
{t(STATUS_LABEL_KEY[status])}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Switch } from "@kandev/ui/switch";
|
||||
@@ -22,6 +23,7 @@ import { usePluginActions } from "./use-plugin-actions";
|
||||
* flag by the page-level default export.
|
||||
*/
|
||||
export function PluginsSettings() {
|
||||
const { t } = useTranslation();
|
||||
const list = usePlugins();
|
||||
const actions = usePluginActions();
|
||||
const autoUpdate = useAutoUpdateSettings();
|
||||
@@ -43,8 +45,8 @@ export function PluginsSettings() {
|
||||
|
||||
return (
|
||||
<SettingsPageTemplate
|
||||
title="Plugins"
|
||||
description="Browse the marketplace, then install, enable, disable, and uninstall kandev plugins."
|
||||
title={t("common:plugins")}
|
||||
description={t("plugins:settingsDescription")}
|
||||
isDirty={false}
|
||||
saveStatus="idle"
|
||||
onSave={() => undefined}
|
||||
@@ -57,10 +59,10 @@ export function PluginsSettings() {
|
||||
data-testid="plugins-tab-installed"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Installed
|
||||
{t("plugins:tabInstalled")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="browse" data-testid="plugins-tab-browse" className="cursor-pointer">
|
||||
Browse
|
||||
{t("plugins:tabBrowse")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -116,12 +118,13 @@ function InstalledTab({
|
||||
updatingId,
|
||||
onUpdate,
|
||||
}: InstalledTabProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<GlobalAutoUpdateToggle settings={autoUpdate} />
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm font-medium text-foreground">Installed plugins</div>
|
||||
<div className="text-sm font-medium text-foreground">{t("plugins:installedPlugins")}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-testid="plugins-sync-button"
|
||||
@@ -131,14 +134,14 @@ function InstalledTab({
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<IconRefresh className={`h-4 w-4 ${actions.syncBusy ? "animate-spin" : ""}`} />
|
||||
Sync
|
||||
{t("plugins:sync")}
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="install-plugin-trigger"
|
||||
onClick={actions.openInstall}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Install plugin
|
||||
{t("plugins:installPlugin")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,6 +181,7 @@ function GlobalAutoUpdateToggle({
|
||||
}: {
|
||||
settings: ReturnType<typeof useAutoUpdateSettings>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/70 bg-background p-4">
|
||||
<div className="min-w-0 space-y-1">
|
||||
@@ -185,12 +189,9 @@ function GlobalAutoUpdateToggle({
|
||||
htmlFor="plugins-auto-update-default"
|
||||
className="text-sm font-medium text-foreground cursor-pointer"
|
||||
>
|
||||
Automatically update plugins
|
||||
{t("plugins:autoUpdateTitle")}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Check the marketplace periodically and install newer versions of active plugins. Applies
|
||||
to every plugin unless overridden per plugin below.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("plugins:autoUpdateDescription")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="plugins-auto-update-default"
|
||||
@@ -221,6 +222,7 @@ function PluginList({
|
||||
updatingId,
|
||||
onUpdate,
|
||||
}: PluginListProps) {
|
||||
const { t } = useTranslation();
|
||||
const { items, loaded, loading, error } = list;
|
||||
|
||||
if (error) {
|
||||
@@ -234,7 +236,7 @@ function PluginList({
|
||||
if (!loaded && loading) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
Loading plugins...
|
||||
{t("plugins:loadingPlugins")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -242,7 +244,7 @@ function PluginList({
|
||||
if (loaded && items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
|
||||
No plugins yet. Install one from the Browse tab, or upload a package.
|
||||
{t("plugins:noPluginsYet")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -24,6 +25,7 @@ export function UninstallPluginDialog({
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: UninstallPluginDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(target)}
|
||||
@@ -33,18 +35,23 @@ export function UninstallPluginDialog({
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Uninstall plugin</DialogTitle>
|
||||
<DialogTitle>{t("plugins:uninstallPlugin")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will permanently remove{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
{target?.display_name ?? "this plugin"}
|
||||
</span>{" "}
|
||||
and revoke its API key. This action cannot be undone.
|
||||
{/* The display name comes from the plugin's manifest, so it is
|
||||
third-party data rather than our copy. */}
|
||||
<Trans
|
||||
i18nKey="plugins:uninstallConfirm"
|
||||
values={{ name: target?.display_name ?? t("plugins:thisPlugin") }}
|
||||
>
|
||||
This will permanently remove{" "}
|
||||
<span className="font-medium text-foreground">{target?.display_name}</span> and revoke
|
||||
its API key. This action cannot be undone.
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose} className="cursor-pointer">
|
||||
Cancel
|
||||
{t("plugins:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -53,7 +60,7 @@ export function UninstallPluginDialog({
|
||||
disabled={busy}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Confirm uninstall
|
||||
{t("plugins:confirmUninstall")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { toast } from "@/lib/toast/sonner";
|
||||
// Module-level `t`: these callbacks fire outside a render, so each message
|
||||
// resolves when the action runs rather than at import.
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { StoreApi } from "zustand";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { useTheme } from "@/components/theme/app-theme";
|
||||
@@ -94,7 +97,11 @@ function useEnableDisableActions(upsertPlugin: (p: PluginRecord) => void) {
|
||||
// Preserve the original Enable failure toast if the diagnostic refresh
|
||||
// itself cannot reach the backend.
|
||||
}
|
||||
toast.error(err instanceof Error ? err.message : `Failed to enable ${plugin.display_name}`);
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("plugins:failedToEnable", { name: plugin.display_name }),
|
||||
);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -107,7 +114,11 @@ function useEnableDisableActions(upsertPlugin: (p: PluginRecord) => void) {
|
||||
unloadPlugin(plugin.id);
|
||||
upsertPlugin(withStatus(plugin, "disabled"));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : `Failed to disable ${plugin.display_name}`);
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("plugins:failedToDisable", { name: plugin.display_name }),
|
||||
);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
@@ -134,7 +145,7 @@ function useAutoUpdateAction(upsertPlugin: (p: PluginRecord) => void) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `Failed to update auto-update for ${plugin.display_name}`,
|
||||
: t("plugins:failedToSetAutoUpdateFor", { name: plugin.display_name }),
|
||||
);
|
||||
} finally {
|
||||
setAutoUpdateBusyId(null);
|
||||
@@ -159,7 +170,9 @@ function useUninstallAction(removePlugin: (id: string) => void) {
|
||||
setUninstallTarget(null);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error ? err.message : `Failed to uninstall ${target.display_name}`,
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("plugins:failedToUninstall", { name: target.display_name }),
|
||||
);
|
||||
} finally {
|
||||
setUninstallBusy(false);
|
||||
@@ -203,7 +216,7 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
|
||||
if (warning) {
|
||||
toast.warning(warning);
|
||||
} else {
|
||||
toast.success(`${plugin.display_name} installed`);
|
||||
toast.success(t("plugins:pluginInstalled", { name: plugin.display_name }));
|
||||
}
|
||||
closeInstallDialog();
|
||||
};
|
||||
@@ -215,7 +228,7 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
|
||||
const result = await install();
|
||||
await afterInstall(result);
|
||||
} catch (err) {
|
||||
setInstallError(err instanceof Error ? err.message : "Failed to install plugin");
|
||||
setInstallError(err instanceof Error ? err.message : t("plugins:failedToInstallPlugin"));
|
||||
} finally {
|
||||
setInstallBusy(false);
|
||||
}
|
||||
@@ -232,7 +245,7 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
|
||||
const result = await installPluginFromUrl(url);
|
||||
await afterInstall(result);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to install plugin");
|
||||
toast.error(err instanceof Error ? err.message : t("plugins:failedToInstallPlugin"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,7 +287,7 @@ function useSyncAction(setPlugins: (plugins: PluginRecord[]) => void) {
|
||||
setSyncErrors(result.errors ?? []);
|
||||
toast.success(summarizeSyncResult(result));
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to sync plugins");
|
||||
toast.error(err instanceof Error ? err.message : t("plugins:failedToSyncPlugins"));
|
||||
} finally {
|
||||
setSyncBusy(false);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "@/lib/toast/sonner";
|
||||
// Module-level `t`: these resolve when the save/load callback runs.
|
||||
import { t } from "@/lib/i18n";
|
||||
import { getPluginConfig, updatePluginConfig } from "@/lib/api/domains/plugins-api";
|
||||
import {
|
||||
SECRET_MASK,
|
||||
@@ -61,7 +63,7 @@ export function usePluginConfigForm(plugin: PluginRecord | null) {
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
setConfigError(err instanceof Error ? err.message : "Failed to load plugin settings");
|
||||
setConfigError(err instanceof Error ? err.message : t("plugins:failedToLoadSettings"));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -89,15 +91,18 @@ export function usePluginConfigForm(plugin: PluginRecord | null) {
|
||||
const handleSave = async () => {
|
||||
if (!pluginId) return;
|
||||
if (missing.length > 0) {
|
||||
toast.error(`Required: ${missing.join(", ")}`);
|
||||
throw new Error(`Required: ${missing.join(", ")}`);
|
||||
// The field names come from the plugin's config_schema, so only the
|
||||
// "Required:" frame is copy.
|
||||
const message = t("plugins:requiredFields", { fields: missing.join(", ") });
|
||||
toast.error(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
setSaveStatus("loading");
|
||||
try {
|
||||
await updatePluginConfig(pluginId, serializeConfigValues(fields, values));
|
||||
} catch (err) {
|
||||
setSaveStatus("error");
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save plugin settings");
|
||||
toast.error(err instanceof Error ? err.message : t("plugins:failedToSaveSettings"));
|
||||
throw err;
|
||||
}
|
||||
// The config IS persisted from here on — a refetch failure (e.g. a
|
||||
@@ -108,12 +113,12 @@ export function usePluginConfigForm(plugin: PluginRecord | null) {
|
||||
const initial = buildInitialValues(fields, refreshed);
|
||||
setValues(initial);
|
||||
setInitialValues(initial);
|
||||
toast.success("Plugin settings saved");
|
||||
toast.success(t("plugins:settingsSaved"));
|
||||
} catch {
|
||||
const masked = maskSecretsIn(values, fields);
|
||||
setValues(masked);
|
||||
setInitialValues(masked);
|
||||
toast.warning("Settings saved, but reloading them failed — refresh to confirm.");
|
||||
toast.warning(t("plugins:settingsSavedReloadFailed"));
|
||||
}
|
||||
setSaveStatus("success");
|
||||
};
|
||||
@@ -127,7 +132,8 @@ export function usePluginConfigForm(plugin: PluginRecord | null) {
|
||||
saveStatus,
|
||||
isDirty,
|
||||
canSave: missing.length === 0,
|
||||
invalidReason: missing.length > 0 ? `Required: ${missing.join(", ")}` : undefined,
|
||||
invalidReason:
|
||||
missing.length > 0 ? t("plugins:requiredFields", { fields: missing.join(", ") }) : undefined,
|
||||
revision: JSON.stringify(values),
|
||||
handleChange,
|
||||
handleSave,
|
||||
|
||||
@@ -29,15 +29,18 @@ function McpPresetButton({ label, onClick }: { label: string; onClick: () => voi
|
||||
);
|
||||
}
|
||||
|
||||
// Returns a catalog key (or null when valid) so the message resolves at render
|
||||
// rather than at module load — the same contract as the local validator in
|
||||
// app/settings/executor/[id]/page.tsx.
|
||||
export function validateMcpPolicy(value: string | undefined): string | null {
|
||||
const raw = value ?? "";
|
||||
if (!raw.trim()) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
||||
return "MCP policy must be a JSON object";
|
||||
return "executors:mcpPolicyMustBeAJsonObject";
|
||||
} catch {
|
||||
return "Invalid JSON";
|
||||
return "executors:invalidJson";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -45,14 +48,14 @@ export function validateMcpPolicy(value: string | undefined): string | null {
|
||||
type McpPolicyCardProps = {
|
||||
mcpPolicy: string;
|
||||
baselinePolicy?: string;
|
||||
mcpPolicyError: string | null;
|
||||
mcpPolicyErrorKey: string | null;
|
||||
onPolicyChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function McpPolicyCard({
|
||||
mcpPolicy,
|
||||
baselinePolicy,
|
||||
mcpPolicyError,
|
||||
mcpPolicyErrorKey,
|
||||
onPolicyChange,
|
||||
}: McpPolicyCardProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -84,7 +87,7 @@ export function McpPolicyCard({
|
||||
rows={8}
|
||||
data-settings-dirty={isDirty}
|
||||
/>
|
||||
{mcpPolicyError && <p className="text-xs text-destructive">{mcpPolicyError}</p>}
|
||||
{mcpPolicyErrorKey && <p className="text-xs text-destructive">{t(mcpPolicyErrorKey)}</p>}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("executors:quickPresets")}</p>
|
||||
<McpPresetButton
|
||||
|
||||
@@ -22,7 +22,7 @@ describe("executor profile dirty fields", () => {
|
||||
<McpPolicyCard
|
||||
mcpPolicy='{"allow_http":true}'
|
||||
baselinePolicy='{"allow_http":true}'
|
||||
mcpPolicyError={null}
|
||||
mcpPolicyErrorKey={null}
|
||||
onPolicyChange={onChange}
|
||||
/>,
|
||||
);
|
||||
@@ -33,7 +33,7 @@ describe("executor profile dirty fields", () => {
|
||||
<McpPolicyCard
|
||||
mcpPolicy='{"allow_http":false}'
|
||||
baselinePolicy='{"allow_http":true}'
|
||||
mcpPolicyError={null}
|
||||
mcpPolicyErrorKey={null}
|
||||
onPolicyChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -65,11 +65,13 @@ const SEGMENT_LABEL_KEYS: Record<string, string> = {
|
||||
prompts: "common:prompts",
|
||||
"resource-metrics": "settings:resourceMetrics",
|
||||
secrets: "settings:secrets",
|
||||
security: "settings:security",
|
||||
shell: "common:shell",
|
||||
sprites: "settings:sprites",
|
||||
system: "common:system",
|
||||
"task-actions": "settings:taskActions",
|
||||
terminal: "settings:terminal",
|
||||
tokens: "settings:tokens",
|
||||
"utility-agents": "settings:utilityAgents",
|
||||
"voice-mode": "settings:voiceMode",
|
||||
workspace: "common:workspace",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
@@ -53,6 +54,7 @@ function useReadinessState({
|
||||
shellProp?: string;
|
||||
onShellChange?: (s: string) => void | Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<SSHAgentReadinessRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -78,11 +80,11 @@ function useReadinessState({
|
||||
setHasProbed(true);
|
||||
} catch (e) {
|
||||
if (seq !== seqRef.current) return;
|
||||
setError(e instanceof Error ? e.message : "Failed to probe agents");
|
||||
setError(e instanceof Error ? e.message : t("executors:sshFailedToProbeAgents"));
|
||||
} finally {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
}
|
||||
}, [executorId, shell]);
|
||||
}, [executorId, shell, t]);
|
||||
|
||||
// Auto-probe the shells once on mount so the dropdown has real options.
|
||||
// Cheap (one SSH dial, ~1s) and fully out-of-band of agent probing.
|
||||
@@ -129,11 +131,11 @@ function useReadinessState({
|
||||
try {
|
||||
await onShellChange(next);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed to save shell");
|
||||
toast.error(e instanceof Error ? e.message : t("executors:sshFailedToSaveShell"));
|
||||
}
|
||||
}
|
||||
},
|
||||
[onShellChange],
|
||||
[onShellChange, t],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -164,6 +166,7 @@ export function SSHAgentReadinessCard({
|
||||
baselineShell,
|
||||
onShellChange,
|
||||
}: SSHAgentReadinessCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const state = useReadinessState({ executorId, shellProp, onShellChange });
|
||||
const {
|
||||
rows,
|
||||
@@ -185,10 +188,11 @@ export function SSHAgentReadinessCard({
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Available agents on this host</CardTitle>
|
||||
<CardTitle>{t("executors:sshAvailableAgents")}</CardTitle>
|
||||
<CardDescription>
|
||||
Probes the remote {"$PATH"} for each enabled agent under the chosen login shell. Copy
|
||||
the install hint and run it on the remote when an agent is missing.
|
||||
{/* $PATH is a shell variable name, interpolated so the
|
||||
pseudo-locale cannot transliterate it. */}
|
||||
{t("executors:sshAvailableAgentsDescription", { pathVar: "$PATH" })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -200,7 +204,7 @@ export function SSHAgentReadinessCard({
|
||||
className="cursor-pointer shrink-0"
|
||||
>
|
||||
{loading ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
|
||||
{hasProbed ? "Re-probe" : "Probe agents"}
|
||||
{hasProbed ? t("executors:sshReprobe") : t("executors:sshProbeAgents")}
|
||||
</Button>
|
||||
</div>
|
||||
<ShellSelector
|
||||
@@ -234,6 +238,7 @@ function ShellSelector({
|
||||
onChange: (s: string) => void | Promise<void>;
|
||||
isDirty: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// While probing, show a placeholder option so the dropdown can't surface
|
||||
// a stale "bash" selection that we haven't confirmed exists on the host.
|
||||
// After probing, render whatever the host has, plus the current value so
|
||||
@@ -242,7 +247,7 @@ function ShellSelector({
|
||||
return (
|
||||
<div className="flex items-center gap-2 pt-3">
|
||||
<Label htmlFor="ssh-readiness-shell" className="text-xs text-muted-foreground">
|
||||
Login shell
|
||||
{t("executors:sshLoginShell")}
|
||||
</Label>
|
||||
<Select value={shell} onValueChange={(v) => void onChange(v)} disabled={loading}>
|
||||
<SelectTrigger
|
||||
@@ -258,7 +263,7 @@ function ShellSelector({
|
||||
<SelectItem key={s} value={s} className="text-xs">
|
||||
{s}
|
||||
{shells && !shells.includes(s) ? (
|
||||
<span className="text-amber-600 ml-1">(not detected)</span>
|
||||
<span className="text-amber-600 ml-1">{t("executors:sshShellNotDetected")}</span>
|
||||
) : null}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -286,6 +291,7 @@ function ReadinessContent({
|
||||
hasProbed: boolean;
|
||||
rows: SSHAgentReadinessRow[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (error) {
|
||||
return (
|
||||
<p data-testid="ssh-agent-readiness-error" className="text-sm text-red-600 dark:text-red-400">
|
||||
@@ -296,25 +302,26 @@ function ReadinessContent({
|
||||
if (!hasProbed) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click {`"Probe agents"`} to check which agents are installed on the remote.
|
||||
{t("executors:sshProbeHint", { button: t("executors:sshProbeAgents") })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (rows.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">No enabled agents to probe.</p>;
|
||||
return <p className="text-sm text-muted-foreground">{t("executors:sshNoEnabledAgents")}</p>;
|
||||
}
|
||||
return <ReadinessTable rows={rows} />;
|
||||
}
|
||||
|
||||
function ReadinessTable({ rows }: { rows: SSHAgentReadinessRow[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Table data-testid="ssh-agent-readiness-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Agent</TableHead>
|
||||
<TableHead>Binary</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Install hint</TableHead>
|
||||
<TableHead>{t("executors:agent")}</TableHead>
|
||||
<TableHead>{t("executors:sshBinary")}</TableHead>
|
||||
<TableHead>{t("executors:status")}</TableHead>
|
||||
<TableHead>{t("executors:sshInstallHint")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -343,40 +350,43 @@ function ReadinessRow({ row }: { row: SSHAgentReadinessRow }) {
|
||||
}
|
||||
|
||||
function StatusBadge({ row }: { row: SSHAgentReadinessRow }) {
|
||||
const { t } = useTranslation();
|
||||
if (row.error) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-amber-500/30 bg-amber-500/10 text-amber-700">
|
||||
<IconX className="mr-1 h-3 w-3" /> Probe error
|
||||
<IconX className="mr-1 h-3 w-3" /> {t("executors:sshProbeError")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (row.available) {
|
||||
return (
|
||||
<Badge variant="outline" className="border-green-500/30 bg-green-500/10 text-green-700">
|
||||
<IconCheck className="mr-1 h-3 w-3" /> Installed
|
||||
<IconCheck className="mr-1 h-3 w-3" /> {t("executors:sshInstalled")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 bg-red-500/10 text-red-700">
|
||||
<IconX className="mr-1 h-3 w-3" /> Missing
|
||||
<IconX className="mr-1 h-3 w-3" /> {t("executors:sshMissing")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallHint({ hint, available }: { hint?: string; available: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (available) return <span className="text-muted-foreground">—</span>;
|
||||
if (!hint) return <span className="text-muted-foreground">No hint available</span>;
|
||||
if (!hint)
|
||||
return <span className="text-muted-foreground">{t("executors:sshNoHintAvailable")}</span>;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="truncate">{hint}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
aria-label="Copy install hint"
|
||||
aria-label={t("executors:sshCopyInstallHint")}
|
||||
onClick={() => {
|
||||
void copyToClipboard(hint).then((success) => {
|
||||
if (success) toast.success("Install hint copied");
|
||||
if (success) toast.success(t("executors:sshInstallHintCopied"));
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
// Aliased so components can keep the conventional `t` from useTranslation();
|
||||
// this one is for the module-scope helpers and callbacks below, which have no
|
||||
// hook in scope and must resolve when they run, not at import.
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
@@ -107,13 +112,11 @@ function initialState(initial?: Partial<SSHExecutorConfig>): SSHConnectionState
|
||||
};
|
||||
}
|
||||
|
||||
// A window.confirm argument is invisible to the guard; the module-level `t`
|
||||
// resolves it when the user clicks Save.
|
||||
function confirmRunningSessions(count?: number): boolean {
|
||||
if (!count) return true;
|
||||
return window.confirm(
|
||||
`This executor has ${count} running session(s). ` +
|
||||
"They will keep running on the current host. Only new sessions started " +
|
||||
"after save will use the updated config. Continue?",
|
||||
);
|
||||
return window.confirm(translate("executors:sshConfirmRunningSessions", { count }));
|
||||
}
|
||||
|
||||
type CoordinatedSSHSaveOptions = {
|
||||
@@ -139,7 +142,7 @@ function useCoordinatedSSHSave({
|
||||
revision,
|
||||
isDirty: Boolean(id) && revision !== JSON.stringify(baseline),
|
||||
canSave,
|
||||
invalidReason: canSave ? undefined : "Test the connection and trust its fingerprint to save.",
|
||||
invalidReason: canSave ? undefined : translate("executors:sshTestAndTrustToSave"),
|
||||
save,
|
||||
discard,
|
||||
});
|
||||
@@ -215,7 +218,7 @@ function useSSHConnection(props: SSHConnectionCardProps) {
|
||||
testing: false,
|
||||
}));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Failed to reach backend";
|
||||
const msg = e instanceof Error ? e.message : translate("executors:sshFailedToReachBackend");
|
||||
setState((prev) => ({ ...prev, error: msg, testing: false }));
|
||||
}
|
||||
}, [form]);
|
||||
@@ -223,6 +226,8 @@ function useSSHConnection(props: SSHConnectionCardProps) {
|
||||
const canSave = !!result?.success && !!result.fingerprint && trust && !resultStale && !saving;
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
// Unreachable while `canSave` gates the Save button; this signals the save
|
||||
// coordinator rather than the user, so it is control flow, not copy.
|
||||
if (!canSave || !result?.fingerprint) throw new Error("Test and trust this host before saving");
|
||||
if (!confirmRunningSessions(props.runningSessionCount)) throw new SettingsSaveCancelledError();
|
||||
const submitted = form;
|
||||
@@ -232,7 +237,7 @@ function useSSHConnection(props: SSHConnectionCardProps) {
|
||||
setBaseline(submitted);
|
||||
setState((prev) => ({ ...prev, saving: false }));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Failed to save executor";
|
||||
const msg = e instanceof Error ? e.message : translate("executors:sshFailedToSaveExecutor");
|
||||
setState((prev) => ({ ...prev, saving: false, error: msg }));
|
||||
throw e;
|
||||
}
|
||||
@@ -267,6 +272,7 @@ function useSSHConnection(props: SSHConnectionCardProps) {
|
||||
}
|
||||
|
||||
export function SSHConnectionCard(props: SSHConnectionCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const c = useSSHConnection(props);
|
||||
return (
|
||||
<SettingsCard isDirty={c.isDirty} data-testid="ssh-connection-card">
|
||||
@@ -275,11 +281,9 @@ export function SSHConnectionCard(props: SSHConnectionCardProps) {
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<IconTerminal2 className="h-5 w-5" />
|
||||
Connection
|
||||
{t("executors:connection")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Run an agent on Linux amd64 or macOS hosts you can reach over SSH.
|
||||
</CardDescription>
|
||||
<CardDescription>{t("executors:sshConnectionDescription")}</CardDescription>
|
||||
</div>
|
||||
<ConnectionBadge fingerprint={c.form.host_fingerprint} />
|
||||
</div>
|
||||
@@ -316,6 +320,7 @@ export function SSHConnectionCard(props: SSHConnectionCardProps) {
|
||||
}
|
||||
|
||||
function PinnedFingerprintRow({ fingerprint }: { fingerprint: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid="ssh-fingerprint-pinned"
|
||||
@@ -323,7 +328,8 @@ function PinnedFingerprintRow({ fingerprint }: { fingerprint: string }) {
|
||||
>
|
||||
<IconShieldLock className="h-4 w-4 shrink-0" />
|
||||
<span className="text-muted-foreground">
|
||||
Pinned fingerprint:{" "}
|
||||
{t("executors:sshPinnedFingerprint")}{" "}
|
||||
{/* Never translated — the user compares it against their own host. */}
|
||||
<code data-testid="ssh-fingerprint-pinned-value" className="font-mono">
|
||||
{fingerprint}
|
||||
</code>
|
||||
@@ -349,6 +355,7 @@ function SSHConnectionActions({
|
||||
onSave: () => void;
|
||||
showSave: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
@@ -364,7 +371,7 @@ function SSHConnectionActions({
|
||||
) : (
|
||||
<IconTestPipe className="mr-1.5 h-4 w-4" />
|
||||
)}
|
||||
Test connection
|
||||
{t("executors:sshTestConnection")}
|
||||
</Button>
|
||||
{showSave && (
|
||||
<Button
|
||||
@@ -375,7 +382,7 @@ function SSHConnectionActions({
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{saving ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
|
||||
Save
|
||||
{t("executors:save")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -383,10 +390,11 @@ function SSHConnectionActions({
|
||||
}
|
||||
|
||||
function ConnectionBadge({ fingerprint }: { fingerprint?: string }) {
|
||||
const { t } = useTranslation();
|
||||
if (!fingerprint) {
|
||||
return (
|
||||
<Badge data-testid="ssh-connection-badge" data-status="unverified" variant="secondary">
|
||||
Unverified
|
||||
{t("executors:sshUnverified")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -397,7 +405,7 @@ function ConnectionBadge({ fingerprint }: { fingerprint?: string }) {
|
||||
variant="default"
|
||||
className="bg-green-600"
|
||||
>
|
||||
Trusted
|
||||
{t("executors:sshTrusted")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -444,6 +452,7 @@ function TestResultDisplay({
|
||||
}
|
||||
|
||||
function TestResultHeader({ success, totalMs }: { success: boolean; totalMs: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid={success ? "ssh-test-result-success" : "ssh-test-result-failure"}
|
||||
@@ -454,7 +463,7 @@ function TestResultHeader({ success, totalMs }: { success: boolean; totalMs: num
|
||||
) : (
|
||||
<IconX className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
{success ? "Connection test passed" : "Connection test failed"}
|
||||
{success ? t("executors:sshConnectionTestPassed") : t("executors:sshConnectionTestFailed")}
|
||||
<span className="text-muted-foreground font-normal">({totalMs}ms)</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
@@ -26,13 +27,14 @@ function fieldIsDirty<K extends keyof SSHExecutorConfig>(
|
||||
}
|
||||
|
||||
export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFormProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<TextField
|
||||
id="ssh-name"
|
||||
testId="ssh-input-name"
|
||||
label="Name"
|
||||
placeholder="My VPS"
|
||||
label={t("executors:name")}
|
||||
placeholder={t("executors:sshNamePlaceholder")}
|
||||
value={form.name}
|
||||
isDirty={fieldIsDirty(form, baseline, "name", "")}
|
||||
onChange={(value) => onChange("name", value)}
|
||||
@@ -40,8 +42,8 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
|
||||
<TextField
|
||||
id="ssh-host-alias"
|
||||
testId="ssh-input-host-alias"
|
||||
label="Host alias from ~/.ssh/config (optional)"
|
||||
hint="If set, inherits HostName / Port / User / IdentityFile / ProxyJump from your config."
|
||||
label={t("executors:sshHostAliasLabel", { path: "~/.ssh/config" })}
|
||||
hint={t("executors:sshHostAliasHint")}
|
||||
placeholder="prod"
|
||||
value={form.host_alias ?? ""}
|
||||
isDirty={fieldIsDirty(form, baseline, "host_alias", "")}
|
||||
@@ -50,7 +52,7 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
|
||||
<TextField
|
||||
id="ssh-host"
|
||||
testId="ssh-input-host"
|
||||
label="Host"
|
||||
label={t("executors:host")}
|
||||
placeholder="dev.example.com"
|
||||
value={form.host ?? ""}
|
||||
isDirty={fieldIsDirty(form, baseline, "host", "")}
|
||||
@@ -59,7 +61,7 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
|
||||
<TextField
|
||||
id="ssh-port"
|
||||
testId="ssh-input-port"
|
||||
label="Port"
|
||||
label={t("executors:port")}
|
||||
type="number"
|
||||
placeholder="22"
|
||||
value={String(form.port ?? 22)}
|
||||
@@ -69,7 +71,7 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
|
||||
<TextField
|
||||
id="ssh-user"
|
||||
testId="ssh-input-user"
|
||||
label="User"
|
||||
label={t("executors:user")}
|
||||
placeholder="ubuntu"
|
||||
value={form.user ?? ""}
|
||||
isDirty={fieldIsDirty(form, baseline, "user", "")}
|
||||
@@ -84,8 +86,8 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
|
||||
<TextField
|
||||
id="ssh-identity-file"
|
||||
testId="ssh-input-identity-file"
|
||||
label="Identity file path"
|
||||
hint="Passphrase-protected keys must be loaded into ssh-agent first."
|
||||
label={t("executors:sshIdentityFilePath")}
|
||||
hint={t("executors:sshIdentityFileHint")}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
value={form.identity_file ?? ""}
|
||||
isDirty={fieldIsDirty(form, baseline, "identity_file", "")}
|
||||
@@ -95,8 +97,8 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
|
||||
<TextField
|
||||
id="ssh-proxy-jump"
|
||||
testId="ssh-input-proxy-jump"
|
||||
label="ProxyJump (optional)"
|
||||
hint="Single bastion hop. Chained jumps are not supported."
|
||||
label={t("executors:sshProxyJumpLabel")}
|
||||
hint={t("executors:sshProxyJumpHint")}
|
||||
placeholder="bastion.example.com"
|
||||
value={form.proxy_jump ?? ""}
|
||||
isDirty={fieldIsDirty(form, baseline, "proxy_jump", "")}
|
||||
@@ -153,8 +155,9 @@ function IdentitySourceField({
|
||||
isDirty: boolean;
|
||||
onChange: (value: SSHIdentitySource) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FieldShell id="ssh-identity-source" label="Identity source">
|
||||
<FieldShell id="ssh-identity-source" label={t("executors:sshIdentitySource")}>
|
||||
<Select value={value} onValueChange={(next) => onChange(next as SSHIdentitySource)}>
|
||||
<SelectTrigger
|
||||
id="ssh-identity-source"
|
||||
@@ -168,7 +171,7 @@ function IdentitySourceField({
|
||||
ssh-agent (SSH_AUTH_SOCK)
|
||||
</SelectItem>
|
||||
<SelectItem value="file" data-testid="ssh-input-identity-source-file">
|
||||
Identity file (private key path)
|
||||
{t("executors:sshIdentitySourceFile")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
// Trust-confirmation block shown under the SSH test result. Disabled when the
|
||||
// caller's last test ran against a different form, to keep stale fingerprints
|
||||
// from being trusted for a new target.
|
||||
@@ -16,26 +18,33 @@ export function FingerprintTrustBlock({
|
||||
resultStale: boolean;
|
||||
onTrustChange: (v: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const fingerprintChanged = !!currentlyPinned && currentlyPinned !== fingerprint;
|
||||
return (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="text-xs">
|
||||
<span className="text-muted-foreground">Host fingerprint observed: </span>
|
||||
{/* The fingerprints below are never translated: the user compares them
|
||||
character-for-character against what their own host reports. */}
|
||||
<span className="text-muted-foreground">{t("executors:sshFingerprintObserved")} </span>
|
||||
<code data-testid="ssh-fingerprint-observed" className="font-mono">
|
||||
{fingerprint}
|
||||
</code>
|
||||
</div>
|
||||
{fingerprintChanged && (
|
||||
<p data-testid="ssh-fingerprint-change-warning" className="text-xs text-amber-600">
|
||||
Warning: this fingerprint differs from the one currently pinned (
|
||||
<code className="font-mono">{currentlyPinned}</code>). Trusting it overwrites the pinned
|
||||
key. If you didn’t expect a host re-key, stop here.
|
||||
<Trans
|
||||
i18nKey="executors:sshFingerprintChanged"
|
||||
values={{ fingerprint: currentlyPinned }}
|
||||
>
|
||||
Warning: this fingerprint differs from the one currently pinned (
|
||||
<code className="font-mono">{currentlyPinned}</code>). Trusting it overwrites the pinned
|
||||
key. If you didn’t expect a host re-key, stop here.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
{resultStale && (
|
||||
<p data-testid="ssh-test-result-stale" className="text-xs text-amber-600">
|
||||
You changed a connection field after this test ran. Run Test Connection again before
|
||||
trusting and saving.
|
||||
{t("executors:sshTestResultStale")}
|
||||
</p>
|
||||
)}
|
||||
<label
|
||||
@@ -53,8 +62,10 @@ export function FingerprintTrustBlock({
|
||||
className={"mt-0.5 " + (resultStale ? "cursor-not-allowed" : "cursor-pointer")}
|
||||
/>
|
||||
<span>
|
||||
<strong>Trust this host.</strong> I’ve verified the fingerprint above matches my server.
|
||||
Future connections that report a different fingerprint will be refused.
|
||||
<Trans i18nKey="executors:sshTrustThisHost">
|
||||
<strong>Trust this host.</strong> I’ve verified the fingerprint above matches my server.
|
||||
Future connections that report a different fingerprint will be refused.
|
||||
</Trans>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
@@ -16,6 +17,7 @@ export interface SSHSessionsCardProps {
|
||||
const REFRESH_INTERVAL_MS = 90_000;
|
||||
|
||||
export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [sessions, setSessions] = useState<SSHSession[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -34,11 +36,11 @@ export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
|
||||
setSessions(rows);
|
||||
} catch (e) {
|
||||
if (seq !== seqRef.current) return;
|
||||
setError(e instanceof Error ? e.message : "Failed to load sessions");
|
||||
setError(e instanceof Error ? e.message : t("executors:sshFailedToLoadSessions"));
|
||||
} finally {
|
||||
if (seq === seqRef.current) setLoading(false);
|
||||
}
|
||||
}, [executorId]);
|
||||
}, [executorId, t]);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset sequence so a previous executor's pending response can't land
|
||||
@@ -59,10 +61,8 @@ export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Active sessions</CardTitle>
|
||||
<CardDescription>
|
||||
Sessions currently running on this SSH host. Refreshes every 90 seconds.
|
||||
</CardDescription>
|
||||
<CardTitle>{t("executors:sshActiveSessions")}</CardTitle>
|
||||
<CardDescription>{t("executors:sshActiveSessionsDescription")}</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -73,7 +73,7 @@ export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{loading ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
|
||||
Refresh
|
||||
{t("executors:refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -93,6 +93,7 @@ function SSHSessionsBody({
|
||||
error: string | null;
|
||||
sessions: SSHSession[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (error) {
|
||||
return (
|
||||
<p data-testid="ssh-sessions-error" className="text-sm text-red-600">
|
||||
@@ -103,7 +104,7 @@ function SSHSessionsBody({
|
||||
if (sessions.length === 0 && !loading) {
|
||||
return (
|
||||
<p data-testid="ssh-sessions-empty" className="text-sm text-muted-foreground">
|
||||
No active sessions.
|
||||
{t("executors:sshNoActiveSessions")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -112,17 +113,18 @@ function SSHSessionsBody({
|
||||
}
|
||||
|
||||
function SSHSessionsTable({ sessions }: { sessions: SSHSession[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Table data-testid="ssh-sessions-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Task</TableHead>
|
||||
<TableHead>Session</TableHead>
|
||||
<TableHead>Host</TableHead>
|
||||
<TableHead>Remote port</TableHead>
|
||||
<TableHead>Local fwd</TableHead>
|
||||
<TableHead>Uptime</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>{t("executors:task")}</TableHead>
|
||||
<TableHead>{t("executors:session")}</TableHead>
|
||||
<TableHead>{t("executors:host")}</TableHead>
|
||||
<TableHead>{t("executors:sshRemotePort")}</TableHead>
|
||||
<TableHead>{t("executors:sshLocalForward")}</TableHead>
|
||||
<TableHead>{t("executors:uptime")}</TableHead>
|
||||
<TableHead>{t("executors:status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { FilterDimension } from "@/lib/state/slices/ui/sidebar-view-types";
|
||||
@@ -57,6 +58,11 @@ export function repositoryOptions(repositoriesByWorkspace: ReposByWorkspace): Op
|
||||
export function useFilterValueOptions(dimension: FilterDimension): Option[] {
|
||||
const snapshots = useAppStore((s) => s.kanbanMulti.snapshots);
|
||||
const repositoriesByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId);
|
||||
// `executorTypeOptions` resolves its labels through `getExecutorLabel`, which
|
||||
// now reads the catalog. Subscribing here — and keeping the language in the
|
||||
// memo's deps — is what makes those labels follow a runtime locale switch;
|
||||
// without it the memo only recomputes when store data changes.
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return useMemo(() => {
|
||||
if (dimension === "workflow") return workflowOptions(snapshots);
|
||||
@@ -64,5 +70,5 @@ export function useFilterValueOptions(dimension: FilterDimension): Option[] {
|
||||
if (dimension === "executorType") return executorTypeOptions(snapshots);
|
||||
if (dimension === "repository") return repositoryOptions(repositoriesByWorkspace);
|
||||
return [];
|
||||
}, [dimension, snapshots, repositoriesByWorkspace]);
|
||||
}, [dimension, snapshots, repositoriesByWorkspace, i18n.language]);
|
||||
}
|
||||
|
||||
@@ -158,6 +158,32 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [
|
||||
],
|
||||
},
|
||||
{ name: "settings — task actions", url: "/settings/general/task-actions" },
|
||||
{ name: "settings — plugins", url: "/settings/plugins" },
|
||||
// NOT YET: "settings — executors", "settings — account security",
|
||||
// "settings — account tokens". All three routes' own copy is fully migrated
|
||||
// and was verified by walking them under pseudo — including every dialog and
|
||||
// both marketplace tabs, since a static page scan never opens a Radix portal.
|
||||
// What stops them being entries here is what the fixture renders BESIDE that
|
||||
// copy, and in each case an `allow` entry would be the wrong fix:
|
||||
// - `/settings/executors` renders the seeded executor profiles' NAMES, and
|
||||
// the e2e fixture happens to name them `Local` and `Worktree`. That is
|
||||
// user data on exactly the footing as the workspace names below, so it
|
||||
// needs `findUnlocalizedText` to stop treating user data as eligible —
|
||||
// listing the two values would fix this fixture and leave every developer
|
||||
// instance broken under different names. The hub cards' own `Docker` and
|
||||
// `Sprites.dev` labels are brand nouns; they are in the guard's
|
||||
// `words.exclude` but not in `ALLOWED`, which is a real sync gap in that
|
||||
// list rather than something this migration should widen it to paper over.
|
||||
// - Both account routes render `not found` — the router's 404 body, because
|
||||
// the e2e profile does not register the auth routes, surfaced in the
|
||||
// sessions/tokens error region. It is a backend diagnostic, English by
|
||||
// design (docs/i18n.md, "What the backend deliberately does NOT
|
||||
// translate"), and allowlisting a payload string would hide the next real
|
||||
// miss in the same region.
|
||||
// The `plugins` entry above has neither problem and passes with no `allow`.
|
||||
// Its marketplace entries — names, descriptions, categories, source names and
|
||||
// index.json URLs — come from the catalog rather than the DOM on load.
|
||||
//
|
||||
// NOT YET: "settings — integrations github", "… gitlab", "… jira",
|
||||
// "… linear", "… sentry". Each page's
|
||||
// own copy is fully migrated (verified by running this oracle against it —
|
||||
|
||||
@@ -111,6 +111,12 @@ export const noLiteralStringOptions = {
|
||||
"to",
|
||||
"htmlFor",
|
||||
"data-.*",
|
||||
// `PluginErrorBoundary`'s console-log identifier (`slot "task-sidebar"`,
|
||||
// `route "/plugins/hello"`). It is written to console.error in
|
||||
// componentDidCatch and never reaches the DOM, so it is a developer
|
||||
// diagnostic rather than copy. The only literal `context=` values in the
|
||||
// tree are that boundary's four call sites.
|
||||
"context",
|
||||
// Identifiers and prefixes the caller composes into ids/testids.
|
||||
"id",
|
||||
"k",
|
||||
@@ -1200,4 +1206,143 @@ export const i18nGuardFiles = [
|
||||
"app/settings/automations/page.tsx",
|
||||
"app/settings/workspace/[[]id[]]/automations/**/*.tsx",
|
||||
"hooks/domains/settings/use-automation-runs.ts",
|
||||
// Settings → Plugins, Settings → Account, and the plural
|
||||
// `app/settings/executors/` tree (the executor list, the typed create
|
||||
// routes, and the SSH connection pages). Copy lives in two new namespaces,
|
||||
// `plugins` and `account`, plus additions to `executors`.
|
||||
//
|
||||
// `components/plugins/**` is reached from OUTSIDE Settings — the app
|
||||
// sidebar, the mobile menu sheet, the chat top bar, the chat input, the task
|
||||
// sidebar and the kanban top bar all render `PluginSlot`, and
|
||||
// `PluginModalHost` mounts at the app root. It is listed here because this
|
||||
// migration owns the Plugins feature end to end, not because those routes
|
||||
// are migrated; only the two strings those files own (`PluginRouteFallback`
|
||||
// and the mobile nav heading) went through `t()`. Everything else they
|
||||
// render — a slot component, a modal title, a nav item's label — is
|
||||
// plugin-authored and is third-party data, not our copy.
|
||||
//
|
||||
// Nine entries hold no JSX, or none that carries copy, so `mode: "jsx-only"`
|
||||
// never inspects them and the string count reported them as ZERO while they
|
||||
// owned copy that reaches the user. Each records that it was read by eye,
|
||||
// and only the pseudo-locale can prove it stays that way:
|
||||
// - `app/settings/executors/new/[type]/executor-types.ts` — reported 0 and
|
||||
// carried ELEVEN strings, the six type labels and their descriptions.
|
||||
// - `lib/executor-icons.ts` and `components/settings/executor-description.ts`
|
||||
// — plain functions returning copy, the shape one step out from a config
|
||||
// table and just as invisible.
|
||||
// - `components/settings/plugins/use-plugin-actions.ts` (eight toasts),
|
||||
// `use-plugin-config-form.ts` (five toasts plus the required-fields
|
||||
// reason), `lib/plugins/sync-summary.ts` (the sync toast), and the three
|
||||
// `hooks/domains/plugins/*.ts` error fallbacks. All import the
|
||||
// module-level `t` rather than the hook, so each resolves when its
|
||||
// callback fires and stays out of the dependency array.
|
||||
// - `app/settings/executors/new/[type]/ssh-config.ts` carries no copy at
|
||||
// all (snake_case config mapping); it is listed to pin that.
|
||||
//
|
||||
// This PR also closes the residual #2218 recorded against it:
|
||||
// `validateMcpPolicy` in `profile-edit/mcp-policy-card.tsx` returned English
|
||||
// ("Invalid JSON", "MCP policy must be a JSON object") because its only
|
||||
// callers were the un-migrated plural tree. It now returns catalog keys and
|
||||
// the card's prop is `mcpPolicyErrorKey`, matching the local validator in
|
||||
// `app/settings/executor/[id]/page.tsx`.
|
||||
//
|
||||
// `src/settings-routes.tsx` is listed for the first time. docs/i18n.md's
|
||||
// "Copy that belongs to no directory" rule says the LAST area to migrate
|
||||
// adds it, because allowlisting it earlier claims a completeness nobody has:
|
||||
// the two Account routes were the final inline English entries in
|
||||
// `SETTINGS_ROUTES`, so every entry in that table now resolves from a
|
||||
// catalog. Read the entry as a claim about that FILE, not about all of
|
||||
// Settings — `app/settings/automations/page.tsx`,
|
||||
// `app/settings/integrations/page.tsx` and `components/settings/agent-card.tsx`
|
||||
// still hold English and belong to their own migrations. They render no
|
||||
// route-table copy, which is why they do not block this entry.
|
||||
//
|
||||
// THREE surfaces describe the same six executor types and had already
|
||||
// drifted from one another: the hub cards use the imperative mood ("Run
|
||||
// agents directly…"), while the create and edit headers use the third person
|
||||
// ("Runs agents directly…") — and the edit header's SSH sentence differs
|
||||
// from the create header's again. Sentences that are byte-identical share a
|
||||
// key (the create page reuses `executors:description*`, which
|
||||
// `/settings/executor/:id` already owned); the ones that differ keep their
|
||||
// own. Folding them together would have rewritten shipping English on two of
|
||||
// the three with every gate green.
|
||||
// `components/settings/executor-copy.test.ts` pins all three tables
|
||||
// byte-for-byte, and `account/account-route-copy.test.ts` does the same for
|
||||
// the two route headers. De-duplicating the tables is a behaviour change and
|
||||
// belongs in its own PR.
|
||||
//
|
||||
// Deliberately left in English, none of it copy:
|
||||
// - The executor type keys `local` / `worktree` / `local_docker` /
|
||||
// `remote_docker` / `sprites` / `ssh`. They are the persisted enum, the
|
||||
// create route's path segment, and a `===` comparison in four places.
|
||||
// The `exec-*` executorIds are backend row ids. Only the labels beside
|
||||
// them are copy.
|
||||
// - The brand and protocol names `Docker`, `Sprites.dev` and `SSH` when
|
||||
// they ARE the whole label. They read the same in every locale (the
|
||||
// guard's own `words.exclude` lists all three), and keeping them out of
|
||||
// the catalog stops the pseudo-locale transliterating a name the user
|
||||
// must match against their SSH config or Sprites dashboard.
|
||||
// - Every SSH fingerprint and known-hosts value. `ssh-fingerprint-trust-block`
|
||||
// is a security surface: the user compares those strings
|
||||
// character-for-character against what their own host reports, so both
|
||||
// the observed and the pinned fingerprint are interpolated as values.
|
||||
// - The SSH example values `prod`, `dev.example.com`, `22`, `ubuntu`,
|
||||
// `~/.ssh/id_ed25519` and `bastion.example.com`, and the
|
||||
// `ssh-agent (SSH_AUTH_SOCK)` option — a program name plus the
|
||||
// environment variable it reads. `My VPS` beside them IS copy and does
|
||||
// go through `t()`. `~/.ssh/config`, `$PATH`, `.tar.gz` and `index.json`
|
||||
// are interpolated into their sentences for the same reason.
|
||||
// - The throw in `ssh-connection-card`'s `handleSave`. It is unreachable
|
||||
// while `canSave` gates the button and it signals the settings save
|
||||
// coordinator, not the user — the same shape docs/i18n.md records for
|
||||
// the migrated GitHub and Jira settings.
|
||||
// - Plugin ids, versions, manifest keys (`config_schema`), permission
|
||||
// scopes (`events:*`, `read:*`, `write:*`), webhook keys, marketplace
|
||||
// source URLs, and the raw manifest JSON dump. Those are the contract.
|
||||
// A plugin's display name, description, categories, icon, config-field
|
||||
// labels and modal titles come from its manifest or an untrusted
|
||||
// index.json, so they are third-party data — interpolated as values,
|
||||
// never written into a message.
|
||||
// - Account data: emails, usernames, user agents, IP addresses, and every
|
||||
// API token VALUE, which never passes through `t()` at all. Token names
|
||||
// are user data; only the column headers and role labels are copy.
|
||||
// - `PluginErrorBoundary`'s `context` prop, now in `jsx-attributes.exclude`
|
||||
// above — it is a console.error identifier that never reaches the DOM.
|
||||
//
|
||||
// `getExecutorLabel` returns catalog text now, so a caller that does not
|
||||
// subscribe to i18n renders the previous locale until something else makes it
|
||||
// re-render. Every consumer this PR owns subscribes, and
|
||||
// `use-filter-value-options.ts` (the task sidebar's executor filter) gained
|
||||
// `i18n.language` in its memo deps for the same reason. One consumer is left:
|
||||
// `lib/sidebar/apply-view.ts` builds a group label from it inside the sidebar
|
||||
// view pipeline, which this PR does not own — its labels resolve correctly on
|
||||
// load and lag a runtime locale switch until the view recomputes. It belongs
|
||||
// to whoever migrates `lib/sidebar`.
|
||||
//
|
||||
// One deliberate English change, the only one in this PR: the SSH
|
||||
// running-sessions confirm read "This executor has 3 running session(s)."
|
||||
// The `(s)` is the inline-plural shape docs/i18n.md rejects — the plural rule
|
||||
// cannot be expressed at the call site in a language with three or six forms
|
||||
// — so it is now `_one`/`_other` and reads "1 running session" /
|
||||
// "3 running sessions".
|
||||
//
|
||||
// Four dates moved off `toLocaleString()` onto `lib/i18n/formats`'
|
||||
// `formatDateTime`, and the marketplace star count off `toLocaleString()`
|
||||
// onto `formatNumber`, so they follow the active locale rather than the
|
||||
// browser's — the same call `storage-quarantine.ts` made.
|
||||
"app/settings/executors/**/*.{ts,tsx}",
|
||||
"components/settings/account/**/*.{ts,tsx}",
|
||||
"components/settings/executor-description.ts",
|
||||
"components/settings/plugins/**/*.{ts,tsx}",
|
||||
"components/settings/ssh-agent-readiness-card.tsx",
|
||||
"components/settings/ssh-connection-card.tsx",
|
||||
"components/settings/ssh-connection-form.tsx",
|
||||
"components/settings/ssh-fingerprint-trust-block.tsx",
|
||||
"components/settings/ssh-sessions-card.tsx",
|
||||
"components/settings/ssh-settings.tsx",
|
||||
"components/plugins/**/*.{ts,tsx}",
|
||||
"hooks/domains/plugins/*.ts",
|
||||
"lib/executor-icons.ts",
|
||||
"lib/plugins/sync-summary.ts",
|
||||
"src/settings-routes.tsx",
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "@/lib/toast/sonner";
|
||||
import { getPluginSettings, updatePluginSettings } from "@/lib/api/domains/plugins-api";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export type AutoUpdateSettings = {
|
||||
/** The instance-wide auto-update default (applies to plugins without an override). */
|
||||
@@ -49,7 +50,7 @@ export function useAutoUpdateSettings(): AutoUpdateSettings {
|
||||
setState(res.auto_update_default);
|
||||
} catch (err) {
|
||||
setState(prev); // roll back
|
||||
toast.error(err instanceof Error ? err.message : "Failed to update auto-update setting");
|
||||
toast.error(err instanceof Error ? err.message : t("plugins:failedToUpdateAutoUpdate"));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
refreshMarketplace,
|
||||
type CatalogQuery,
|
||||
} from "@/lib/api/domains/marketplace-api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { MarketplaceCatalog } from "@/lib/types/plugins";
|
||||
|
||||
const EMPTY_CATALOG: MarketplaceCatalog = { plugins: [], sources: [] };
|
||||
@@ -37,7 +38,7 @@ export function useMarketplace(query: CatalogQuery) {
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load marketplace");
|
||||
setError(err instanceof Error ? err.message : t("plugins:failedToLoadMarketplace"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { listPlugins } from "@/lib/api/domains/plugins-api";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,7 @@ export function usePlugins() {
|
||||
setPlugins(response);
|
||||
})
|
||||
.catch((err) => {
|
||||
setPluginsError(err instanceof Error ? err.message : "Failed to load plugins");
|
||||
setPluginsError(err instanceof Error ? err.message : t("plugins:failedToLoadPlugins"));
|
||||
})
|
||||
.finally(() => {
|
||||
setPluginsLoading(false);
|
||||
|
||||
@@ -22,9 +22,12 @@ describe("executor icons", () => {
|
||||
});
|
||||
|
||||
it("returns human-readable labels for known executor types", () => {
|
||||
expect(getExecutorLabel("local")).toBe("Local");
|
||||
expect(getExecutorLabel("worktree")).toBe("Worktree");
|
||||
expect(getExecutorLabel("local_docker")).toBe("Local Docker");
|
||||
expect(getExecutorLabel("remote_docker")).toBe("Remote Docker");
|
||||
expect(getExecutorLabel("sprites")).toBe("Sprites.dev");
|
||||
expect(getExecutorLabel("ssh")).toBe("SSH");
|
||||
expect(getExecutorLabel("does-not-exist")).toBe("does-not-exist");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
IconTerminal2,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
export const EXECUTOR_ICON_MAP: Record<string, typeof IconFolder> = {
|
||||
local: IconFolder,
|
||||
worktree: IconFolders,
|
||||
@@ -23,17 +25,32 @@ export function getExecutorIcon(type: string): typeof IconFolder {
|
||||
return EXECUTOR_ICON_MAP[type] ?? IconFolder;
|
||||
}
|
||||
|
||||
const EXECUTOR_LABEL_MAP: Record<string, string> = {
|
||||
local: "Local",
|
||||
worktree: "Worktree",
|
||||
local_docker: "Local Docker",
|
||||
remote_docker: "Remote Docker",
|
||||
// Brand and protocol names read the same in every locale, so they are values
|
||||
// rather than copy — the guard's `words.exclude` lists both. Keeping them out
|
||||
// of the catalog also keeps the pseudo-locale from transliterating them into
|
||||
// something the user cannot match against their own SSH config or Sprites
|
||||
// dashboard.
|
||||
const EXECUTOR_BRAND_LABEL_MAP: Record<string, string> = {
|
||||
sprites: "Sprites.dev",
|
||||
ssh: "SSH",
|
||||
};
|
||||
|
||||
// Keys, not messages: `t` resolves inside getExecutorLabel at call time, so
|
||||
// the label follows a locale switch. Assigning t() here would freeze it at the
|
||||
// boot locale.
|
||||
const EXECUTOR_LABEL_KEY_MAP: Record<string, string> = {
|
||||
local: "executors:typeLocal",
|
||||
worktree: "executors:typeWorktree",
|
||||
local_docker: "executors:localDocker",
|
||||
remote_docker: "executors:remoteDocker",
|
||||
};
|
||||
|
||||
export function getExecutorLabel(type: string): string {
|
||||
return EXECUTOR_LABEL_MAP[type] ?? type;
|
||||
const brand = EXECUTOR_BRAND_LABEL_MAP[type];
|
||||
if (brand) return brand;
|
||||
const key = EXECUTOR_LABEL_KEY_MAP[type];
|
||||
// An unmapped type echoes the raw wire value, which is an identifier.
|
||||
return key ? t(key) : type;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,14 +5,18 @@
|
||||
* surfaced separately (an inline `plugins-sync-errors` region) — they do
|
||||
* not affect this summary line.
|
||||
*/
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { SyncResult } from "@/lib/types/plugins";
|
||||
|
||||
export function summarizeSyncResult(result: SyncResult): string {
|
||||
const parts: string[] = [];
|
||||
if (result.added.length > 0) parts.push(`${result.added.length} sideloaded`);
|
||||
if (result.installed.length > 0) parts.push(`${result.installed.length} installed`);
|
||||
if (result.missing.length > 0) parts.push(`${result.missing.length} missing`);
|
||||
if (result.added.length > 0)
|
||||
parts.push(t("plugins:syncSideloaded", { count: result.added.length }));
|
||||
if (result.installed.length > 0)
|
||||
parts.push(t("plugins:syncInstalled", { count: result.installed.length }));
|
||||
if (result.missing.length > 0)
|
||||
parts.push(t("plugins:syncMissing", { count: result.missing.length }));
|
||||
|
||||
if (parts.length === 0) return "Everything up to date";
|
||||
return `Sync: ${parts.join(", ")}`;
|
||||
if (parts.length === 0) return t("plugins:syncUpToDate");
|
||||
return t("plugins:syncSummary", { parts: parts.join(", ") });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"actions": "Actions",
|
||||
"activeSessions": "Active sessions",
|
||||
"apiTokens": "API tokens",
|
||||
"apiTokensBlurb": "Personal access tokens authenticate scripts and CLIs as you. Revoke a token immediately if it may have leaked.",
|
||||
"cancel": "Cancel",
|
||||
"changePassword": "Change password",
|
||||
"couldNotChangePassword": "Could not change password.",
|
||||
"couldNotCreateToken": "Could not create token.",
|
||||
"createApiToken": "Create API token",
|
||||
"createApiTokenDescription": "Grants the same access as your account to any script or CLI that uses it. Give it a name so you can recognize it later.",
|
||||
"createToken": "Create token",
|
||||
"created": "Created",
|
||||
"creating": "Creating...",
|
||||
"currentPassword": "Current password",
|
||||
"device": "Device",
|
||||
"done": "Done",
|
||||
"failedToLoadSessions": "Failed to load sessions.",
|
||||
"failedToLoadTokens": "Failed to load tokens.",
|
||||
"ipAddress": "IP",
|
||||
"lastSeen": "Last seen",
|
||||
"lastUsed": "Last used",
|
||||
"loadingSessions": "Loading sessions...",
|
||||
"loadingTokens": "Loading tokens...",
|
||||
"name": "Name",
|
||||
"never": "Never",
|
||||
"newPassword": "New password",
|
||||
"newToken": "New token",
|
||||
"noTokensYet": "No tokens yet.",
|
||||
"password": "Password",
|
||||
"passwordUpdated": "Password updated.",
|
||||
"revoke": "Revoke",
|
||||
"saving": "Saving...",
|
||||
"securityPageDescription": "Change your password and review devices signed in to your account.",
|
||||
"securityPageTitle": "Profile & password",
|
||||
"signOut": "Sign out",
|
||||
"thisDevice": "This device",
|
||||
"tokenCreated": "Token created",
|
||||
"tokenShownOnlyOnce": "This is shown only once — copy it now and store it securely.",
|
||||
"tokensPageDescription": "Personal access tokens for scripts and CLIs acting as you.",
|
||||
"tokensPageTitle": "API tokens"
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"action": "Action",
|
||||
"add": "Add",
|
||||
"addASpritesApiKeyBeforeCreating": "Add a Sprites API key before creating the profile.",
|
||||
"addDockerfileContentBeforeCreating": "Add Dockerfile content before creating the profile.",
|
||||
"addRule": "Add Rule",
|
||||
"advanced": "Advanced",
|
||||
"agent": "Agent",
|
||||
"allow": "Allow",
|
||||
"allowlistGithubPlaywright": "Allowlist GitHub + Playwright",
|
||||
"apiKey": "API Key",
|
||||
@@ -14,6 +17,7 @@
|
||||
"buildFailedWithMessage": "Build failed: {{message}}",
|
||||
"buildFailedWithStatus": "Build failed ({{status}}): {{text}}",
|
||||
"buildImage": "Build Image",
|
||||
"buildThisDockerImageBeforeCreating": "Build this Docker image before creating the profile.",
|
||||
"building": "Building...",
|
||||
"checking": "Checking...",
|
||||
"chooseAnExecutorTypeToRun": "Choose an executor type to run environments on your infrastructure.",
|
||||
@@ -24,21 +28,26 @@
|
||||
"connected": "Connected",
|
||||
"connectedActiveSprites_one": "Connected. {{count}} active sprite.",
|
||||
"connectedActiveSprites_other": "Connected. {{count}} active sprites.",
|
||||
"connection": "Connection",
|
||||
"connectionSettings": "Connection Settings",
|
||||
"connectionTestFailed": "Connection test failed",
|
||||
"connectionTestPassed": "Connection test passed",
|
||||
"connectsToARemoteDockerHost": "Connects to a remote Docker host. The repository will be cloned inside the container.",
|
||||
"copyAuthFiles": "Copy auth files",
|
||||
"createANewProfileForThis": "Create a new profile for this executor. You can configure scripts and environment variables on the profile page.",
|
||||
"createExecutor": "Create Executor",
|
||||
"createNewProfile": "Create New Profile",
|
||||
"createNewSecret": "Create new secret...",
|
||||
"createProfile": "Create Profile",
|
||||
"creating": "Creating...",
|
||||
"creatingProfile": "Creating profile...",
|
||||
"defaultNoTls": "Default (no TLS)",
|
||||
"defineNetworkAccessRulesAppliedWhen": "Define network access rules applied when a sprite is created for this profile.",
|
||||
"defineTheDockerImageBuildAnd": "Define the Docker image. Build and test it here.",
|
||||
"delete": "Delete",
|
||||
"deleteExecutor": "Delete Executor",
|
||||
"deleteProfile": "Delete Profile",
|
||||
"deleteProfileConfirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleting": "Deleting...",
|
||||
"deny": "Deny",
|
||||
"descriptionCustom": "Custom executor.",
|
||||
@@ -46,6 +55,8 @@
|
||||
"descriptionLocalPc": "Runs agents directly in the repository folder.",
|
||||
"descriptionRemoteDocker": "Connects to a remote Docker host.",
|
||||
"descriptionSprites": "Runs agents in Sprites.dev cloud sandboxes.",
|
||||
"descriptionSsh": "Runs agents on a trusted Linux amd64 or macOS host over SSH.",
|
||||
"descriptionSshCreate": "Connects to a remote host over SSH and runs agentctl there.",
|
||||
"descriptionWorktree": "Creates git worktrees for isolated agent sessions.",
|
||||
"differentConfigurationsForThisExecutorEach": "Different configurations for this executor. Each profile can have its own prepare script, environment variables, and settings.",
|
||||
"disabled": "Disabled",
|
||||
@@ -59,6 +70,8 @@
|
||||
"eGMyApiToken": "e.g. {{example}}",
|
||||
"eGProductionDevelopment": "e.g. {{example}}",
|
||||
"enabled": "Enabled",
|
||||
"enterAProfileName": "Enter a profile name.",
|
||||
"enterAnImageTagBeforeCreating": "Enter an image tag before creating the profile.",
|
||||
"envVarsConfiguredCount_one": "{{count}} configured",
|
||||
"envVarsConfiguredCount_other": "{{count}} configured",
|
||||
"environmentVariables": "Environment Variables",
|
||||
@@ -69,6 +82,7 @@
|
||||
"failedToConnectToBackend": "Failed to connect to backend",
|
||||
"failedToCreateProfile": "Failed to create profile",
|
||||
"failedToCreateSecret": "Failed to create secret",
|
||||
"failedToLoadExecutor": "Failed to load executor",
|
||||
"failedToSaveProfile": "Failed to save profile",
|
||||
"filesSelected": "Files Selected",
|
||||
"gitIdentity": "Git Identity",
|
||||
@@ -78,6 +92,13 @@
|
||||
"gitUserEmail": "Git User Email",
|
||||
"gitUserName": "Git User Name",
|
||||
"goToExecutors": "Go to Executors",
|
||||
"host": "Host",
|
||||
"hubDescription": "Executor profiles define how and where agents run. Each profile configures an execution environment with scripts, environment variables, and MCP policies.",
|
||||
"hubDescriptionDocker": "Run Docker containers on this machine.",
|
||||
"hubDescriptionLocal": "Run agents directly in the repository folder.",
|
||||
"hubDescriptionSprites": "Run agents in Sprites.dev cloud sandboxes.",
|
||||
"hubDescriptionSsh": "Connect to a remote host over SSH and run agentctl there.",
|
||||
"hubDescriptionWorktree": "Create git worktrees for isolated agent sessions.",
|
||||
"image": "Image",
|
||||
"imageTag": "Image Tag",
|
||||
"include": "Include",
|
||||
@@ -88,6 +109,7 @@
|
||||
"key": "Key",
|
||||
"loading": "Loading...",
|
||||
"loadingEditor": "Loading editor...",
|
||||
"loadingExecutor": "Loading executor...",
|
||||
"localDocker": "Local Docker",
|
||||
"localDockerExecutor": "Local Docker Executor",
|
||||
"localGitIdentityNotDetected": "Local git user.name/user.email not detected on this machine",
|
||||
@@ -100,6 +122,8 @@
|
||||
"name": "Name",
|
||||
"networkPolicies": "Network Policies",
|
||||
"newProfile": "New Profile",
|
||||
"newSshExecutor": "New SSH Executor",
|
||||
"newTypeProfile": "New {{type}} Profile",
|
||||
"noDockerContainers": "No Docker containers.",
|
||||
"noEnvironmentVariablesConfigured": "No environment variables configured.",
|
||||
"noEnvironmentVariablesConfiguredAddOne": "No environment variables configured. Add one below.",
|
||||
@@ -108,6 +132,7 @@
|
||||
"noResponseBody": "No response body",
|
||||
"noTransferableCredentialsFound": "No transferable credentials found.",
|
||||
"none": "None",
|
||||
"notAnSshExecutor": "Executor {{id}} is not an SSH executor",
|
||||
"notConfigured": "Not Configured",
|
||||
"onlyHttpSse": "Only HTTP/SSE",
|
||||
"onlyStdio": "Only stdio",
|
||||
@@ -117,6 +142,7 @@
|
||||
"pasteYourSecretValue": "Paste your secret value...",
|
||||
"pathToTlsCertificatesForThe": "Path to TLS certificates for the Docker host.",
|
||||
"personalAccessTokenForCloningRepositories": "Personal access token for cloning repositories inside the container. Auto-detected from host environment if not set.",
|
||||
"port": "Port",
|
||||
"prepareScript": "Prepare Script",
|
||||
"prepareScriptBadge": "Prepare script",
|
||||
"prepareScriptDescriptionLocal": "Runs on the host machine before the agent starts.",
|
||||
@@ -129,6 +155,7 @@
|
||||
"profiles": "Profiles",
|
||||
"provideSecret": "Provide secret",
|
||||
"quickPresets": "Quick presets",
|
||||
"refresh": "Refresh",
|
||||
"relatedDockerContainersRemoved_one": "{{count}} related Docker container will also be removed.",
|
||||
"relatedDockerContainersRemoved_other": "{{count}} related Docker containers will also be removed.",
|
||||
"remoteAuthMethod": "Remote auth method",
|
||||
@@ -149,8 +176,63 @@
|
||||
"selectOrCreateASecret": "Select or create a secret...",
|
||||
"selectSecret": "Select secret...",
|
||||
"selectTheSecretContainingYourSprites": "Select the secret containing your Sprites.dev API token.",
|
||||
"session": "Session",
|
||||
"setACustomNameAndEmail": "Set a custom name and email for remote git commits.",
|
||||
"setEnvVarViaStoredSecret": "Set <1>{{envVar}}</1> via a stored secret",
|
||||
"spritesApiKeyIsRequired": "Sprites API key is required.",
|
||||
"spritesTokenIsRequired": "Sprites token is required.",
|
||||
"sshActiveSessions": "Active sessions",
|
||||
"sshActiveSessionsDescription": "Sessions currently running on this SSH host. Refreshes every 90 seconds.",
|
||||
"sshAvailableAgents": "Available agents on this host",
|
||||
"sshAvailableAgentsDescription": "Probes the remote {{pathVar}} for each enabled agent under the chosen login shell. Copy the install hint and run it on the remote when an agent is missing.",
|
||||
"sshBinary": "Binary",
|
||||
"sshConfirmRunningSessions_one": "This executor has {{count}} running session. They will keep running on the current host. Only new sessions started after save will use the updated config. Continue?",
|
||||
"sshConfirmRunningSessions_other": "This executor has {{count}} running sessions. They will keep running on the current host. Only new sessions started after save will use the updated config. Continue?",
|
||||
"sshConnectionDescription": "Run an agent on Linux amd64 or macOS hosts you can reach over SSH.",
|
||||
"sshConnectionTestFailed": "Connection test failed",
|
||||
"sshConnectionTestPassed": "Connection test passed",
|
||||
"sshCopyInstallHint": "Copy install hint",
|
||||
"sshCreateDescription": "Connect to a remote Linux amd64 or macOS host and run agentctl there.",
|
||||
"sshExecutorPageDescription": "Edit the connection settings or re-trust the host. Existing sessions keep their snapshot of the previous config.",
|
||||
"sshFailedToLoadSessions": "Failed to load sessions",
|
||||
"sshFailedToProbeAgents": "Failed to probe agents",
|
||||
"sshFailedToReachBackend": "Failed to reach backend",
|
||||
"sshFailedToSaveExecutor": "Failed to save executor",
|
||||
"sshFailedToSaveShell": "Failed to save shell",
|
||||
"sshFingerprintChanged": "Warning: this fingerprint differs from the one currently pinned (<1>{{fingerprint}}</1>). Trusting it overwrites the pinned key. If you didn’t expect a host re-key, stop here.",
|
||||
"sshFingerprintObserved": "Host fingerprint observed:",
|
||||
"sshHostAliasHint": "If set, inherits HostName / Port / User / IdentityFile / ProxyJump from your config.",
|
||||
"sshHostAliasLabel": "Host alias from {{path}} (optional)",
|
||||
"sshIdentityFileHint": "Passphrase-protected keys must be loaded into ssh-agent first.",
|
||||
"sshIdentityFilePath": "Identity file path",
|
||||
"sshIdentitySource": "Identity source",
|
||||
"sshIdentitySourceFile": "Identity file (private key path)",
|
||||
"sshInstallHint": "Install hint",
|
||||
"sshInstallHintCopied": "Install hint copied",
|
||||
"sshInstalled": "Installed",
|
||||
"sshLocalForward": "Local fwd",
|
||||
"sshLoginShell": "Login shell",
|
||||
"sshMissing": "Missing",
|
||||
"sshNamePlaceholder": "My VPS",
|
||||
"sshNoActiveSessions": "No active sessions.",
|
||||
"sshNoEnabledAgents": "No enabled agents to probe.",
|
||||
"sshNoHintAvailable": "No hint available",
|
||||
"sshPinnedFingerprint": "Pinned fingerprint:",
|
||||
"sshProbeAgents": "Probe agents",
|
||||
"sshProbeError": "Probe error",
|
||||
"sshProbeHint": "Click \"{{button}}\" to check which agents are installed on the remote.",
|
||||
"sshProxyJumpHint": "Single bastion hop. Chained jumps are not supported.",
|
||||
"sshProxyJumpLabel": "ProxyJump (optional)",
|
||||
"sshRemotePort": "Remote port",
|
||||
"sshReprobe": "Re-probe",
|
||||
"sshShellNotDetected": "(not detected)",
|
||||
"sshTestAndTrustToSave": "Test the connection and trust its fingerprint to save.",
|
||||
"sshTestConnection": "Test connection",
|
||||
"sshTestResultStale": "You changed a connection field after this test ran. Run Test Connection again before trusting and saving.",
|
||||
"sshTrustThisHost": "<0>Trust this host.</0> I’ve verified the fingerprint above matches my server. Future connections that report a different fingerprint will be refused.",
|
||||
"sshTrusted": "Trusted",
|
||||
"sshUnverified": "Unverified",
|
||||
"status": "Status",
|
||||
"stop": "Stop",
|
||||
"success": "Success",
|
||||
"task": "Task",
|
||||
@@ -160,12 +242,17 @@
|
||||
"tlsCertificatePath": "TLS certificate path",
|
||||
"tlsVerify": "TLS verify",
|
||||
"tokenConfiguredButUnableToConnect": "Token configured but unable to connect.",
|
||||
"typeLocal": "Local",
|
||||
"typeTokenToConfirmDeletion": "Type \"{{token}}\" to confirm deletion. This action cannot be undone.",
|
||||
"typeWorktree": "Worktree",
|
||||
"unknownError": "Unknown error",
|
||||
"unknownExecutorType": "Unknown executor type",
|
||||
"untrackedTask": "Untracked",
|
||||
"uptime": "Uptime",
|
||||
"useDefaults": "Use defaults",
|
||||
"useLocalGitConfig": "Use local git config",
|
||||
"usedByRemoteExecutorsForCommit": "Used by remote executors for commit author configuration.",
|
||||
"user": "User",
|
||||
"usesTheLocalDockerDaemonOn": "Uses the local Docker daemon on this machine.",
|
||||
"value": "Value",
|
||||
"verifyingConnection": "Verifying connection..."
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"add": "Add",
|
||||
"allCategories": "All categories",
|
||||
"autoUpdate": "Auto-update",
|
||||
"autoUpdateDescription": "Check the marketplace periodically and install newer versions of active plugins. Applies to every plugin unless overridden per plugin below.",
|
||||
"autoUpdateFor": "Auto-update for {{name}}",
|
||||
"autoUpdateTitle": "Automatically update plugins",
|
||||
"byAuthor": "by {{author}}",
|
||||
"cancel": "Cancel",
|
||||
"category": "Category",
|
||||
"confirmUninstall": "Confirm uninstall",
|
||||
"disable": "Disable",
|
||||
"dropzoneHint": "Drag and drop a {{extension}} package here, or click to browse",
|
||||
"enable": "Enable",
|
||||
"failedToAddSource": "Failed to add source",
|
||||
"failedToDisable": "Failed to disable {{name}}",
|
||||
"failedToEnable": "Failed to enable {{name}}",
|
||||
"failedToInstallPlugin": "Failed to install plugin",
|
||||
"failedToLoadMarketplace": "Failed to load marketplace",
|
||||
"failedToLoadPlugins": "Failed to load plugins",
|
||||
"failedToLoadSettings": "Failed to load plugin settings",
|
||||
"failedToRemoveSource": "Failed to remove source",
|
||||
"failedToSaveSettings": "Failed to save plugin settings",
|
||||
"failedToSetAutoUpdateFor": "Failed to update auto-update for {{name}}",
|
||||
"failedToSyncPlugins": "Failed to sync plugins",
|
||||
"failedToUninstall": "Failed to uninstall {{name}}",
|
||||
"failedToUpdateAutoUpdate": "Failed to update auto-update setting",
|
||||
"failedToUpdateSource": "Failed to update source",
|
||||
"fromUrl": "From URL",
|
||||
"hideRaw": "Hide raw",
|
||||
"install": "Install",
|
||||
"installPlugin": "Install plugin",
|
||||
"installPluginDescription": "Install a plugin package from a URL, or upload a {{extension}} file directly.",
|
||||
"installed": "Installed",
|
||||
"installedPlugins": "Installed plugins",
|
||||
"installing": "Installing…",
|
||||
"loadingMarketplace": "Loading marketplace…",
|
||||
"loadingPlugins": "Loading plugins...",
|
||||
"loadingSelectedUtilityAgent": "Loading selected utility agent...",
|
||||
"loadingSettings": "Loading settings...",
|
||||
"loadingUtilityAgents": "Loading utility agents...",
|
||||
"manifest": "Manifest",
|
||||
"manifestApiVersion": "API version",
|
||||
"manifestAuthor": "Author",
|
||||
"manifestCapabilities": "Capabilities",
|
||||
"manifestInstalled": "Installed",
|
||||
"manifestSigned": "Signed",
|
||||
"manifestVersion": "Version",
|
||||
"manifestWebhooks": "Webhooks",
|
||||
"marketplaceSources": "Marketplace sources",
|
||||
"marketplaceSourcesDescription": "Add a team or corporate registry to browse its plugins alongside the official catalog. Each source serves an {{document}} document.",
|
||||
"no": "no",
|
||||
"noDeclaredSettings": "This plugin does not declare any settings (no <1>config_schema</1> in its manifest).",
|
||||
"noInstalledPluginWithId": "No installed plugin with id <1>{{id}}</1>.",
|
||||
"noPluginsAvailable": "No plugins available yet. Check back soon, or add a marketplace source with your own.",
|
||||
"noPluginsMatchSearch": "No plugins match your search. Try clearing the filters.",
|
||||
"noPluginsYet": "No plugins yet. Install one from the Browse tab, or upload a package.",
|
||||
"notSet": "Not set",
|
||||
"override": "override",
|
||||
"packageUrl": "Package URL",
|
||||
"pluginInstalled": "{{name}} installed",
|
||||
"pluginPageFailedToLoad": "This plugin page failed to load.",
|
||||
"pluginsAvailable_one": "{{count}} plugin available",
|
||||
"pluginsAvailable_other": "{{count}} plugins available",
|
||||
"refresh": "Refresh",
|
||||
"removeSource": "Remove {{name}}",
|
||||
"repo": "Repo",
|
||||
"requiredFields": "Required: {{fields}}",
|
||||
"reset": "Reset",
|
||||
"resetAutoUpdateFor": "Reset auto-update for {{name}} to the default",
|
||||
"savingRestartsPlugin": "Saving restarts the plugin so the new settings take effect.",
|
||||
"searchPlugins": "Search plugins…",
|
||||
"selectAUtilityAgent": "Select a utility agent...",
|
||||
"selectPlaceholder": "Select...",
|
||||
"selectedUtilityAgentUnavailable": "Selected utility agent unavailable",
|
||||
"settings": "Settings",
|
||||
"settingsDescription": "Browse the marketplace, then install, enable, disable, and uninstall kandev plugins.",
|
||||
"settingsSaved": "Plugin settings saved",
|
||||
"settingsSavedReloadFailed": "Settings saved, but reloading them failed — refresh to confirm.",
|
||||
"sortMostStars": "Most stars",
|
||||
"sortName": "Name",
|
||||
"sortRecentlyUpdated": "Recently updated",
|
||||
"sourceNamePlaceholder": "Source name (e.g. Acme Internal)",
|
||||
"sourceOfficial": "Official",
|
||||
"sourceUnreachable": "Unreachable",
|
||||
"sourceUnreachableDetail": "<0>{{name}}</0> is unreachable{{detail}} — its plugins are hidden.",
|
||||
"sources": "Sources",
|
||||
"statusActive": "Active",
|
||||
"statusDisabled": "Disabled",
|
||||
"statusError": "Error",
|
||||
"statusRegistered": "Registered",
|
||||
"statusUninstalled": "Uninstalled",
|
||||
"sync": "Sync",
|
||||
"syncInstalled_one": "{{count}} installed",
|
||||
"syncInstalled_other": "{{count}} installed",
|
||||
"syncMissing_one": "{{count}} missing",
|
||||
"syncMissing_other": "{{count}} missing",
|
||||
"syncSideloaded_one": "{{count}} sideloaded",
|
||||
"syncSideloaded_other": "{{count}} sideloaded",
|
||||
"syncSummary": "Sync: {{parts}}",
|
||||
"syncUpToDate": "Everything up to date",
|
||||
"tabBrowse": "Browse",
|
||||
"tabInstalled": "Installed",
|
||||
"thisPlugin": "this plugin",
|
||||
"uninstall": "Uninstall",
|
||||
"uninstallConfirm": "This will permanently remove <2>{{name}}</2> and revoke its API key. This action cannot be undone.",
|
||||
"uninstallPlugin": "Uninstall plugin",
|
||||
"unsigned": "unsigned",
|
||||
"update": "Update",
|
||||
"updateToVersion": "Update to v{{version}}",
|
||||
"updating": "Updating…",
|
||||
"uploadFile": "Upload file",
|
||||
"utilityAgentsUnavailable": "Utility agents unavailable",
|
||||
"viewRaw": "View raw",
|
||||
"yes": "yes"
|
||||
}
|
||||
@@ -291,6 +291,7 @@
|
||||
"searchEditors": "Search editors...",
|
||||
"secrets": "Secrets",
|
||||
"secretValue": "Secret value",
|
||||
"security": "Security",
|
||||
"selectADefaultEditor": "Select a default editor",
|
||||
"selectAShell": "Select a shell",
|
||||
"sendTestNotification": "Send test notification",
|
||||
@@ -355,6 +356,7 @@
|
||||
"thisProfileWillBeRemovedWhen": "This profile will be removed when you save.",
|
||||
"thisSecret": "this secret",
|
||||
"thisWillPermanentlyRemoveSecret": "This will permanently remove <2>{{name}}</2>. This action cannot be undone.",
|
||||
"tokens": "Tokens",
|
||||
"transcriptNavigation": "Transcript Navigation",
|
||||
"tree": "Tree",
|
||||
"unableToConnect": "Unable to connect.",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"actions": "Àćţĩōńś",
|
||||
"activeSessions": "Àćţĩvē śēśśĩōńś",
|
||||
"apiTokens": "ÀƤĨ ţōķēńś",
|
||||
"apiTokensBlurb": "Ƥēŕśōńàĺ àććēśś ţōķēńś àũţĥēńţĩćàţē śćŕĩƥţś àńď ĆĹĨś àś ŷōũ. Ŕēvōķē à ţōķēń ĩḿḿēďĩàţēĺŷ ĩƒ ĩţ ḿàŷ ĥàvē ĺēàķēď.",
|
||||
"cancel": "Ćàńćēĺ",
|
||||
"changePassword": "Ćĥàńĝē ƥàśśŵōŕď",
|
||||
"couldNotChangePassword": "Ćōũĺď ńōţ ćĥàńĝē ƥàśśŵōŕď.",
|
||||
"couldNotCreateToken": "Ćōũĺď ńōţ ćŕēàţē ţōķēń.",
|
||||
"createApiToken": "Ćŕēàţē ÀƤĨ ţōķēń",
|
||||
"createApiTokenDescription": "Ĝŕàńţś ţĥē śàḿē àććēśś àś ŷōũŕ àććōũńţ ţō àńŷ śćŕĩƥţ ōŕ ĆĹĨ ţĥàţ ũśēś ĩţ. Ĝĩvē ĩţ à ńàḿē śō ŷōũ ćàń ŕēćōĝńĩźē ĩţ ĺàţēŕ.",
|
||||
"createToken": "Ćŕēàţē ţōķēń",
|
||||
"created": "Ćŕēàţēď",
|
||||
"creating": "Ćŕēàţĩńĝ...",
|
||||
"currentPassword": "Ćũŕŕēńţ ƥàśśŵōŕď",
|
||||
"device": "Ďēvĩćē",
|
||||
"done": "Ďōńē",
|
||||
"failedToLoadSessions": "Ƒàĩĺēď ţō ĺōàď śēśśĩōńś.",
|
||||
"failedToLoadTokens": "Ƒàĩĺēď ţō ĺōàď ţōķēńś.",
|
||||
"ipAddress": "ĨƤ",
|
||||
"lastSeen": "Ĺàśţ śēēń",
|
||||
"lastUsed": "Ĺàśţ ũśēď",
|
||||
"loadingSessions": "Ĺōàďĩńĝ śēśśĩōńś...",
|
||||
"loadingTokens": "Ĺōàďĩńĝ ţōķēńś...",
|
||||
"name": "Ńàḿē",
|
||||
"never": "Ńēvēŕ",
|
||||
"newPassword": "Ńēŵ ƥàśśŵōŕď",
|
||||
"newToken": "Ńēŵ ţōķēń",
|
||||
"noTokensYet": "Ńō ţōķēńś ŷēţ.",
|
||||
"password": "Ƥàśśŵōŕď",
|
||||
"passwordUpdated": "Ƥàśśŵōŕď ũƥďàţēď.",
|
||||
"revoke": "Ŕēvōķē",
|
||||
"saving": "Śàvĩńĝ...",
|
||||
"securityPageDescription": "Ćĥàńĝē ŷōũŕ ƥàśśŵōŕď àńď ŕēvĩēŵ ďēvĩćēś śĩĝńēď ĩń ţō ŷōũŕ àććōũńţ.",
|
||||
"securityPageTitle": "Ƥŕōƒĩĺē & ƥàśśŵōŕď",
|
||||
"signOut": "Śĩĝń ōũţ",
|
||||
"thisDevice": "Ţĥĩś ďēvĩćē",
|
||||
"tokenCreated": "Ţōķēń ćŕēàţēď",
|
||||
"tokenShownOnlyOnce": "Ţĥĩś ĩś śĥōŵń ōńĺŷ ōńćē — ćōƥŷ ĩţ ńōŵ àńď śţōŕē ĩţ śēćũŕēĺŷ.",
|
||||
"tokensPageDescription": "Ƥēŕśōńàĺ àććēśś ţōķēńś ƒōŕ śćŕĩƥţś àńď ĆĹĨś àćţĩńĝ àś ŷōũ.",
|
||||
"tokensPageTitle": "ÀƤĨ ţōķēńś"
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
{
|
||||
"action": "Àćţĩōń",
|
||||
"add": "Àďď",
|
||||
"addASpritesApiKeyBeforeCreating": "Àďď à Śƥŕĩţēś ÀƤĨ ķēŷ ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.",
|
||||
"addDockerfileContentBeforeCreating": "Àďď Ďōćķēŕƒĩĺē ćōńţēńţ ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.",
|
||||
"addRule": "Àďď Ŕũĺē",
|
||||
"advanced": "Àďvàńćēď",
|
||||
"agent": "Àĝēńţ",
|
||||
"allow": "Àĺĺōŵ",
|
||||
"allowlistGithubPlaywright": "Àĺĺōŵĺĩśţ ĜĩţĤũƀ + Ƥĺàŷŵŕĩĝĥţ",
|
||||
"apiKey": "ÀƤĨ Ķēŷ",
|
||||
@@ -14,6 +17,7 @@
|
||||
"buildFailedWithMessage": "Ɓũĩĺď ƒàĩĺēď: {{message}}",
|
||||
"buildFailedWithStatus": "Ɓũĩĺď ƒàĩĺēď ({{status}}): {{text}}",
|
||||
"buildImage": "Ɓũĩĺď Ĩḿàĝē",
|
||||
"buildThisDockerImageBeforeCreating": "Ɓũĩĺď ţĥĩś Ďōćķēŕ ĩḿàĝē ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.",
|
||||
"building": "Ɓũĩĺďĩńĝ...",
|
||||
"checking": "Ćĥēćķĩńĝ...",
|
||||
"chooseAnExecutorTypeToRun": "Ćĥōōśē àń ēxēćũţōŕ ţŷƥē ţō ŕũń ēńvĩŕōńḿēńţś ōń ŷōũŕ ĩńƒŕàśţŕũćţũŕē.",
|
||||
@@ -24,21 +28,26 @@
|
||||
"connected": "Ćōńńēćţēď",
|
||||
"connectedActiveSprites_one": "Ćōńńēćţēď. {{count}} àćţĩvē śƥŕĩţē.",
|
||||
"connectedActiveSprites_other": "Ćōńńēćţēď. {{count}} àćţĩvē śƥŕĩţēś.",
|
||||
"connection": "Ćōńńēćţĩōń",
|
||||
"connectionSettings": "Ćōńńēćţĩōń Śēţţĩńĝś",
|
||||
"connectionTestFailed": "Ćōńńēćţĩōń ţēśţ ƒàĩĺēď",
|
||||
"connectionTestPassed": "Ćōńńēćţĩōń ţēśţ ƥàśśēď",
|
||||
"connectsToARemoteDockerHost": "Ćōńńēćţś ţō à ŕēḿōţē Ďōćķēŕ ĥōśţ. Ţĥē ŕēƥōśĩţōŕŷ ŵĩĺĺ ƀē ćĺōńēď ĩńśĩďē ţĥē ćōńţàĩńēŕ.",
|
||||
"copyAuthFiles": "Ćōƥŷ àũţĥ ƒĩĺēś",
|
||||
"createANewProfileForThis": "Ćŕēàţē à ńēŵ ƥŕōƒĩĺē ƒōŕ ţĥĩś ēxēćũţōŕ. Ŷōũ ćàń ćōńƒĩĝũŕē śćŕĩƥţś àńď ēńvĩŕōńḿēńţ vàŕĩàƀĺēś ōń ţĥē ƥŕōƒĩĺē ƥàĝē.",
|
||||
"createExecutor": "Ćŕēàţē Ēxēćũţōŕ",
|
||||
"createNewProfile": "Ćŕēàţē Ńēŵ Ƥŕōƒĩĺē",
|
||||
"createNewSecret": "Ćŕēàţē ńēŵ śēćŕēţ...",
|
||||
"createProfile": "Ćŕēàţē Ƥŕōƒĩĺē",
|
||||
"creating": "Ćŕēàţĩńĝ...",
|
||||
"creatingProfile": "Ćŕēàţĩńĝ ƥŕōƒĩĺē...",
|
||||
"defaultNoTls": "Ďēƒàũĺţ (ńō ŢĹŚ)",
|
||||
"defineNetworkAccessRulesAppliedWhen": "Ďēƒĩńē ńēţŵōŕķ àććēśś ŕũĺēś àƥƥĺĩēď ŵĥēń à śƥŕĩţē ĩś ćŕēàţēď ƒōŕ ţĥĩś ƥŕōƒĩĺē.",
|
||||
"defineTheDockerImageBuildAnd": "Ďēƒĩńē ţĥē Ďōćķēŕ ĩḿàĝē. Ɓũĩĺď àńď ţēśţ ĩţ ĥēŕē.",
|
||||
"delete": "Ďēĺēţē",
|
||||
"deleteExecutor": "Ďēĺēţē Ēxēćũţōŕ",
|
||||
"deleteProfile": "Ďēĺēţē Ƥŕōƒĩĺē",
|
||||
"deleteProfileConfirm": "Àŕē ŷōũ śũŕē ŷōũ ŵàńţ ţō ďēĺēţē \"{{name}}\"? Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
"deleting": "Ďēĺēţĩńĝ...",
|
||||
"deny": "Ďēńŷ",
|
||||
"descriptionCustom": "Ćũśţōḿ ēxēćũţōŕ.",
|
||||
@@ -46,6 +55,8 @@
|
||||
"descriptionLocalPc": "Ŕũńś àĝēńţś ďĩŕēćţĺŷ ĩń ţĥē ŕēƥōśĩţōŕŷ ƒōĺďēŕ.",
|
||||
"descriptionRemoteDocker": "Ćōńńēćţś ţō à ŕēḿōţē Ďōćķēŕ ĥōśţ.",
|
||||
"descriptionSprites": "Ŕũńś àĝēńţś ĩń Śƥŕĩţēś.ďēv ćĺōũď śàńďƀōxēś.",
|
||||
"descriptionSsh": "Ŕũńś àĝēńţś ōń à ţŕũśţēď Ĺĩńũx àḿď64 ōŕ ḿàćŌŚ ĥōśţ ōvēŕ ŚŚĤ.",
|
||||
"descriptionSshCreate": "Ćōńńēćţś ţō à ŕēḿōţē ĥōśţ ōvēŕ ŚŚĤ àńď ŕũńś àĝēńţćţĺ ţĥēŕē.",
|
||||
"descriptionWorktree": "Ćŕēàţēś ĝĩţ ŵōŕķţŕēēś ƒōŕ ĩśōĺàţēď àĝēńţ śēśśĩōńś.",
|
||||
"differentConfigurationsForThisExecutorEach": "Ďĩƒƒēŕēńţ ćōńƒĩĝũŕàţĩōńś ƒōŕ ţĥĩś ēxēćũţōŕ. Ēàćĥ ƥŕōƒĩĺē ćàń ĥàvē ĩţś ōŵń ƥŕēƥàŕē śćŕĩƥţ, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, àńď śēţţĩńĝś.",
|
||||
"disabled": "Ďĩśàƀĺēď",
|
||||
@@ -59,6 +70,8 @@
|
||||
"eGMyApiToken": "ē.ĝ. {{example}}",
|
||||
"eGProductionDevelopment": "ē.ĝ. {{example}}",
|
||||
"enabled": "Ēńàƀĺēď",
|
||||
"enterAProfileName": "Ēńţēŕ à ƥŕōƒĩĺē ńàḿē.",
|
||||
"enterAnImageTagBeforeCreating": "Ēńţēŕ àń ĩḿàĝē ţàĝ ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.",
|
||||
"envVarsConfiguredCount_one": "{{count}} ćōńƒĩĝũŕēď",
|
||||
"envVarsConfiguredCount_other": "{{count}} ćōńƒĩĝũŕēď",
|
||||
"environmentVariables": "Ēńvĩŕōńḿēńţ Vàŕĩàƀĺēś",
|
||||
@@ -69,6 +82,7 @@
|
||||
"failedToConnectToBackend": "Ƒàĩĺēď ţō ćōńńēćţ ţō ƀàćķēńď",
|
||||
"failedToCreateProfile": "Ƒàĩĺēď ţō ćŕēàţē ƥŕōƒĩĺē",
|
||||
"failedToCreateSecret": "Ƒàĩĺēď ţō ćŕēàţē śēćŕēţ",
|
||||
"failedToLoadExecutor": "Ƒàĩĺēď ţō ĺōàď ēxēćũţōŕ",
|
||||
"failedToSaveProfile": "Ƒàĩĺēď ţō śàvē ƥŕōƒĩĺē",
|
||||
"filesSelected": "Ƒĩĺēś Śēĺēćţēď",
|
||||
"gitIdentity": "Ĝĩţ Ĩďēńţĩţŷ",
|
||||
@@ -78,6 +92,13 @@
|
||||
"gitUserEmail": "Ĝĩţ Ũśēŕ Ēḿàĩĺ",
|
||||
"gitUserName": "Ĝĩţ Ũśēŕ Ńàḿē",
|
||||
"goToExecutors": "Ĝō ţō Ēxēćũţōŕś",
|
||||
"host": "Ĥōśţ",
|
||||
"hubDescription": "Ēxēćũţōŕ ƥŕōƒĩĺēś ďēƒĩńē ĥōŵ àńď ŵĥēŕē àĝēńţś ŕũń. Ēàćĥ ƥŕōƒĩĺē ćōńƒĩĝũŕēś àń ēxēćũţĩōń ēńvĩŕōńḿēńţ ŵĩţĥ śćŕĩƥţś, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, àńď ḾĆƤ ƥōĺĩćĩēś.",
|
||||
"hubDescriptionDocker": "Ŕũń Ďōćķēŕ ćōńţàĩńēŕś ōń ţĥĩś ḿàćĥĩńē.",
|
||||
"hubDescriptionLocal": "Ŕũń àĝēńţś ďĩŕēćţĺŷ ĩń ţĥē ŕēƥōśĩţōŕŷ ƒōĺďēŕ.",
|
||||
"hubDescriptionSprites": "Ŕũń àĝēńţś ĩń Śƥŕĩţēś.ďēv ćĺōũď śàńďƀōxēś.",
|
||||
"hubDescriptionSsh": "Ćōńńēćţ ţō à ŕēḿōţē ĥōśţ ōvēŕ ŚŚĤ àńď ŕũń àĝēńţćţĺ ţĥēŕē.",
|
||||
"hubDescriptionWorktree": "Ćŕēàţē ĝĩţ ŵōŕķţŕēēś ƒōŕ ĩśōĺàţēď àĝēńţ śēśśĩōńś.",
|
||||
"image": "Ĩḿàĝē",
|
||||
"imageTag": "Ĩḿàĝē Ţàĝ",
|
||||
"include": "Ĩńćĺũďē",
|
||||
@@ -88,6 +109,7 @@
|
||||
"key": "Ķēŷ",
|
||||
"loading": "Ĺōàďĩńĝ...",
|
||||
"loadingEditor": "Ĺōàďĩńĝ ēďĩţōŕ...",
|
||||
"loadingExecutor": "Ĺōàďĩńĝ ēxēćũţōŕ...",
|
||||
"localDocker": "Ĺōćàĺ Ďōćķēŕ",
|
||||
"localDockerExecutor": "Ĺōćàĺ Ďōćķēŕ Ēxēćũţōŕ",
|
||||
"localGitIdentityNotDetected": "Ĺōćàĺ ĝĩţ ũśēŕ.ńàḿē/ũśēŕ.ēḿàĩĺ ńōţ ďēţēćţēď ōń ţĥĩś ḿàćĥĩńē",
|
||||
@@ -100,6 +122,8 @@
|
||||
"name": "Ńàḿē",
|
||||
"networkPolicies": "Ńēţŵōŕķ Ƥōĺĩćĩēś",
|
||||
"newProfile": "Ńēŵ Ƥŕōƒĩĺē",
|
||||
"newSshExecutor": "Ńēŵ ŚŚĤ Ēxēćũţōŕ",
|
||||
"newTypeProfile": "Ńēŵ {{type}} Ƥŕōƒĩĺē",
|
||||
"noDockerContainers": "Ńō Ďōćķēŕ ćōńţàĩńēŕś.",
|
||||
"noEnvironmentVariablesConfigured": "Ńō ēńvĩŕōńḿēńţ vàŕĩàƀĺēś ćōńƒĩĝũŕēď.",
|
||||
"noEnvironmentVariablesConfiguredAddOne": "Ńō ēńvĩŕōńḿēńţ vàŕĩàƀĺēś ćōńƒĩĝũŕēď. Àďď ōńē ƀēĺōŵ.",
|
||||
@@ -108,6 +132,7 @@
|
||||
"noResponseBody": "Ńō ŕēśƥōńśē ƀōďŷ",
|
||||
"noTransferableCredentialsFound": "Ńō ţŕàńśƒēŕàƀĺē ćŕēďēńţĩàĺś ƒōũńď.",
|
||||
"none": "Ńōńē",
|
||||
"notAnSshExecutor": "Ēxēćũţōŕ {{id}} ĩś ńōţ àń ŚŚĤ ēxēćũţōŕ",
|
||||
"notConfigured": "Ńōţ Ćōńƒĩĝũŕēď",
|
||||
"onlyHttpSse": "Ōńĺŷ ĤŢŢƤ/ŚŚĒ",
|
||||
"onlyStdio": "Ōńĺŷ śţďĩō",
|
||||
@@ -117,6 +142,7 @@
|
||||
"pasteYourSecretValue": "Ƥàśţē ŷōũŕ śēćŕēţ vàĺũē...",
|
||||
"pathToTlsCertificatesForThe": "Ƥàţĥ ţō ŢĹŚ ćēŕţĩƒĩćàţēś ƒōŕ ţĥē Ďōćķēŕ ĥōśţ.",
|
||||
"personalAccessTokenForCloningRepositories": "Ƥēŕśōńàĺ àććēśś ţōķēń ƒōŕ ćĺōńĩńĝ ŕēƥōśĩţōŕĩēś ĩńśĩďē ţĥē ćōńţàĩńēŕ. Àũţō-ďēţēćţēď ƒŕōḿ ĥōśţ ēńvĩŕōńḿēńţ ĩƒ ńōţ śēţ.",
|
||||
"port": "Ƥōŕţ",
|
||||
"prepareScript": "Ƥŕēƥàŕē Śćŕĩƥţ",
|
||||
"prepareScriptBadge": "Ƥŕēƥàŕē śćŕĩƥţ",
|
||||
"prepareScriptDescriptionLocal": "Ŕũńś ōń ţĥē ĥōśţ ḿàćĥĩńē ƀēƒōŕē ţĥē àĝēńţ śţàŕţś.",
|
||||
@@ -129,6 +155,7 @@
|
||||
"profiles": "Ƥŕōƒĩĺēś",
|
||||
"provideSecret": "Ƥŕōvĩďē śēćŕēţ",
|
||||
"quickPresets": "Qũĩćķ ƥŕēśēţś",
|
||||
"refresh": "Ŕēƒŕēśĥ",
|
||||
"relatedDockerContainersRemoved_one": "{{count}} ŕēĺàţēď Ďōćķēŕ ćōńţàĩńēŕ ŵĩĺĺ àĺśō ƀē ŕēḿōvēď.",
|
||||
"relatedDockerContainersRemoved_other": "{{count}} ŕēĺàţēď Ďōćķēŕ ćōńţàĩńēŕś ŵĩĺĺ àĺśō ƀē ŕēḿōvēď.",
|
||||
"remoteAuthMethod": "Ŕēḿōţē àũţĥ ḿēţĥōď",
|
||||
@@ -149,8 +176,63 @@
|
||||
"selectOrCreateASecret": "Śēĺēćţ ōŕ ćŕēàţē à śēćŕēţ...",
|
||||
"selectSecret": "Śēĺēćţ śēćŕēţ...",
|
||||
"selectTheSecretContainingYourSprites": "Śēĺēćţ ţĥē śēćŕēţ ćōńţàĩńĩńĝ ŷōũŕ Śƥŕĩţēś.ďēv ÀƤĨ ţōķēń.",
|
||||
"session": "Śēśśĩōń",
|
||||
"setACustomNameAndEmail": "Śēţ à ćũśţōḿ ńàḿē àńď ēḿàĩĺ ƒōŕ ŕēḿōţē ĝĩţ ćōḿḿĩţś.",
|
||||
"setEnvVarViaStoredSecret": "Śēţ <1>{{envVar}}</1> vĩà à śţōŕēď śēćŕēţ",
|
||||
"spritesApiKeyIsRequired": "Śƥŕĩţēś ÀƤĨ ķēŷ ĩś ŕēqũĩŕēď.",
|
||||
"spritesTokenIsRequired": "Śƥŕĩţēś ţōķēń ĩś ŕēqũĩŕēď.",
|
||||
"sshActiveSessions": "Àćţĩvē śēśśĩōńś",
|
||||
"sshActiveSessionsDescription": "Śēśśĩōńś ćũŕŕēńţĺŷ ŕũńńĩńĝ ōń ţĥĩś ŚŚĤ ĥōśţ. Ŕēƒŕēśĥēś ēvēŕŷ 90 śēćōńďś.",
|
||||
"sshAvailableAgents": "Àvàĩĺàƀĺē àĝēńţś ōń ţĥĩś ĥōśţ",
|
||||
"sshAvailableAgentsDescription": "Ƥŕōƀēś ţĥē ŕēḿōţē {{pathVar}} ƒōŕ ēàćĥ ēńàƀĺēď àĝēńţ ũńďēŕ ţĥē ćĥōśēń ĺōĝĩń śĥēĺĺ. Ćōƥŷ ţĥē ĩńśţàĺĺ ĥĩńţ àńď ŕũń ĩţ ōń ţĥē ŕēḿōţē ŵĥēń àń àĝēńţ ĩś ḿĩśśĩńĝ.",
|
||||
"sshBinary": "Ɓĩńàŕŷ",
|
||||
"sshConfirmRunningSessions_one": "Ţĥĩś ēxēćũţōŕ ĥàś {{count}} ŕũńńĩńĝ śēśśĩōń. Ţĥēŷ ŵĩĺĺ ķēēƥ ŕũńńĩńĝ ōń ţĥē ćũŕŕēńţ ĥōśţ. Ōńĺŷ ńēŵ śēśśĩōńś śţàŕţēď àƒţēŕ śàvē ŵĩĺĺ ũśē ţĥē ũƥďàţēď ćōńƒĩĝ. Ćōńţĩńũē?",
|
||||
"sshConfirmRunningSessions_other": "Ţĥĩś ēxēćũţōŕ ĥàś {{count}} ŕũńńĩńĝ śēśśĩōńś. Ţĥēŷ ŵĩĺĺ ķēēƥ ŕũńńĩńĝ ōń ţĥē ćũŕŕēńţ ĥōśţ. Ōńĺŷ ńēŵ śēśśĩōńś śţàŕţēď àƒţēŕ śàvē ŵĩĺĺ ũśē ţĥē ũƥďàţēď ćōńƒĩĝ. Ćōńţĩńũē?",
|
||||
"sshConnectionDescription": "Ŕũń àń àĝēńţ ōń Ĺĩńũx àḿď64 ōŕ ḿàćŌŚ ĥōśţś ŷōũ ćàń ŕēàćĥ ōvēŕ ŚŚĤ.",
|
||||
"sshConnectionTestFailed": "Ćōńńēćţĩōń ţēśţ ƒàĩĺēď",
|
||||
"sshConnectionTestPassed": "Ćōńńēćţĩōń ţēśţ ƥàśśēď",
|
||||
"sshCopyInstallHint": "Ćōƥŷ ĩńśţàĺĺ ĥĩńţ",
|
||||
"sshCreateDescription": "Ćōńńēćţ ţō à ŕēḿōţē Ĺĩńũx àḿď64 ōŕ ḿàćŌŚ ĥōśţ àńď ŕũń àĝēńţćţĺ ţĥēŕē.",
|
||||
"sshExecutorPageDescription": "Ēďĩţ ţĥē ćōńńēćţĩōń śēţţĩńĝś ōŕ ŕē-ţŕũśţ ţĥē ĥōśţ. Ēxĩśţĩńĝ śēśśĩōńś ķēēƥ ţĥēĩŕ śńàƥśĥōţ ōƒ ţĥē ƥŕēvĩōũś ćōńƒĩĝ.",
|
||||
"sshFailedToLoadSessions": "Ƒàĩĺēď ţō ĺōàď śēśśĩōńś",
|
||||
"sshFailedToProbeAgents": "Ƒàĩĺēď ţō ƥŕōƀē àĝēńţś",
|
||||
"sshFailedToReachBackend": "Ƒàĩĺēď ţō ŕēàćĥ ƀàćķēńď",
|
||||
"sshFailedToSaveExecutor": "Ƒàĩĺēď ţō śàvē ēxēćũţōŕ",
|
||||
"sshFailedToSaveShell": "Ƒàĩĺēď ţō śàvē śĥēĺĺ",
|
||||
"sshFingerprintChanged": "Ŵàŕńĩńĝ: ţĥĩś ƒĩńĝēŕƥŕĩńţ ďĩƒƒēŕś ƒŕōḿ ţĥē ōńē ćũŕŕēńţĺŷ ƥĩńńēď (<1>{{fingerprint}}</1>). Ţŕũśţĩńĝ ĩţ ōvēŕŵŕĩţēś ţĥē ƥĩńńēď ķēŷ. Ĩƒ ŷōũ ďĩďń’ţ ēxƥēćţ à ĥōśţ ŕē-ķēŷ, śţōƥ ĥēŕē.",
|
||||
"sshFingerprintObserved": "Ĥōśţ ƒĩńĝēŕƥŕĩńţ ōƀśēŕvēď:",
|
||||
"sshHostAliasHint": "Ĩƒ śēţ, ĩńĥēŕĩţś ĤōśţŃàḿē / Ƥōŕţ / Ũśēŕ / ĨďēńţĩţŷƑĩĺē / ƤŕōxŷĴũḿƥ ƒŕōḿ ŷōũŕ ćōńƒĩĝ.",
|
||||
"sshHostAliasLabel": "Ĥōśţ àĺĩàś ƒŕōḿ {{path}} (ōƥţĩōńàĺ)",
|
||||
"sshIdentityFileHint": "Ƥàśśƥĥŕàśē-ƥŕōţēćţēď ķēŷś ḿũśţ ƀē ĺōàďēď ĩńţō śśĥ-àĝēńţ ƒĩŕśţ.",
|
||||
"sshIdentityFilePath": "Ĩďēńţĩţŷ ƒĩĺē ƥàţĥ",
|
||||
"sshIdentitySource": "Ĩďēńţĩţŷ śōũŕćē",
|
||||
"sshIdentitySourceFile": "Ĩďēńţĩţŷ ƒĩĺē (ƥŕĩvàţē ķēŷ ƥàţĥ)",
|
||||
"sshInstallHint": "Ĩńśţàĺĺ ĥĩńţ",
|
||||
"sshInstallHintCopied": "Ĩńśţàĺĺ ĥĩńţ ćōƥĩēď",
|
||||
"sshInstalled": "Ĩńśţàĺĺēď",
|
||||
"sshLocalForward": "Ĺōćàĺ ƒŵď",
|
||||
"sshLoginShell": "Ĺōĝĩń śĥēĺĺ",
|
||||
"sshMissing": "Ḿĩśśĩńĝ",
|
||||
"sshNamePlaceholder": "Ḿŷ VƤŚ",
|
||||
"sshNoActiveSessions": "Ńō àćţĩvē śēśśĩōńś.",
|
||||
"sshNoEnabledAgents": "Ńō ēńàƀĺēď àĝēńţś ţō ƥŕōƀē.",
|
||||
"sshNoHintAvailable": "Ńō ĥĩńţ àvàĩĺàƀĺē",
|
||||
"sshPinnedFingerprint": "Ƥĩńńēď ƒĩńĝēŕƥŕĩńţ:",
|
||||
"sshProbeAgents": "Ƥŕōƀē àĝēńţś",
|
||||
"sshProbeError": "Ƥŕōƀē ēŕŕōŕ",
|
||||
"sshProbeHint": "Ćĺĩćķ \"{{button}}\" ţō ćĥēćķ ŵĥĩćĥ àĝēńţś àŕē ĩńśţàĺĺēď ōń ţĥē ŕēḿōţē.",
|
||||
"sshProxyJumpHint": "Śĩńĝĺē ƀàśţĩōń ĥōƥ. Ćĥàĩńēď ĵũḿƥś àŕē ńōţ śũƥƥōŕţēď.",
|
||||
"sshProxyJumpLabel": "ƤŕōxŷĴũḿƥ (ōƥţĩōńàĺ)",
|
||||
"sshRemotePort": "Ŕēḿōţē ƥōŕţ",
|
||||
"sshReprobe": "Ŕē-ƥŕōƀē",
|
||||
"sshShellNotDetected": "(ńōţ ďēţēćţēď)",
|
||||
"sshTestAndTrustToSave": "Ţēśţ ţĥē ćōńńēćţĩōń àńď ţŕũśţ ĩţś ƒĩńĝēŕƥŕĩńţ ţō śàvē.",
|
||||
"sshTestConnection": "Ţēśţ ćōńńēćţĩōń",
|
||||
"sshTestResultStale": "Ŷōũ ćĥàńĝēď à ćōńńēćţĩōń ƒĩēĺď àƒţēŕ ţĥĩś ţēśţ ŕàń. Ŕũń Ţēśţ Ćōńńēćţĩōń àĝàĩń ƀēƒōŕē ţŕũśţĩńĝ àńď śàvĩńĝ.",
|
||||
"sshTrustThisHost": "<0>Ţŕũśţ ţĥĩś ĥōśţ.</0> Ĩ’vē vēŕĩƒĩēď ţĥē ƒĩńĝēŕƥŕĩńţ àƀōvē ḿàţćĥēś ḿŷ śēŕvēŕ. Ƒũţũŕē ćōńńēćţĩōńś ţĥàţ ŕēƥōŕţ à ďĩƒƒēŕēńţ ƒĩńĝēŕƥŕĩńţ ŵĩĺĺ ƀē ŕēƒũśēď.",
|
||||
"sshTrusted": "Ţŕũśţēď",
|
||||
"sshUnverified": "Ũńvēŕĩƒĩēď",
|
||||
"status": "Śţàţũś",
|
||||
"stop": "Śţōƥ",
|
||||
"success": "Śũććēśś",
|
||||
"task": "Ţàśķ",
|
||||
@@ -160,12 +242,17 @@
|
||||
"tlsCertificatePath": "ŢĹŚ ćēŕţĩƒĩćàţē ƥàţĥ",
|
||||
"tlsVerify": "ŢĹŚ vēŕĩƒŷ",
|
||||
"tokenConfiguredButUnableToConnect": "Ţōķēń ćōńƒĩĝũŕēď ƀũţ ũńàƀĺē ţō ćōńńēćţ.",
|
||||
"typeLocal": "Ĺōćàĺ",
|
||||
"typeTokenToConfirmDeletion": "Ţŷƥē \"{{token}}\" ţō ćōńƒĩŕḿ ďēĺēţĩōń. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
"typeWorktree": "Ŵōŕķţŕēē",
|
||||
"unknownError": "Ũńķńōŵń ēŕŕōŕ",
|
||||
"unknownExecutorType": "Ũńķńōŵń ēxēćũţōŕ ţŷƥē",
|
||||
"untrackedTask": "Ũńţŕàćķēď",
|
||||
"uptime": "Ũƥţĩḿē",
|
||||
"useDefaults": "Ũśē ďēƒàũĺţś",
|
||||
"useLocalGitConfig": "Ũśē ĺōćàĺ ĝĩţ ćōńƒĩĝ",
|
||||
"usedByRemoteExecutorsForCommit": "Ũśēď ƀŷ ŕēḿōţē ēxēćũţōŕś ƒōŕ ćōḿḿĩţ àũţĥōŕ ćōńƒĩĝũŕàţĩōń.",
|
||||
"user": "Ũśēŕ",
|
||||
"usesTheLocalDockerDaemonOn": "Ũśēś ţĥē ĺōćàĺ Ďōćķēŕ ďàēḿōń ōń ţĥĩś ḿàćĥĩńē.",
|
||||
"value": "Vàĺũē",
|
||||
"verifyingConnection": "Vēŕĩƒŷĩńĝ ćōńńēćţĩōń..."
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"add": "Àďď",
|
||||
"allCategories": "Àĺĺ ćàţēĝōŕĩēś",
|
||||
"autoUpdate": "Àũţō-ũƥďàţē",
|
||||
"autoUpdateDescription": "Ćĥēćķ ţĥē ḿàŕķēţƥĺàćē ƥēŕĩōďĩćàĺĺŷ àńď ĩńśţàĺĺ ńēŵēŕ vēŕśĩōńś ōƒ àćţĩvē ƥĺũĝĩńś. Àƥƥĺĩēś ţō ēvēŕŷ ƥĺũĝĩń ũńĺēśś ōvēŕŕĩďďēń ƥēŕ ƥĺũĝĩń ƀēĺōŵ.",
|
||||
"autoUpdateFor": "Àũţō-ũƥďàţē ƒōŕ {{name}}",
|
||||
"autoUpdateTitle": "Àũţōḿàţĩćàĺĺŷ ũƥďàţē ƥĺũĝĩńś",
|
||||
"byAuthor": "ƀŷ {{author}}",
|
||||
"cancel": "Ćàńćēĺ",
|
||||
"category": "Ćàţēĝōŕŷ",
|
||||
"confirmUninstall": "Ćōńƒĩŕḿ ũńĩńśţàĺĺ",
|
||||
"disable": "Ďĩśàƀĺē",
|
||||
"dropzoneHint": "Ďŕàĝ àńď ďŕōƥ à {{extension}} ƥàćķàĝē ĥēŕē, ōŕ ćĺĩćķ ţō ƀŕōŵśē",
|
||||
"enable": "Ēńàƀĺē",
|
||||
"failedToAddSource": "Ƒàĩĺēď ţō àďď śōũŕćē",
|
||||
"failedToDisable": "Ƒàĩĺēď ţō ďĩśàƀĺē {{name}}",
|
||||
"failedToEnable": "Ƒàĩĺēď ţō ēńàƀĺē {{name}}",
|
||||
"failedToInstallPlugin": "Ƒàĩĺēď ţō ĩńśţàĺĺ ƥĺũĝĩń",
|
||||
"failedToLoadMarketplace": "Ƒàĩĺēď ţō ĺōàď ḿàŕķēţƥĺàćē",
|
||||
"failedToLoadPlugins": "Ƒàĩĺēď ţō ĺōàď ƥĺũĝĩńś",
|
||||
"failedToLoadSettings": "Ƒàĩĺēď ţō ĺōàď ƥĺũĝĩń śēţţĩńĝś",
|
||||
"failedToRemoveSource": "Ƒàĩĺēď ţō ŕēḿōvē śōũŕćē",
|
||||
"failedToSaveSettings": "Ƒàĩĺēď ţō śàvē ƥĺũĝĩń śēţţĩńĝś",
|
||||
"failedToSetAutoUpdateFor": "Ƒàĩĺēď ţō ũƥďàţē àũţō-ũƥďàţē ƒōŕ {{name}}",
|
||||
"failedToSyncPlugins": "Ƒàĩĺēď ţō śŷńć ƥĺũĝĩńś",
|
||||
"failedToUninstall": "Ƒàĩĺēď ţō ũńĩńśţàĺĺ {{name}}",
|
||||
"failedToUpdateAutoUpdate": "Ƒàĩĺēď ţō ũƥďàţē àũţō-ũƥďàţē śēţţĩńĝ",
|
||||
"failedToUpdateSource": "Ƒàĩĺēď ţō ũƥďàţē śōũŕćē",
|
||||
"fromUrl": "Ƒŕōḿ ŨŔĹ",
|
||||
"hideRaw": "Ĥĩďē ŕàŵ",
|
||||
"install": "Ĩńśţàĺĺ",
|
||||
"installPlugin": "Ĩńśţàĺĺ ƥĺũĝĩń",
|
||||
"installPluginDescription": "Ĩńśţàĺĺ à ƥĺũĝĩń ƥàćķàĝē ƒŕōḿ à ŨŔĹ, ōŕ ũƥĺōàď à {{extension}} ƒĩĺē ďĩŕēćţĺŷ.",
|
||||
"installed": "Ĩńśţàĺĺēď",
|
||||
"installedPlugins": "Ĩńśţàĺĺēď ƥĺũĝĩńś",
|
||||
"installing": "Ĩńśţàĺĺĩńĝ…",
|
||||
"loadingMarketplace": "Ĺōàďĩńĝ ḿàŕķēţƥĺàćē…",
|
||||
"loadingPlugins": "Ĺōàďĩńĝ ƥĺũĝĩńś...",
|
||||
"loadingSelectedUtilityAgent": "Ĺōàďĩńĝ śēĺēćţēď ũţĩĺĩţŷ àĝēńţ...",
|
||||
"loadingSettings": "Ĺōàďĩńĝ śēţţĩńĝś...",
|
||||
"loadingUtilityAgents": "Ĺōàďĩńĝ ũţĩĺĩţŷ àĝēńţś...",
|
||||
"manifest": "Ḿàńĩƒēśţ",
|
||||
"manifestApiVersion": "ÀƤĨ vēŕśĩōń",
|
||||
"manifestAuthor": "Àũţĥōŕ",
|
||||
"manifestCapabilities": "Ćàƥàƀĩĺĩţĩēś",
|
||||
"manifestInstalled": "Ĩńśţàĺĺēď",
|
||||
"manifestSigned": "Śĩĝńēď",
|
||||
"manifestVersion": "Vēŕśĩōń",
|
||||
"manifestWebhooks": "Ŵēƀĥōōķś",
|
||||
"marketplaceSources": "Ḿàŕķēţƥĺàćē śōũŕćēś",
|
||||
"marketplaceSourcesDescription": "Àďď à ţēàḿ ōŕ ćōŕƥōŕàţē ŕēĝĩśţŕŷ ţō ƀŕōŵśē ĩţś ƥĺũĝĩńś àĺōńĝśĩďē ţĥē ōƒƒĩćĩàĺ ćàţàĺōĝ. Ēàćĥ śōũŕćē śēŕvēś àń {{document}} ďōćũḿēńţ.",
|
||||
"no": "ńō",
|
||||
"noDeclaredSettings": "Ţĥĩś ƥĺũĝĩń ďōēś ńōţ ďēćĺàŕē àńŷ śēţţĩńĝś (ńō <1>ćōńƒĩĝ_śćĥēḿà</1> ĩń ĩţś ḿàńĩƒēśţ).",
|
||||
"noInstalledPluginWithId": "Ńō ĩńśţàĺĺēď ƥĺũĝĩń ŵĩţĥ ĩď <1>{{id}}</1>.",
|
||||
"noPluginsAvailable": "Ńō ƥĺũĝĩńś àvàĩĺàƀĺē ŷēţ. Ćĥēćķ ƀàćķ śōōń, ōŕ àďď à ḿàŕķēţƥĺàćē śōũŕćē ŵĩţĥ ŷōũŕ ōŵń.",
|
||||
"noPluginsMatchSearch": "Ńō ƥĺũĝĩńś ḿàţćĥ ŷōũŕ śēàŕćĥ. Ţŕŷ ćĺēàŕĩńĝ ţĥē ƒĩĺţēŕś.",
|
||||
"noPluginsYet": "Ńō ƥĺũĝĩńś ŷēţ. Ĩńśţàĺĺ ōńē ƒŕōḿ ţĥē Ɓŕōŵśē ţàƀ, ōŕ ũƥĺōàď à ƥàćķàĝē.",
|
||||
"notSet": "Ńōţ śēţ",
|
||||
"override": "ōvēŕŕĩďē",
|
||||
"packageUrl": "Ƥàćķàĝē ŨŔĹ",
|
||||
"pluginInstalled": "{{name}} ĩńśţàĺĺēď",
|
||||
"pluginPageFailedToLoad": "Ţĥĩś ƥĺũĝĩń ƥàĝē ƒàĩĺēď ţō ĺōàď.",
|
||||
"pluginsAvailable_one": "{{count}} ƥĺũĝĩń àvàĩĺàƀĺē",
|
||||
"pluginsAvailable_other": "{{count}} ƥĺũĝĩńś àvàĩĺàƀĺē",
|
||||
"refresh": "Ŕēƒŕēśĥ",
|
||||
"removeSource": "Ŕēḿōvē {{name}}",
|
||||
"repo": "Ŕēƥō",
|
||||
"requiredFields": "Ŕēqũĩŕēď: {{fields}}",
|
||||
"reset": "Ŕēśēţ",
|
||||
"resetAutoUpdateFor": "Ŕēśēţ àũţō-ũƥďàţē ƒōŕ {{name}} ţō ţĥē ďēƒàũĺţ",
|
||||
"savingRestartsPlugin": "Śàvĩńĝ ŕēśţàŕţś ţĥē ƥĺũĝĩń śō ţĥē ńēŵ śēţţĩńĝś ţàķē ēƒƒēćţ.",
|
||||
"searchPlugins": "Śēàŕćĥ ƥĺũĝĩńś…",
|
||||
"selectAUtilityAgent": "Śēĺēćţ à ũţĩĺĩţŷ àĝēńţ...",
|
||||
"selectPlaceholder": "Śēĺēćţ...",
|
||||
"selectedUtilityAgentUnavailable": "Śēĺēćţēď ũţĩĺĩţŷ àĝēńţ ũńàvàĩĺàƀĺē",
|
||||
"settings": "Śēţţĩńĝś",
|
||||
"settingsDescription": "Ɓŕōŵśē ţĥē ḿàŕķēţƥĺàćē, ţĥēń ĩńśţàĺĺ, ēńàƀĺē, ďĩśàƀĺē, àńď ũńĩńśţàĺĺ ķàńďēv ƥĺũĝĩńś.",
|
||||
"settingsSaved": "Ƥĺũĝĩń śēţţĩńĝś śàvēď",
|
||||
"settingsSavedReloadFailed": "Śēţţĩńĝś śàvēď, ƀũţ ŕēĺōàďĩńĝ ţĥēḿ ƒàĩĺēď — ŕēƒŕēśĥ ţō ćōńƒĩŕḿ.",
|
||||
"sortMostStars": "Ḿōśţ śţàŕś",
|
||||
"sortName": "Ńàḿē",
|
||||
"sortRecentlyUpdated": "Ŕēćēńţĺŷ ũƥďàţēď",
|
||||
"sourceNamePlaceholder": "Śōũŕćē ńàḿē (ē.ĝ. Àćḿē Ĩńţēŕńàĺ)",
|
||||
"sourceOfficial": "Ōƒƒĩćĩàĺ",
|
||||
"sourceUnreachable": "Ũńŕēàćĥàƀĺē",
|
||||
"sourceUnreachableDetail": "<0>{{name}}</0> ĩś ũńŕēàćĥàƀĺē{{detail}} — ĩţś ƥĺũĝĩńś àŕē ĥĩďďēń.",
|
||||
"sources": "Śōũŕćēś",
|
||||
"statusActive": "Àćţĩvē",
|
||||
"statusDisabled": "Ďĩśàƀĺēď",
|
||||
"statusError": "Ēŕŕōŕ",
|
||||
"statusRegistered": "Ŕēĝĩśţēŕēď",
|
||||
"statusUninstalled": "Ũńĩńśţàĺĺēď",
|
||||
"sync": "Śŷńć",
|
||||
"syncInstalled_one": "{{count}} ĩńśţàĺĺēď",
|
||||
"syncInstalled_other": "{{count}} ĩńśţàĺĺēď",
|
||||
"syncMissing_one": "{{count}} ḿĩśśĩńĝ",
|
||||
"syncMissing_other": "{{count}} ḿĩśśĩńĝ",
|
||||
"syncSideloaded_one": "{{count}} śĩďēĺōàďēď",
|
||||
"syncSideloaded_other": "{{count}} śĩďēĺōàďēď",
|
||||
"syncSummary": "Śŷńć: {{parts}}",
|
||||
"syncUpToDate": "Ēvēŕŷţĥĩńĝ ũƥ ţō ďàţē",
|
||||
"tabBrowse": "Ɓŕōŵśē",
|
||||
"tabInstalled": "Ĩńśţàĺĺēď",
|
||||
"thisPlugin": "ţĥĩś ƥĺũĝĩń",
|
||||
"uninstall": "Ũńĩńśţàĺĺ",
|
||||
"uninstallConfirm": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē <2>{{name}}</2> àńď ŕēvōķē ĩţś ÀƤĨ ķēŷ. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
"uninstallPlugin": "Ũńĩńśţàĺĺ ƥĺũĝĩń",
|
||||
"unsigned": "ũńśĩĝńēď",
|
||||
"update": "Ũƥďàţē",
|
||||
"updateToVersion": "Ũƥďàţē ţō v{{version}}",
|
||||
"updating": "Ũƥďàţĩńĝ…",
|
||||
"uploadFile": "Ũƥĺōàď ƒĩĺē",
|
||||
"utilityAgentsUnavailable": "Ũţĩĺĩţŷ àĝēńţś ũńàvàĩĺàƀĺē",
|
||||
"viewRaw": "Vĩēŵ ŕàŵ",
|
||||
"yes": "ŷēś"
|
||||
}
|
||||
@@ -291,6 +291,7 @@
|
||||
"searchEditors": "Śēàŕćĥ ēďĩţōŕś...",
|
||||
"secrets": "Śēćŕēţś",
|
||||
"secretValue": "Śēćŕēţ vàĺũē",
|
||||
"security": "Śēćũŕĩţŷ",
|
||||
"selectADefaultEditor": "Śēĺēćţ à ďēƒàũĺţ ēďĩţōŕ",
|
||||
"selectAShell": "Śēĺēćţ à śĥēĺĺ",
|
||||
"sendTestNotification": "Śēńď ţēśţ ńōţĩƒĩćàţĩōń",
|
||||
@@ -355,6 +356,7 @@
|
||||
"thisProfileWillBeRemovedWhen": "Ţĥĩś ƥŕōƒĩĺē ŵĩĺĺ ƀē ŕēḿōvēď ŵĥēń ŷōũ śàvē.",
|
||||
"thisSecret": "ţĥĩś śēćŕēţ",
|
||||
"thisWillPermanentlyRemoveSecret": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē <2>{{name}}</2>. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
"tokens": "Ţōķēńś",
|
||||
"transcriptNavigation": "Ţŕàńśćŕĩƥţ Ńàvĩĝàţĩōń",
|
||||
"tree": "Ţŕēē",
|
||||
"unableToConnect": "Ũńàƀĺē ţō ćōńńēćţ.",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import AgentsSettingsPage from "@/app/settings/agents/page";
|
||||
import AgentSetupPage from "@/app/settings/agents/[agentId]/page";
|
||||
@@ -176,22 +176,8 @@ const SETTINGS_ROUTES: Record<string, RouteRenderer> = {
|
||||
<UsersTable />
|
||||
</SystemRouteShell>
|
||||
),
|
||||
"/settings/account/security": () => (
|
||||
<SystemPageShell
|
||||
title="Profile & password"
|
||||
description="Change your password and review devices signed in to your account."
|
||||
>
|
||||
<SecuritySettings />
|
||||
</SystemPageShell>
|
||||
),
|
||||
"/settings/account/tokens": () => (
|
||||
<SystemPageShell
|
||||
title="API tokens"
|
||||
description="Personal access tokens for scripts and CLIs acting as you."
|
||||
>
|
||||
<ApiTokens />
|
||||
</SystemPageShell>
|
||||
),
|
||||
"/settings/account/security": () => <AccountSecurityRoute />,
|
||||
"/settings/account/tokens": () => <AccountTokensRoute />,
|
||||
"/settings/system/about": () => (
|
||||
<SystemRouteShell titleKey="system:navAbout" descriptionKey="system:aboutPageDescription">
|
||||
<AboutCard />
|
||||
@@ -418,6 +404,32 @@ function renderIntegrationSettingsRoute(section: string | null, workspaceId?: st
|
||||
}
|
||||
}
|
||||
|
||||
// Components rather than inline JSX so `t()` resolves at render — a t() call
|
||||
// inside SETTINGS_ROUTES would run at module load and freeze at the boot locale.
|
||||
function AccountSecurityRoute() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SystemPageShell
|
||||
title={t("account:securityPageTitle")}
|
||||
description={t("account:securityPageDescription")}
|
||||
>
|
||||
<SecuritySettings />
|
||||
</SystemPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountTokensRoute() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SystemPageShell
|
||||
title={t("account:tokensPageTitle")}
|
||||
description={t("account:tokensPageDescription")}
|
||||
>
|
||||
<ApiTokens />
|
||||
</SystemPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function renderUpdatesRoute() {
|
||||
return <UpdatesRoute />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user