-
-
+ {/* ── Swarm Status Banner ───────────────────────────────────────────── */}
+
+
+ {/* ── Swarm overview cards ──────────────────────────────────────────── */}
+ {swarmOk && (
+
+ {[
+ { label: "Total Nodes", value: swarmInfo.nodes, icon: Server, color: "text-cyan-400" },
+ { label: "Managers", value: swarmInfo.managers, icon: Shield, color: "text-purple-400" },
+ { label: "Services", value: services.length, icon: Layers, color: "text-green-400" },
+ { label: "Running Tasks", value: runningTasks, icon: Activity, color: "text-yellow-400" },
+ ].map((stat) => (
+
+
+
+
+
+
{stat.label}
+
{stat.value}
-
- {[1, 2, 3].map((j) => (
-
- ))}
-
))}
)}
- {/* Node cards */}
- {nodes.length > 0 && (
-
- {nodes.map((node, i) => {
- // Find containers for this node (standalone mode: all containers)
- const nodeContainers = swarmActive ? [] : containers;
+ {/* ── Join tokens ───────────────────────────────────────────────────── */}
+
+
+
+
- // Build per-container stats
- const enrichedContainers = (nodeContainers as Array<{ id: string; name: string; image: string; state: string; status: string }>).map((c) => {
- const s = statsMap.get(c.name) ?? statsMap.get(c.id);
- return { ...c, cpuPct: s?.cpuPct ?? 0, memUseMB: s?.memUseMB ?? 0 };
- });
+ {/* ── Tabs ──────────────────────────────────────────────────────────── */}
+
+ {tabs.map((tab) => (
+
+ ))}
+
- // Aggregate CPU/RAM for this node from container stats
- const totalCPUPct = enrichedContainers.reduce((a: number, c: { cpuPct: number }) => a + c.cpuPct, 0);
- const totalMemUseMB = enrichedContainers.reduce((a: number, c: { memUseMB: number }) => a + c.memUseMB, 0);
- const memPct = node.memTotalMB > 0 ? (totalMemUseMB / node.memTotalMB) * 100 : 0;
-
- return (
-
+ {/* NODES tab */}
+ {activeTab === "nodes" && (
+
+ {/* Add node toolbar */}
+
+
- )}
-
- {/* Empty state — no nodes, no error, not loading */}
- {!nodesLoading && !isError && nodes.length === 0 && (
-
-
-
- No nodes found
-
- Make sure Docker socket is mounted in the Gateway container
-
-
-
- )}
-
- {/* Standalone containers section (when not in Swarm mode) */}
- {!swarmActive && statsData && statsData.stats.length > 0 && nodes.length === 0 && (
-
-
- RUNNING CONTAINERS
-
-
- {statsData.stats.map((s) => (
-
-
-
-
- CPU: {s.cpuPct.toFixed(1)}%
-
-
- MEM:{" "}
-
- {formatMB(s.memUseMB)} / {formatMB(s.memLimMB)}
-
-
- ({s.memPct.toFixed(0)}%)
-
-
-
+
Add Node via SSH
+
+
+ {nodesQ.isLoading ? (
+
+ Loading nodes…
- ))}
-
-
+ ) : nodes.length === 0 ? (
+
+
+
No nodes found
+
+ {swarmInfoQ.isError ? "Cannot connect to Swarm — check gateway logs" : "Swarm has no registered nodes yet"}
+
+
+
+ ) : (
+
+ {nodes.map((node: NodeInfo) => (
+ nodesQ.refetch()} />
+ ))}
+
+ )}
+
+ )}
+
+ {/* SERVICES tab */}
+ {activeTab === "services" && (
+
+
+
+
+ {servicesQ.isLoading ? (
+
+ Loading services…
+
+ ) : services.length === 0 ? (
+
+
+
No swarm services running
+
+
+ ) : (
+
+
+
+
+
+
+ | Service |
+ Mode |
+ Replicas |
+ Ports |
+ Actions |
+
+
+
+ {services.map((svc: ServiceInfo) => (
+ setTasksTarget({ id, name })}
+ onRemove={(id, name) => setRemoveTarget({ id, name })}
+ />
+ ))}
+
+
+
+
+
+ )}
+
+ )}
+
+ {/* AGENTS tab */}
+ {activeTab === "agents" && (
+
+
+
+
+ Auto-stop after 15 min idle (managed by SwarmManager)
+
+
+
+
+ {agentsQ.isLoading ? (
+
+ Loading agents…
+
+ ) : agents.length === 0 ? (
+
+
+
No agent services deployed
+
+ Deploy agents as Swarm services to run them across nodes
+
+
+
+ ) : (
+
+ {/* Legend */}
+
+ active
+ idle 5-10m
+ idle 10m+ → stopping soon
+
+ {agents.map((agent: AgentServiceInfo) => (
+
{ agentsQ.refetch(); servicesQ.refetch(); }}
+ />
+ ))}
+
+ )}
+
+ )}
+
+ {/* SHELL tab */}
+ {activeTab === "shell" && (
+
+
+
+ )}
+
+
+ {/* ── Dialogs / drawers ─────────────────────────────────────────────── */}
+ {scaleTarget && (
+ setScaleTarget(null)}
+ onConfirm={confirmScale}
+ />
+ )}
+ {removeTarget && (
+ setRemoveTarget(null)}
+ onConfirm={confirmRemove}
+ isPending={removeMutation.isPending}
+ />
+ )}
+ {tasksTarget && (
+ setTasksTarget(null)}
+ />
+ )}
+ {showDeploy && (
+ setShowDeploy(false)}
+ onSuccess={() => { servicesQ.refetch(); agentsQ.refetch(); }}
+ />
)}
);
diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx
index 7d5def7..94ba4a2 100644
--- a/client/src/pages/Settings.tsx
+++ b/client/src/pages/Settings.tsx
@@ -1,6 +1,8 @@
/*
- * Settings — API Keys, Model Providers, Gateway Configuration
- * Теперь с реальным подключением к Ollama API через tRPC
+ * Settings — LLM Provider management (DB-backed), Gateway config, Security
+ *
+ * Providers are stored in the `llmProviders` MySQL table.
+ * API keys are encrypted with AES-256-GCM by the server; only hints shown here.
*/
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
@@ -10,6 +12,13 @@ import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
import {
Key,
Globe,
@@ -26,12 +35,18 @@ import {
Shield,
Loader2,
Zap,
+ Trash2,
+ Edit2,
+ Star,
+ StarOff,
} from "lucide-react";
-import { motion } from "framer-motion";
-import { useState, useEffect } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import { useState } from "react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
+// ─── Status helpers ────────────────────────────────────────────────────────────
+
function getStatusIcon(status: string) {
switch (status) {
case "connected": return
;
@@ -48,33 +63,396 @@ function getStatusBadge(status: string) {
}
}
-export default function Settings() {
- const [showKeys, setShowKeys] = useState
>({});
+// ─── Provider Form Modal ───────────────────────────────────────────────────────
- // Реальные данные из Ollama API
- const healthQuery = trpc.ollama.health.useQuery(undefined, {
- refetchInterval: 30_000, // Обновлять каждые 30 секунд
- });
- const modelsQuery = trpc.ollama.models.useQuery(undefined, {
- refetchInterval: 60_000, // Обновлять каждые 60 секунд
- });
+interface ProviderFormData {
+ name: string;
+ baseUrl: string;
+ apiKey: string;
+ modelDefault: string;
+ notes: string;
+ setActive: boolean;
+}
+
+const EMPTY_FORM: ProviderFormData = {
+ name: "",
+ baseUrl: "https://ollama.com/v1",
+ apiKey: "",
+ modelDefault: "",
+ notes: "",
+ setActive: false,
+};
+
+interface ProviderModalProps {
+ open: boolean;
+ editId?: number;
+ initial?: Partial;
+ onClose: () => void;
+ onSaved: () => void;
+}
+
+function ProviderModal({ open, editId, initial, onClose, onSaved }: ProviderModalProps) {
+ const [form, setForm] = useState({ ...EMPTY_FORM, ...initial });
+ const [showKey, setShowKey] = useState(false);
+ const isEdit = editId !== undefined;
+
+ const createMutation = trpc.providers.create.useMutation();
+ const updateMutation = trpc.providers.update.useMutation();
+
+ const handleSave = async () => {
+ try {
+ if (isEdit) {
+ await updateMutation.mutateAsync({
+ id: editId!,
+ name: form.name,
+ baseUrl: form.baseUrl,
+ apiKey: form.apiKey || undefined,
+ modelDefault: form.modelDefault || undefined,
+ notes: form.notes || undefined,
+ });
+ toast.success("Provider updated");
+ } else {
+ await createMutation.mutateAsync({
+ name: form.name,
+ baseUrl: form.baseUrl,
+ apiKey: form.apiKey,
+ modelDefault: form.modelDefault || undefined,
+ notes: form.notes || undefined,
+ setActive: form.setActive,
+ });
+ toast.success("Provider created");
+ }
+ onSaved();
+ onClose();
+ } catch (err: any) {
+ toast.error(`Failed: ${err.message}`);
+ }
+ };
+
+ const busy = createMutation.isPending || updateMutation.isPending;
+
+ return (
+
+ );
+}
+
+// ─── Provider Card ─────────────────────────────────────────────────────────────
+
+interface ProviderCardProps {
+ p: {
+ id: number;
+ name: string;
+ baseUrl: string;
+ apiKeyHint: string;
+ isActive: boolean;
+ isDefault: boolean;
+ modelDefault: string | null;
+ notes: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+ };
+ ollamaStatus: string;
+ ollamaLatency: number;
+ healthLoading: boolean;
+ onEdit: () => void;
+ onDelete: () => void;
+ onActivate: () => void;
+ onTest: () => void;
+}
+
+function ProviderCard({
+ p, ollamaStatus, ollamaLatency, healthLoading,
+ onEdit, onDelete, onActivate, onTest,
+}: ProviderCardProps) {
+ const [showKey, setShowKey] = useState(false);
+
+ return (
+
+
+
+
+
+ {p.isActive ? (
+ healthLoading
+ ?
+ : getStatusIcon(ollamaStatus)
+ ) : (
+
+ )}
+
+
+ {p.name}
+ {p.isActive && (
+
+ ACTIVE
+
+ )}
+ {p.modelDefault && (
+
+ {p.modelDefault}
+
+ )}
+
+
+ {p.baseUrl}
+
+
+
+
+
+ {p.isActive && ollamaLatency > 0 && (
+
+
+ {ollamaLatency}ms
+
+ )}
+ {p.isActive && (
+
+ {ollamaStatus.toUpperCase()}
+
+ )}
+
+
+
+
+
+ {/* API Key hint */}
+
+
+
+
+
+
+
+ {!p.apiKeyHint && (
+
+ NO KEY
+
+ )}
+
+
+
+ {p.notes && (
+ {p.notes}
+ )}
+
+ {/* Actions */}
+
+ {p.isActive ? (
+
+ ) : (
+
+ )}
+
+ {!p.isActive && (
+
+ )}
+
+
+
+
+ );
+}
+
+// ─── Main Settings Component ───────────────────────────────────────────────────
+
+export default function Settings() {
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editProvider, setEditProvider] = useState<{ id: number; form: Partial } | null>(null);
+
+ // Queries
+ const healthQuery = trpc.ollama.health.useQuery(undefined, { refetchInterval: 30_000 });
+ const modelsQuery = trpc.ollama.models.useQuery(undefined, { refetchInterval: 60_000 });
+ const providersQuery = trpc.providers.list.useQuery(undefined, { staleTime: 5_000 });
+
+ // Mutations
+ const deleteMutation = trpc.providers.delete.useMutation();
+ const activateMutation = trpc.providers.activate.useMutation();
+ const utils = trpc.useUtils();
+
+ const refetchAll = () => {
+ utils.providers.list.invalidate();
+ utils.config.providers.invalidate();
+ healthQuery.refetch();
+ };
const ollamaStatus = healthQuery.data?.connected ? "connected" : healthQuery.isLoading ? "unchecked" : "error";
const ollamaLatency = healthQuery.data?.latencyMs ?? 0;
const ollamaModels = modelsQuery.data?.success ? modelsQuery.data.models : [];
+ const providers = providersQuery.data ?? [];
- const toggleKeyVisibility = (id: string) => {
- setShowKeys((prev) => ({ ...prev, [id]: !prev[id] }));
+ const handleDelete = async (id: number) => {
+ const res = await deleteMutation.mutateAsync({ id });
+ if (res.ok) {
+ toast.success("Provider deleted");
+ refetchAll();
+ } else {
+ toast.error(res.error ?? "Failed to delete");
+ }
};
- const scanModels = () => {
- modelsQuery.refetch();
- toast.success("Сканирование моделей запущено...");
+ const handleActivate = async (id: number) => {
+ await activateMutation.mutateAsync({ id });
+ toast.success("Provider activated — Gateway reloaded");
+ refetchAll();
};
- const testConnection = () => {
+ const handleTest = () => {
healthQuery.refetch();
- toast.info("Тестирование подключения к Ollama...");
+ toast.info("Testing connection...");
+ };
+
+ const openEdit = (p: typeof providers[0]) => {
+ setEditProvider({
+ id: p.id,
+ form: { name: p.name, baseUrl: p.baseUrl, modelDefault: p.modelDefault ?? "", notes: p.notes ?? "" },
+ });
+ setModalOpen(true);
+ };
+
+ const openCreate = () => {
+ setEditProvider(null);
+ setModalOpen(true);
};
return (
@@ -82,10 +460,19 @@ export default function Settings() {
Настройки
- Конфигурация Gateway, API-ключи и провайдеры моделей
+ Конфигурация Gateway, LLM-провайдеры и API-ключи
+ {/* Provider Add/Edit Modal */}
+ setModalOpen(false)}
+ onSaved={refetchAll}
+ />
+
@@ -106,200 +493,99 @@ export default function Settings() {
- Управление провайдерами LLM-моделей. Поддерживаются OpenAI-совместимые API.
+ LLM-провайдеры хранятся в БД. API-ключи зашифрованы AES-256-GCM.
- {/* === OLLAMA PROVIDER (REAL DATA) === */}
-
-
-
-
-
- {healthQuery.isLoading ? (
-
- ) : (
- getStatusIcon(ollamaStatus)
- )}
-
-
- Ollama Cloud
-
- LIVE
-
-
- OPENAI-COMPATIBLE
-
-
-
- {ollamaLatency > 0 && (
-
-
- {ollamaLatency}ms
-
- )}
-
- {ollamaStatus.toUpperCase()}
-
-
-
-
-
-
- {/* Base URL */}
-
-
-
-
-
-
-
-
- {/* API Key */}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Models — REAL DATA */}
-
-
-
-
-
- {modelsQuery.isLoading ? (
-
-
- Загрузка моделей...
-
- ) : ollamaModels.length > 0 ? (
-
- {ollamaModels.map((model) => (
-
- {model.id}
-
- ))}
-
- ) : (
-
- {modelsQuery.data && !modelsQuery.data.success
- ? `Ошибка: ${(modelsQuery.data as any).error}`
- : "Нет доступных моделей"}
-
- )}
-
-
- {/* Health Error */}
- {healthQuery.data && !healthQuery.data.connected && healthQuery.data.error && (
-
-
-
- {healthQuery.data.error}
-
-
- )}
-
-
-
-
- {/* Placeholder for other providers */}
-
-
-
-
-
-
-
- OpenAI
- NOT CONFIGURED
-
-
-
- UNCHECKED
-
-
-
-
-
- Нажмите «Добавить провайдер» для настройки OpenAI, Anthropic или другого OpenAI-совместимого API.
+ {/* Provider list */}
+ {providersQuery.isLoading ? (
+
+
+ Загрузка провайдеров...
+
+ ) : providers.length === 0 ? (
+
+
+
+ Нет настроенных провайдеров.{" "}
+ {" "}
+ или перезапустите контейнеры — провайдер из env будет добавлен автоматически.
-
+ ) : (
+
+
+ {providers.map((p) => (
+
openEdit(p)}
+ onDelete={() => handleDelete(p.id)}
+ onActivate={() => handleActivate(p.id)}
+ onTest={handleTest}
+ />
+ ))}
+
+
+ )}
+
+ {/* Models section (always visible for the active provider) */}
+ {providers.some((p) => p.isActive) && (
+
+
+
+
+
+ Доступные модели ({ollamaModels.length})
+
+
+
+
+
+ {modelsQuery.isLoading ? (
+
+
+ Загрузка...
+
+ ) : ollamaModels.length > 0 ? (
+
+ {ollamaModels.map((m) => (
+
+ {m.id}
+
+ ))}
+
+ ) : (
+ Нет доступных моделей
+ )}
+
+
+ )}
{/* Gateway Tab */}
@@ -313,46 +599,33 @@ export default function Settings() {
-
-
-
Auto-scaling
-
Автоматическое масштабирование агентов при нагрузке
+ {[
+ ["Auto-scaling", "Автоматическое масштабирование агентов при нагрузке", true],
+ ["Health Checks", "Периодическая проверка состояния агентов", true],
+ ["Debug Logging", "Расширенное логирование для отладки", false],
+ ].map(([label, desc, defaultChecked]) => (
+
+
+
{label as string}
+
{desc as string}
+
+
-
-
-
-
-
Health Checks
-
Периодическая проверка состояния агентов
-
-
-
-
-
-
Debug Logging
-
Расширенное логирование для отладки
-
-
-
+ ))}
@@ -364,11 +637,10 @@ export default function Settings() {
- Внешние подключения (Connectors)
+ Внешние подключения
- {/* Telegram */}
-
- {/* Placeholder for more connectors */}