feat: add unified Settings sheet, MCP indicator, and Docker host config

- Add AppSettingsSheet (gear icon in Toolbar) with MCP, Docker, and AI sections
- MCP Server: toggle on/off, port config, status badge, endpoint URL with copy
- Docker: local/remote daemon selector with remote URL input
- AI: moved Ollama settings into the unified sheet
- MCP status probes actual TCP port for reliable running detection
- Docker commands respect configurable docker host (-H flag) for remote daemons
- MCP server supports graceful shutdown via tokio watch channel
- Settings persisted to app_settings.json alongside existing config files
- StatusBar shows MCP indicator (green/gray dot) with tooltip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 09:04:12 +03:00
parent 20b00e55b0
commit e76a96deb8
14 changed files with 800 additions and 42 deletions

View File

@@ -1,7 +1,9 @@
import { useAppStore } from "@/stores/app-store";
import { useConnections } from "@/hooks/use-connections";
import { useMcpStatus } from "@/hooks/use-settings";
import { Circle } from "lucide-react";
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
function formatDbVersion(version: string): string {
const gpMatch = version.match(/Greenplum Database ([\d.]+)/i);
@@ -21,6 +23,7 @@ interface Props {
export function StatusBar({ rowCount, executionTime }: Props) {
const { activeConnectionId, connectedIds, readOnlyMap, pgVersion } = useAppStore();
const { data: connections } = useConnections();
const { data: mcpStatus } = useMcpStatus();
const activeConn = connections?.find((c) => c.id === activeConnectionId);
const isConnected = activeConnectionId
@@ -62,6 +65,25 @@ export function StatusBar({ rowCount, executionTime }: Props) {
<div className="flex items-center gap-3">
{rowCount != null && <span>{rowCount.toLocaleString()} rows</span>}
{executionTime != null && <span>{executionTime} ms</span>}
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-default">
<span
className={`inline-block h-1.5 w-1.5 rounded-full ${
mcpStatus?.running
? "bg-green-500"
: "bg-muted-foreground/30"
}`}
/>
<span>MCP</span>
</span>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">
MCP Server {mcpStatus?.running ? `running on :${mcpStatus.port}` : "stopped"}
</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);

View File

@@ -8,13 +8,15 @@ import { ReadOnlyToggle } from "@/components/layout/ReadOnlyToggle";
import { useAppStore } from "@/stores/app-store";
import { useConnections, useReconnect } from "@/hooks/use-connections";
import { toast } from "sonner";
import { Database, Plus, RefreshCw, Search } from "lucide-react";
import { Database, Plus, RefreshCw, Search, Settings } from "lucide-react";
import type { ConnectionConfig, Tab } from "@/types";
import { getEnvironment } from "@/lib/environment";
import { AppSettingsSheet } from "@/components/settings/AppSettingsSheet";
export function Toolbar() {
const [listOpen, setListOpen] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [editingConn, setEditingConn] = useState<ConnectionConfig | null>(null);
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
const { data: connections } = useConnections();
@@ -116,6 +118,16 @@ export function Toolbar() {
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={() => setSettingsOpen(true)}
title="Settings"
>
<Settings className="h-3.5 w-3.5" />
</Button>
</div>
<ConnectionList
@@ -136,6 +148,11 @@ export function Toolbar() {
onOpenChange={setDialogOpen}
connection={editingConn}
/>
<AppSettingsSheet
open={settingsOpen}
onOpenChange={setSettingsOpen}
/>
</>
);
}

View File

@@ -0,0 +1,299 @@
import { useState, useEffect } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetFooter,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { useAppSettings, useSaveAppSettings, useMcpStatus } from "@/hooks/use-settings";
import { useAiSettings, useSaveAiSettings, useOllamaModels } from "@/hooks/use-ai";
import { RefreshCw, Loader2, Copy, Check } from "lucide-react";
import { toast } from "sonner";
import type { AppSettings, DockerHost } from "@/types";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function AppSettingsSheet({ open, onOpenChange }: Props) {
const { data: appSettings } = useAppSettings();
const { data: mcpStatus } = useMcpStatus();
const saveAppMutation = useSaveAppSettings();
const { data: aiSettings } = useAiSettings();
const saveAiMutation = useSaveAiSettings();
// MCP state
const [mcpEnabled, setMcpEnabled] = useState(true);
const [mcpPort, setMcpPort] = useState(9427);
// Docker state
const [dockerHost, setDockerHost] = useState<DockerHost>("local");
const [dockerRemoteUrl, setDockerRemoteUrl] = useState("");
// AI state
const [ollamaUrl, setOllamaUrl] = useState("http://localhost:11434");
const [aiModel, setAiModel] = useState("");
const [copied, setCopied] = useState(false);
// Sync form with loaded settings
useEffect(() => {
if (appSettings) {
setMcpEnabled(appSettings.mcp.enabled);
setMcpPort(appSettings.mcp.port);
setDockerHost(appSettings.docker.host);
setDockerRemoteUrl(appSettings.docker.remote_url ?? "");
}
}, [appSettings]);
useEffect(() => {
if (aiSettings) {
setOllamaUrl(aiSettings.ollama_url);
setAiModel(aiSettings.model);
}
}, [aiSettings]);
const {
data: models,
isLoading: modelsLoading,
isError: modelsError,
refetch: refetchModels,
} = useOllamaModels(ollamaUrl);
const mcpEndpoint = `http://127.0.0.1:${mcpPort}/mcp`;
const handleCopy = async () => {
await navigator.clipboard.writeText(mcpEndpoint);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleSave = () => {
const settings: AppSettings = {
mcp: { enabled: mcpEnabled, port: mcpPort },
docker: {
host: dockerHost,
remote_url: dockerHost === "remote" ? dockerRemoteUrl || undefined : undefined,
},
};
saveAppMutation.mutate(settings, {
onSuccess: () => {
toast.success("Settings saved");
},
onError: (err) =>
toast.error("Failed to save settings", { description: String(err) }),
});
// Save AI settings separately
saveAiMutation.mutate(
{ provider: "ollama", ollama_url: ollamaUrl, model: aiModel },
{
onError: (err) =>
toast.error("Failed to save AI settings", { description: String(err) }),
}
);
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-[400px] sm:max-w-[400px] overflow-y-auto">
<SheetHeader>
<SheetTitle>Settings</SheetTitle>
<SheetDescription>Application configuration</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-6 px-4">
{/* MCP Server */}
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">MCP Server</h3>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Enabled</span>
<Button
size="sm"
variant={mcpEnabled ? "default" : "outline"}
className="h-6 text-xs px-3"
onClick={() => setMcpEnabled(!mcpEnabled)}
>
{mcpEnabled ? "On" : "Off"}
</Button>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Port</label>
<Input
type="number"
value={mcpPort}
onChange={(e) => setMcpPort(Number(e.target.value))}
className="h-8 text-xs"
min={1}
max={65535}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Status:</span>
<span className="flex items-center gap-1.5 text-xs">
<span
className={`inline-block h-2 w-2 rounded-full ${
mcpStatus?.running
? "bg-green-500"
: "bg-muted-foreground/30"
}`}
/>
{mcpStatus?.running ? "Running" : "Stopped"}
</span>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Endpoint</label>
<div className="flex items-center gap-1">
<code className="flex-1 rounded bg-muted px-2 py-1 text-xs font-mono truncate">
{mcpEndpoint}
</code>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={handleCopy}
title="Copy endpoint URL"
>
{copied ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</Button>
</div>
</div>
</section>
<Separator />
{/* Docker */}
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Docker</h3>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Docker Host</label>
<Select value={dockerHost} onValueChange={(v) => setDockerHost(v as DockerHost)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="local">Local</SelectItem>
<SelectItem value="remote">Remote</SelectItem>
</SelectContent>
</Select>
</div>
{dockerHost === "remote" && (
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Remote URL</label>
<Input
value={dockerRemoteUrl}
onChange={(e) => setDockerRemoteUrl(e.target.value)}
placeholder="tcp://192.168.1.100:2375"
className="h-8 text-xs"
/>
</div>
)}
</section>
<Separator />
{/* AI */}
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">AI</h3>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Provider</label>
<Select value="ollama" disabled>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ollama">Ollama</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs text-muted-foreground">Ollama URL</label>
<Input
value={ollamaUrl}
onChange={(e) => setOllamaUrl(e.target.value)}
placeholder="http://localhost:11434"
className="h-8 text-xs"
/>
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
<label className="text-xs text-muted-foreground">Model</label>
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0"
onClick={() => refetchModels()}
disabled={modelsLoading}
title="Refresh models"
>
{modelsLoading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
</div>
{modelsError ? (
<p className="text-xs text-destructive">Cannot connect to Ollama</p>
) : (
<Select value={aiModel} onValueChange={setAiModel}>
<SelectTrigger className="h-8 w-full text-xs">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{models?.map((m) => (
<SelectItem key={m.name} value={m.name}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</section>
</div>
<SheetFooter>
<Button
className="w-full"
onClick={handleSave}
disabled={saveAppMutation.isPending}
>
{saveAppMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin mr-2" />
) : null}
Save
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

30
src/hooks/use-settings.ts Normal file
View File

@@ -0,0 +1,30 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getAppSettings, saveAppSettings, getMcpStatus } from "@/lib/tauri";
import type { AppSettings } from "@/types";
export function useAppSettings() {
return useQuery({
queryKey: ["app-settings"],
queryFn: getAppSettings,
staleTime: Infinity,
});
}
export function useSaveAppSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (settings: AppSettings) => saveAppSettings(settings),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["app-settings"] });
queryClient.invalidateQueries({ queryKey: ["mcp-status"] });
},
});
}
export function useMcpStatus() {
return useQuery({
queryKey: ["mcp-status"],
queryFn: getMcpStatus,
refetchInterval: 5000,
});
}

View File

@@ -33,6 +33,8 @@ import type {
CloneProgress,
CloneResult,
TuskContainer,
AppSettings,
McpStatus,
} from "@/types";
// Connections
@@ -320,3 +322,13 @@ export const onCloneProgress = (
callback: (p: CloneProgress) => void
): Promise<UnlistenFn> =>
listen<CloneProgress>("clone-progress", (e) => callback(e.payload));
// App Settings
export const getAppSettings = () =>
invoke<AppSettings>("get_app_settings");
export const saveAppSettings = (settings: AppSettings) =>
invoke<void>("save_app_settings", { settings });
export const getMcpStatus = () =>
invoke<McpStatus>("get_mcp_status");

View File

@@ -323,6 +323,30 @@ export interface ErdData {
relationships: ErdRelationship[];
}
// App Settings
export type DockerHost = "local" | "remote";
export interface McpSettings {
enabled: boolean;
port: number;
}
export interface DockerSettings {
host: DockerHost;
remote_url?: string;
}
export interface AppSettings {
mcp: McpSettings;
docker: DockerSettings;
}
export interface McpStatus {
enabled: boolean;
port: number;
running: boolean;
}
// Docker
export interface DockerStatus {
installed: boolean;