@@ -94,108 +87,56 @@ export function CronPage() {
}
/>
-
+ {/* Toolbar */}
+
+
+
+
+
{showSkeleton ? (
-
- ) : jobs.length === 0 ? (
+
+ ) : filtered.length === 0 ? (
setShowCreate(true)} className="gap-1">
- {t("newJob")}
-
+ !search ? (
+
+ ) : undefined
}
/>
) : (
-
-
-
-
- | {t("columns.enabled")} |
- {t("columns.name")} |
- {t("columns.schedule")} |
- {t("columns.message")} |
- {t("columns.agent")} |
- {t("columns.actions")} |
-
-
-
- {pageItems.map((job: CronJob) => (
-
- |
- setToggleTarget({ job, enabled: checked })}
- />
- |
-
-
- |
-
- {formatSchedule(job)}
- |
-
- {job.payload?.message}
- |
-
- {job.agentId ? (
- {job.agentId}
- ) : (
- {t("defaultAgent")}
- )}
- |
-
-
-
-
-
-
- |
-
- ))}
-
-
-
-
+ <>
+
+ {pageItems.map((job) => (
+ navigate(`/cron/${job.id}`)}
+ onRun={() => runJob(job.id)}
+ onDelete={() => setDeleteTarget(job)}
+ />
+ ))}
+
+
+ >
)}
@@ -205,25 +146,6 @@ export function CronPage() {
onSubmit={createJob}
/>
- {toggleTarget && (
-
setToggleTarget(null)}
- title={toggleTarget.enabled ? t("enable.title") : t("disable.title")}
- description={
- toggleTarget.enabled
- ? t("enable.description", { name: toggleTarget.job.name })
- : t("disable.description", { name: toggleTarget.job.name })
- }
- confirmLabel={toggleTarget.enabled ? t("enable.confirmLabel") : t("disable.confirmLabel")}
- variant={toggleTarget.enabled ? "default" : "destructive"}
- onConfirm={async () => {
- await toggleJob(toggleTarget.job.id, toggleTarget.enabled);
- setToggleTarget(null);
- }}
- />
- )}
-
{deleteTarget && (
)}
- {runLogTarget && (
- setRunLogTarget(null)}
- jobName={runLogTarget.name}
- entries={runLogEntries}
- loading={runLogLoading}
- />
- )}
);
}
diff --git a/ui/web/src/pages/cron/cron-utils.tsx b/ui/web/src/pages/cron/cron-utils.tsx
new file mode 100644
index 00000000..2012ab9d
--- /dev/null
+++ b/ui/web/src/pages/cron/cron-utils.tsx
@@ -0,0 +1,34 @@
+import { Badge } from "@/components/ui/badge";
+import type { CronJob } from "./hooks/use-cron";
+
+export function formatSchedule(job: CronJob): string {
+ const s = job.schedule;
+ if (s.kind === "every" && s.everyMs) {
+ const sec = s.everyMs / 1000;
+ if (sec < 60) return `every ${sec}s`;
+ if (sec < 3600) return `every ${Math.round(sec / 60)}m`;
+ return `every ${Math.round(sec / 3600)}h`;
+ }
+ if (s.kind === "cron" && s.expr) return s.expr;
+ if (s.kind === "at" && s.atMs) return `once at ${new Date(s.atMs).toLocaleString()}`;
+ return s.kind;
+}
+
+/** Status badge for cron jobs. */
+export function CronStatusBadge({ status }: { status?: string }) {
+ if (!status) return null;
+ if (status === "running") {
+ return (
+
+ {status}
+
+ );
+ }
+ if (status === "ok" || status === "success") {
+ return
{status};
+ }
+ if (status === "error" || status === "failed") {
+ return
{status};
+ }
+ return
{status};
+}
diff --git a/ui/web/src/pages/cron/hooks/use-cron.ts b/ui/web/src/pages/cron/hooks/use-cron.ts
index 677d2146..46e0fef4 100644
--- a/ui/web/src/pages/cron/hooks/use-cron.ts
+++ b/ui/web/src/pages/cron/hooks/use-cron.ts
@@ -149,5 +149,19 @@ export function useCron() {
[ws],
);
- return { jobs, loading, refreshing, refresh: invalidate, createJob, toggleJob, deleteJob, runJob, getRunLog };
+ const updateJob = useCallback(
+ async (jobId: string, params: Record
) => {
+ try {
+ await ws.call(Methods.CRON_UPDATE, { jobId, ...params });
+ await invalidate();
+ toast.success(i18next.t("cron:toast.updated"));
+ } catch (err) {
+ toast.error(i18next.t("cron:toast.failedUpdate"), err instanceof Error ? err.message : "");
+ throw err;
+ }
+ },
+ [ws, invalidate],
+ );
+
+ return { jobs, loading, refreshing, refresh: invalidate, createJob, toggleJob, deleteJob, runJob, getRunLog, updateJob };
}
diff --git a/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx b/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx
new file mode 100644
index 00000000..d2aabb4d
--- /dev/null
+++ b/ui/web/src/pages/providers/provider-detail/provider-advanced-dialog.tsx
@@ -0,0 +1,277 @@
+import { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { Save, Settings } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { ConfigGroupHeader } from "@/components/shared/config-group-header";
+import { PROVIDER_TYPES } from "@/constants/providers";
+import { CLISection } from "../provider-cli-section";
+import { OAuthSection } from "../provider-oauth-section";
+import type { ProviderData, ProviderInput } from "@/types/provider";
+
+interface ProviderAdvancedDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ provider: ProviderData;
+ onUpdate: (id: string, data: ProviderInput) => Promise;
+}
+
+function deriveState(provider: ProviderData) {
+ const s = provider.settings as Record | undefined;
+ return {
+ apiBase: provider.api_base || "",
+ acpBinary: provider.provider_type === "acp" ? (provider.api_base || "") : "",
+ acpArgs: Array.isArray(s?.args) ? (s.args as string[]).join(" ") : "",
+ acpIdleTTL: (s?.idle_ttl as string) || "",
+ acpPermMode: (s?.perm_mode as string) || "approve-all",
+ acpWorkDir: (s?.work_dir as string) || "",
+ };
+}
+
+export function ProviderAdvancedDialog({
+ open,
+ onOpenChange,
+ provider,
+ onUpdate,
+}: ProviderAdvancedDialogProps) {
+ const { t } = useTranslation("providers");
+
+ const isACP = provider.provider_type === "acp";
+ const isCLI = provider.provider_type === "claude_cli";
+ const isOAuth = provider.provider_type === "chatgpt_oauth";
+ const isStandard = !isACP && !isCLI && !isOAuth;
+
+ const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type);
+
+ const init = deriveState(provider);
+ const [apiBase, setApiBase] = useState(init.apiBase);
+ const [acpBinary, setAcpBinary] = useState(init.acpBinary);
+ const [acpArgs, setAcpArgs] = useState(init.acpArgs);
+ const [acpIdleTTL, setAcpIdleTTL] = useState(init.acpIdleTTL);
+ const [acpPermMode, setAcpPermMode] = useState(init.acpPermMode);
+ const [acpWorkDir, setAcpWorkDir] = useState(init.acpWorkDir);
+
+ // Re-sync when dialog opens
+ useEffect(() => {
+ if (!open) return;
+ const s = deriveState(provider);
+ setApiBase(s.apiBase);
+ setAcpBinary(s.acpBinary);
+ setAcpArgs(s.acpArgs);
+ setAcpIdleTTL(s.acpIdleTTL);
+ setAcpPermMode(s.acpPermMode);
+ setAcpWorkDir(s.acpWorkDir);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open]);
+
+ const [saving, setSaving] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ const handleSave = async () => {
+ setSaving(true);
+ setSaveError(null);
+ try {
+ const data: ProviderInput = {
+ name: provider.name,
+ provider_type: provider.provider_type,
+ };
+
+ if (isACP) {
+ data.api_base = acpBinary.trim() || undefined;
+ const settings: Record = {};
+ if (acpArgs.trim()) settings.args = acpArgs.trim().split(/\s+/);
+ if (acpIdleTTL.trim()) settings.idle_ttl = acpIdleTTL.trim();
+ if (acpPermMode) settings.perm_mode = acpPermMode;
+ if (acpWorkDir.trim()) settings.work_dir = acpWorkDir.trim();
+ if (Object.keys(settings).length > 0) data.settings = settings;
+ } else if (isStandard) {
+ data.api_base = apiBase.trim() || undefined;
+ }
+
+ await onUpdate(provider.id, data);
+ onOpenChange(false);
+ } catch (err) {
+ setSaveError(err instanceof Error ? err.message : t("form.saving"));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/ui/web/src/pages/providers/provider-detail/provider-detail-page.tsx b/ui/web/src/pages/providers/provider-detail/provider-detail-page.tsx
new file mode 100644
index 00000000..ead4ab36
--- /dev/null
+++ b/ui/web/src/pages/providers/provider-detail/provider-detail-page.tsx
@@ -0,0 +1,71 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useProviders } from "../hooks/use-providers";
+import { ProviderHeader } from "./provider-header";
+import { ProviderOverview } from "./provider-overview";
+import { ProviderAdvancedDialog } from "./provider-advanced-dialog";
+import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog";
+import { DetailPageSkeleton } from "@/components/shared/loading-skeleton";
+
+interface ProviderDetailPageProps {
+ providerId: string;
+ onBack: () => void;
+}
+
+export function ProviderDetailPage({ providerId, onBack }: ProviderDetailPageProps) {
+ const { t } = useTranslation("providers");
+ const { providers, loading, updateProvider, deleteProvider } = useProviders();
+ const [advancedOpen, setAdvancedOpen] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+
+ const provider = providers.find((p) => p.id === providerId);
+
+ if (loading || !provider) {
+ return ;
+ }
+
+ const displayTitle = provider.display_name || provider.name;
+
+ return (
+
+
setAdvancedOpen(true)}
+ onDelete={() => setDeleteOpen(true)}
+ />
+
+
+
+
+
+ {
+ await deleteProvider(providerId);
+ setDeleteOpen(false);
+ onBack();
+ }}
+ />
+
+ );
+}
diff --git a/ui/web/src/pages/providers/provider-detail/provider-header.tsx b/ui/web/src/pages/providers/provider-detail/provider-header.tsx
new file mode 100644
index 00000000..5e40646c
--- /dev/null
+++ b/ui/web/src/pages/providers/provider-detail/provider-header.tsx
@@ -0,0 +1,85 @@
+import { useTranslation } from "react-i18next";
+import { ArrowLeft, Cpu, Settings, Trash2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { PROVIDER_TYPES } from "@/constants/providers";
+import type { ProviderData } from "@/types/provider";
+
+interface ProviderHeaderProps {
+ provider: ProviderData;
+ onBack: () => void;
+ onAdvanced: () => void;
+ onDelete: () => void;
+}
+
+export function ProviderHeader({ provider, onBack, onAdvanced, onDelete }: ProviderHeaderProps) {
+ const { t } = useTranslation("providers");
+ const { t: tc } = useTranslation("common");
+ const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type);
+ const typeLabel = typeInfo?.label ?? provider.provider_type;
+ const displayTitle = provider.display_name || provider.name;
+
+ return (
+
+
+
+
+ {/* Provider icon */}
+
+
+
+
+ {/* Provider info */}
+
+
+
{displayTitle}
+
+
+
+
+
+ {provider.enabled ? tc("enabled") : tc("disabled")}
+
+
+
+ {typeLabel}
+
+
+
+ {provider.name}
+ ·
+ {typeLabel}
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx
new file mode 100644
index 00000000..94f04f15
--- /dev/null
+++ b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx
@@ -0,0 +1,151 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Copy } from "lucide-react";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { StickySaveBar } from "@/components/shared/sticky-save-bar";
+import { PROVIDER_TYPES } from "@/constants/providers";
+import { toast } from "@/stores/use-toast-store";
+import type { ProviderData, ProviderInput } from "@/types/provider";
+
+interface ProviderOverviewProps {
+ provider: ProviderData;
+ onUpdate: (id: string, data: ProviderInput) => Promise;
+}
+
+const NO_API_KEY_TYPES = new Set(["claude_cli", "acp", "chatgpt_oauth"]);
+
+export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) {
+ const { t } = useTranslation("providers");
+ const { t: tc } = useTranslation("common");
+
+ const typeInfo = PROVIDER_TYPES.find((pt) => pt.value === provider.provider_type);
+ const typeLabel = typeInfo?.label ?? provider.provider_type;
+ const showApiKey = !NO_API_KEY_TYPES.has(provider.provider_type);
+
+ // Identity
+ const [displayName, setDisplayName] = useState(provider.display_name || "");
+
+ // API Key
+ const [apiKey, setApiKey] = useState(provider.api_key || "");
+
+ // Status
+ const [enabled, setEnabled] = useState(provider.enabled);
+
+ // Save state
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [saveError, setSaveError] = useState(null);
+
+ const handleSave = async () => {
+ setSaving(true);
+ setSaveError(null);
+ setSaved(false);
+ try {
+ const data: ProviderInput = {
+ name: provider.name,
+ display_name: displayName.trim() || undefined,
+ provider_type: provider.provider_type,
+ enabled,
+ };
+ // Only include api_key if changed from the masked value
+ if (showApiKey && apiKey && apiKey !== "***") {
+ data.api_key = apiKey;
+ }
+ await onUpdate(provider.id, data);
+ setSaved(true);
+ setTimeout(() => setSaved(false), 3000);
+ } catch (err) {
+ setSaveError(err instanceof Error ? err.message : t("toast.failedUpdate"));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleCopyName = () => {
+ navigator.clipboard.writeText(provider.name).catch(() => {});
+ toast.success(tc("copy"));
+ };
+
+ return (
+
+ {/* Identity */}
+
+ {t("detail.identity")}
+
+
+
+ setDisplayName(e.target.value)}
+ placeholder={t("form.displayNamePlaceholder")}
+ className="text-base md:text-sm"
+ />
+
+
+
+
+
+ {typeLabel}
+
+
+
+
+
+
+
+ {provider.name}
+
+
+
+
+
+
+ {/* API Key */}
+ {showApiKey && (
+
+ {t("detail.apiKeySection")}
+
+
+
setApiKey(e.target.value)}
+ placeholder={t("form.apiKeyEditPlaceholder")}
+ className="text-base md:text-sm"
+ />
+
{t("form.apiKeySetHint")}
+
+
+ )}
+
+ {/* Status */}
+
+ {t("detail.statusSection")}
+
+
+
+
{t("detail.enabledDesc")}
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/web/src/pages/providers/provider-form-dialog.tsx b/ui/web/src/pages/providers/provider-form-dialog.tsx
index 32b917c6..0e83ac47 100644
--- a/ui/web/src/pages/providers/provider-form-dialog.tsx
+++ b/ui/web/src/pages/providers/provider-form-dialog.tsx
@@ -31,14 +31,12 @@ import { Loader2 } from "lucide-react";
interface ProviderFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
- provider: ProviderData | null; // null = create mode
onSubmit: (data: ProviderInput) => Promise;
existingProviders?: ProviderData[];
}
-export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, existingProviders = [] }: ProviderFormDialogProps) {
+export function ProviderFormDialog({ open, onOpenChange, onSubmit, existingProviders = [] }: ProviderFormDialogProps) {
const { t } = useTranslation("providers");
- const isEdit = !!provider;
const queryClient = useQueryClient();
const [name, setName] = useState("");
const [displayName, setDisplayName] = useState("");
@@ -56,8 +54,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
const [acpPermMode, setAcpPermMode] = useState("approve-all");
const [acpWorkDir, setAcpWorkDir] = useState("");
- // Only one Claude CLI provider allowed per instance
- const hasClaudeCLI = !isEdit && existingProviders.some((p) => p.provider_type === "claude_cli");
+ const hasClaudeCLI = existingProviders.some((p) => p.provider_type === "claude_cli");
const isOAuth = providerType === "chatgpt_oauth";
const isCLI = providerType === "claude_cli";
@@ -66,37 +63,19 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
useEffect(() => {
if (open) {
setError("");
- if (provider) {
- setName(provider.name);
- setDisplayName(provider.display_name || "");
- setProviderType(provider.provider_type);
- setApiBase(provider.api_base || "");
- setApiKey(provider.api_key || "");
- setEnabled(provider.enabled);
- // Load ACP settings from provider
- if (provider.provider_type === "acp") {
- const s = provider.settings as Record | undefined;
- setAcpBinary(provider.api_base || "");
- setAcpArgs(Array.isArray(s?.args) ? (s.args as string[]).join(" ") : "");
- setAcpIdleTTL((s?.idle_ttl as string) || "");
- setAcpPermMode((s?.perm_mode as string) || "approve-all");
- setAcpWorkDir((s?.work_dir as string) || "");
- }
- } else {
- setName("");
- setDisplayName("");
- setProviderType("openai_compat");
- setApiBase("");
- setApiKey("");
- setEnabled(true);
- setAcpBinary("");
- setAcpArgs("");
- setAcpIdleTTL("");
- setAcpPermMode("approve-all");
- setAcpWorkDir("");
- }
+ setName("");
+ setDisplayName("");
+ setProviderType("openai_compat");
+ setApiBase("");
+ setApiKey("");
+ setEnabled(true);
+ setAcpBinary("");
+ setAcpArgs("");
+ setAcpIdleTTL("");
+ setAcpPermMode("approve-all");
+ setAcpWorkDir("");
}
- }, [open, provider]);
+ }, [open]);
const handleSubmit = async () => {
if (!name.trim() || !providerType) return;
@@ -110,7 +89,6 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
enabled,
};
- // ACP: serialize fields into api_base (binary) + settings JSON
if (isACP) {
data.api_base = acpBinary.trim() || undefined;
const settings: Record = {};
@@ -125,7 +103,6 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
}
}
- // Only include api_key if it's a real value (not the mask)
if (apiKey && apiKey !== "***") {
data.api_key = apiKey;
}
@@ -141,44 +118,41 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
return (
- {isEdit && (
-
@@ -255,6 +209,7 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
value={apiBase}
onChange={(e) => setApiBase(e.target.value)}
placeholder={PROVIDER_TYPES.find((pt) => pt.value === providerType)?.placeholder || PROVIDER_TYPES.find((pt) => pt.value === providerType)?.apiBase || "https://api.example.com/v1"}
+ className="text-base md:text-sm"
/>
@@ -265,13 +220,9 @@ export function ProviderFormDialog({ open, onOpenChange, provider, onSubmit, exi
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
- placeholder={isEdit ? t("form.apiKeyEditPlaceholder") : t("form.apiKeyPlaceholder")}
+ placeholder={t("form.apiKeyPlaceholder")}
+ className="text-base md:text-sm"
/>
- {isEdit && apiKey === "***" && (
-