diff --git a/web-ui/src/app/settings/_components/drift-badge.tsx b/web-ui/src/app/settings/_components/drift-badge.tsx new file mode 100644 index 0000000..90af4bc --- /dev/null +++ b/web-ui/src/app/settings/_components/drift-badge.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { AlertTriangle, CheckCircle2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +export function DriftBadge({ drift }: { drift: boolean }) { + if (drift) { + return ( + + + Drift + + ); + } + return ( + + + Synced + + ); +} diff --git a/web-ui/src/app/settings/_components/env-var-editor.tsx b/web-ui/src/app/settings/_components/env-var-editor.tsx new file mode 100644 index 0000000..3353d3b --- /dev/null +++ b/web-ui/src/app/settings/_components/env-var-editor.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { McpEnvVar } from "@/lib/api/settings"; + +type Props = { + spec: McpEnvVar; + value: string; + onChange: (v: string) => void; + disabled?: boolean; +}; + +export function EnvVarEditor({ spec, value, onChange, disabled }: Props) { + if (spec.type === "bool") { + const checked = value === "true"; + return ( + onChange(c ? "true" : "false")} + disabled={disabled} + /> + ); + } + + if (spec.enum_values && spec.enum_values.length > 0) { + return ( + + + + + + {spec.enum_values.map((v) => ( + + {v} + + ))} + + + ); + } + + if (spec.type === "int" || spec.type === "float") { + return ( + onChange(e.target.value)} + min={spec.min ?? undefined} + max={spec.max ?? undefined} + step={spec.type === "float" ? "0.01" : "1"} + disabled={disabled} + className="w-[160px] text-start" + dir="ltr" + /> + ); + } + + return ( + onChange(e.target.value)} + disabled={disabled} + className="w-[260px] text-start" + dir="ltr" + /> + ); +} diff --git a/web-ui/src/app/settings/_components/env-var-row.tsx b/web-ui/src/app/settings/_components/env-var-row.tsx new file mode 100644 index 0000000..454a82a --- /dev/null +++ b/web-ui/src/app/settings/_components/env-var-row.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useState } from "react"; +import { ExternalLink, Save, Lock } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import type { McpEnvVar } from "@/lib/api/settings"; +import { useUpdateMcpEnv } from "@/lib/api/settings"; +import { toast } from "sonner"; +import { DriftBadge } from "./drift-badge"; +import { EnvVarEditor } from "./env-var-editor"; + +type Props = { + spec: McpEnvVar; + infisicalProjectId: string; + infisicalEnv: string; + onPendingRedeploy: () => void; +}; + +export function EnvVarRow({ + spec, + infisicalProjectId, + infisicalEnv, + onPendingRedeploy, +}: Props) { + const [draft, setDraft] = useState(spec.infisical_value ?? ""); + const update = useUpdateMcpEnv(); + const dirty = draft !== (spec.infisical_value ?? ""); + + function handleSave() { + update.mutate( + { key: spec.key, value: draft }, + { + onSuccess: (res) => { + toast.success(res.message); + onPendingRedeploy(); + }, + onError: (err) => toast.error(`שגיאה: ${err.message}`), + }, + ); + } + + const infisicalUrl = + `https://secret.dev.marcus-law.co.il/project/${infisicalProjectId}/secrets/overview?env=${infisicalEnv}`; + + return ( + + + + + + {spec.key} + + + {spec.type} + + {spec.is_secret && ( + + + secret + + )} + + + {spec.description} + + + + + + Infisical: + {spec.is_editable ? ( + + ) : ( + + {spec.infisical_value ?? — לא מוגדר —} + + )} + + + Container: + + {spec.container_value ?? — לא מוגדר —} + + + + + + {!spec.is_editable && ( + + ערוך ב-Infisical + + + )} + {spec.is_editable && ( + + + {update.isPending ? "שומר..." : "שמור"} + + )} + + + ); +} diff --git a/web-ui/src/app/settings/_components/environment-tab.tsx b/web-ui/src/app/settings/_components/environment-tab.tsx index d56b649..3a1c758 100644 --- a/web-ui/src/app/settings/_components/environment-tab.tsx +++ b/web-ui/src/app/settings/_components/environment-tab.tsx @@ -1 +1,135 @@ -export function EnvironmentTab() { return Environment tab — coming soon; } +"use client"; + +import { useState, useMemo } from "react"; +import { RefreshCw, AlertCircle } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Badge } from "@/components/ui/badge"; +import { + useMcpEnv, + useMcpRedeploy, + type McpEnvVar, + type EnvCategory, +} from "@/lib/api/settings"; +import { toast } from "sonner"; +import { EnvVarRow } from "./env-var-row"; + +const CATEGORY_LABELS: Record = { + multimodal: "Multimodal", + rerank: "Rerank", + halacha: "Halacha", + general: "כללי", + credentials: "אישורים", + connection: "חיבורים", +}; + +const CATEGORY_ORDER: EnvCategory[] = [ + "multimodal", "rerank", "halacha", "general", "credentials", "connection", +]; + +export function EnvironmentTab() { + const { data, isPending, error } = useMcpEnv(); + const redeploy = useMcpRedeploy(); + const [pendingRedeploy, setPendingRedeploy] = useState(false); + + const grouped = useMemo(() => { + if (!data?.vars) return new Map(); + const m = new Map(); + for (const v of data.vars) { + const arr = m.get(v.category) ?? []; + arr.push(v); + m.set(v.category, arr); + } + return m; + }, [data]); + + function handleRedeploy() { + redeploy.mutate(undefined, { + onSuccess: (res) => { + toast.success(res.message); + setPendingRedeploy(false); + }, + onError: (err) => toast.error(`Redeploy נכשל: ${err.message}`), + }); + } + + if (isPending) return ; + if (error) { + return ( + + + + שגיאה בטעינת env vars: {error.message} + + + ); + } + if (!data) return null; + + const driftCount = data.vars.filter((v) => v.drift).length; + + return ( + + + + + + Infisical: {data.infisical_environment} + + + Path: {data.infisical_path} + + {driftCount > 0 && ( + + {driftCount} drift + + )} + {data.errors.length > 0 && ( + + {data.errors.join(", ")} + + )} + + + + {redeploy.isPending ? "Redeploying..." : "Redeploy now"} + + + + + {CATEGORY_ORDER.map((cat) => { + const vars = grouped.get(cat); + if (!vars || vars.length === 0) return null; + return ( + + + + {CATEGORY_LABELS[cat]} + + {vars.length} + + + + {vars.map((v) => ( + setPendingRedeploy(true)} + /> + ))} + + + + ); + })} + + ); +} diff --git a/web-ui/src/components/ui/switch.tsx b/web-ui/src/components/ui/switch.tsx new file mode 100644 index 0000000..60d1404 --- /dev/null +++ b/web-ui/src/components/ui/switch.tsx @@ -0,0 +1,33 @@ +"use client" + +import * as React from "react" +import { Switch as SwitchPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch }
+ {spec.key} +
{spec.description}
{data.infisical_environment}
{data.infisical_path}