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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
299
src/components/settings/AppSettingsSheet.tsx
Normal file
299
src/components/settings/AppSettingsSheet.tsx
Normal 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
30
src/hooks/use-settings.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user