feat(mcp): P0 只读 MCP tool runtime + opsxdb-mcp stdio
- aitools 共用 runtime:list/describe/schema/query/redis(纯只读,无写确认) - 白名单 ~/.opsxdb/mcp.json;审计 mcp-audit.jsonl;复用 danger.go - 桌面 connections 双写 ~/.opsxdb/connections.json(ox1. 解码) - Cursor 配置示例与任务书验收勾选更新;写操作与 Agent UI 留 P1
This commit is contained in:
1 parent
5c17d32647
commit
0b68c8344d
19 files changed
+1989
-79
No files matched your search
+87
-14
@@ -3,7 +3,8 @@ import type { DriverId } from "./catalog";
|
||||
import { driverById } from "./catalog";
|
||||
import { formatDbError, notifyId, type NotifyKind, type NotifyPayload } from "./notify";
|
||||
import { decodeSecret, encodeSecret } from "./secrets";
|
||||
import { dropSchemaCache } from "./api";
|
||||
import { dropSchemaCache, syncConnectionsToDisk } from "./api";
|
||||
import { normalizeRedisDatabase } from "./redisContext";
|
||||
|
||||
export type SavedConnection = {
|
||||
id: string;
|
||||
@@ -115,6 +116,8 @@ export type ResultSetItem = {
|
||||
label: string;
|
||||
sql: string;
|
||||
result: QueryResult;
|
||||
/** 仅对象预览填入,避免复制 INSERT 时误用其他标签的全局元数据。 */
|
||||
source?: { connectionId: string; driver: string; database: string; table: string };
|
||||
/** C5:分页状态(仅单条 SELECT 启用);page 从 0 起 */
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
@@ -123,6 +126,8 @@ export type ResultSetItem = {
|
||||
/** 列宽/选中态等视图持久化(修 G3,resultSet 级) */
|
||||
viewState?: {
|
||||
colWidths?: Record<string, number>;
|
||||
sortCol?: string | null;
|
||||
sortDir?: "asc" | "desc" | null;
|
||||
selectedIdx?: number | null;
|
||||
selectedCol?: string | null;
|
||||
};
|
||||
@@ -142,6 +147,7 @@ export type ColumnInfo = {
|
||||
key?: string;
|
||||
default?: unknown;
|
||||
comment?: string;
|
||||
extra?: string;
|
||||
};
|
||||
|
||||
export type TreeNode = {
|
||||
@@ -158,6 +164,8 @@ export type TreeNode = {
|
||||
ttl?: number;
|
||||
/** Redis 文件夹前缀(含分隔符),如 CEX:FOLLOW: */
|
||||
fullName?: string;
|
||||
/** Redis TYPE 精确命中(搜索置顶标「精确」) */
|
||||
exact?: boolean;
|
||||
nullable?: boolean;
|
||||
key?: string;
|
||||
};
|
||||
@@ -192,7 +200,7 @@ export type ViewMode = "workbench" | "picker";
|
||||
export type PickerMode = "list" | "drivers";
|
||||
|
||||
type State = {
|
||||
theme: "dark" | "light";
|
||||
theme: "dark" | "light" | "system";
|
||||
view: ViewMode;
|
||||
connections: SavedConnection[];
|
||||
connGroups: ConnectionGroup[];
|
||||
@@ -200,6 +208,8 @@ type State = {
|
||||
activeConnectionId: string | null;
|
||||
tree: TreeNode[];
|
||||
treeLoading: boolean;
|
||||
/** 侧栏 ↻ 等触发对象树重载 */
|
||||
treeEpoch: number;
|
||||
tabs: QueryTab[];
|
||||
activeTabId: string;
|
||||
result: QueryResult | null;
|
||||
@@ -250,6 +260,7 @@ type State = {
|
||||
viewDataRequest: { database: string; table: string; token: number } | null;
|
||||
|
||||
set: <K extends keyof State>(key: K, value: State[K]) => void;
|
||||
requestTreeRefresh: () => void;
|
||||
notify: (input: {
|
||||
kind?: NotifyKind;
|
||||
title?: string;
|
||||
@@ -277,11 +288,13 @@ type State = {
|
||||
openPicker: (driver?: DriverId | null) => void;
|
||||
openConnectionList: () => void;
|
||||
editConnection: (c: SavedConnection) => void;
|
||||
newQueryTab: () => void;
|
||||
newQueryTab: (database?: string) => void;
|
||||
/** 重命名当前激活的查询 Tab(如打开表后标题设为表名) */
|
||||
setActiveTabTitle: (title: string) => void;
|
||||
/** 设置当前 Tab 的库上下文(Redis 编辑器 SELECT/USE 切库用) */
|
||||
setTabDatabase: (database: string) => void;
|
||||
setTabDatabase: (database: string, tabId?: string) => void;
|
||||
/** 切连接时激活其查询标签,或新建查询;保留别的连接的 SQL 草稿。 */
|
||||
selectConnection: (id: string) => void;
|
||||
closeTab: (id: string) => void;
|
||||
/** R2-F6:Tab 右键菜单批量关闭(脏确认由调用侧负责) */
|
||||
closeOtherTabs: (id: string) => void;
|
||||
@@ -290,6 +303,8 @@ type State = {
|
||||
setStopOnError: (v: boolean) => void;
|
||||
setExecTimeoutMs: (v: number) => void;
|
||||
updateActiveSql: (sql: string) => void;
|
||||
/** AI 异步返回只写入原标签;用户编辑或切库后跳过。 */
|
||||
updateTabSqlIfUnchanged: (id: string, originalSql: string, connectionId: string | undefined, database: string | undefined, sql: string) => boolean;
|
||||
pushAi: (role: AiMessage["role"], content: string) => void;
|
||||
setSession: (connectionId: string, sessionId: string | null) => void;
|
||||
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
|
||||
@@ -323,7 +338,7 @@ function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "r
|
||||
if (!raw) return {};
|
||||
const p = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
theme: p.theme === "light" ? "light" : "dark",
|
||||
theme: p.theme === "light" || p.theme === "system" ? p.theme : "dark",
|
||||
leftWidth: typeof p.leftWidth === "number" ? Math.min(360, Math.max(200, p.leftWidth)) : undefined,
|
||||
aiWidth: typeof p.aiWidth === "number" ? p.aiWidth : undefined,
|
||||
resultRatio: typeof p.resultRatio === "number" ? p.resultRatio : undefined,
|
||||
@@ -411,6 +426,11 @@ function defaultTab(): QueryTab {
|
||||
return { id: crypto.randomUUID(), title: "查询 1", sql: "", savedSql: "", dirty: false };
|
||||
}
|
||||
|
||||
/** 启动占位标签没有任何用户内容,可在真正创建标签时直接替换。 */
|
||||
function isEmptyStartupTab(tab: QueryTab): boolean {
|
||||
return !tab.connectionId && !tab.database && !tab.sql?.trim() && !tab.savedSql?.trim() && /^查询 1$/.test(tab.title);
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
theme: "dark",
|
||||
view: "workbench",
|
||||
@@ -420,6 +440,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
activeConnectionId: null,
|
||||
tree: [],
|
||||
treeLoading: false,
|
||||
treeEpoch: 0,
|
||||
tabs: [defaultTab()],
|
||||
activeTabId: "",
|
||||
result: null,
|
||||
@@ -463,7 +484,9 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
viewDataRequest: null,
|
||||
|
||||
set: (key, value) => {
|
||||
set({ [key]: value } as Partial<State>);
|
||||
set((state) => key === "activeConnectionId" && value !== state.activeConnectionId
|
||||
? ({ [key]: value, sidebarFilter: "" } as Partial<State>)
|
||||
: ({ [key]: value } as Partial<State>));
|
||||
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi" || key === "stopOnError" || key === "execTimeoutMs") {
|
||||
get().persistPrefs();
|
||||
}
|
||||
@@ -473,6 +496,8 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
requestTreeRefresh: () => set((st) => ({ treeEpoch: (st.treeEpoch || 0) + 1 })),
|
||||
|
||||
notify: (input) => {
|
||||
const parsed = input.raw !== undefined ? formatDbError(input.raw) : null;
|
||||
const kind = input.kind ?? parsed?.kind ?? "info";
|
||||
@@ -523,8 +548,10 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
}));
|
||||
const prefs = loadPrefs();
|
||||
const savedTabs = loadTabs();
|
||||
const tabs = savedTabs?.tabs ?? get().tabs;
|
||||
const activeTabId = savedTabs?.activeTabId || get().activeTabId || tabs[0]?.id || "";
|
||||
const loadedTabs = savedTabs?.tabs ?? get().tabs;
|
||||
const tabs = loadedTabs.length > 1 ? loadedTabs.filter(t => !isEmptyStartupTab(t)) : loadedTabs;
|
||||
const activeTabId = tabs.some(t => t.id === savedTabs?.activeTabId)
|
||||
? savedTabs!.activeTabId : tabs[0]?.id || "";
|
||||
set({
|
||||
connections: list,
|
||||
connGroups: loadGroups(),
|
||||
@@ -542,9 +569,12 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
password: encodeSecret(c.password || ""),
|
||||
sshPassword: c.sshPassword ? encodeSecret(c.sshPassword) : c.sshPassword,
|
||||
sshPassphrase: c.sshPassphrase ? encodeSecret(c.sshPassphrase) : c.sshPassphrase,
|
||||
sentinelPassword: c.sentinelPassword ? encodeSecret(c.sentinelPassword) : c.sentinelPassword,
|
||||
}));
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(encoded));
|
||||
set({ connections: encoded });
|
||||
// 双写 ~/.opsxdb/connections.json(经本机 Gin),供 opsxdb-mcp 读取。 // Logan.Li
|
||||
void syncConnectionsToDisk(encoded);
|
||||
},
|
||||
|
||||
upsertConnection: (c) => {
|
||||
@@ -577,7 +607,7 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
dropSchemaCache(id);
|
||||
const sessions = { ...get().sessions };
|
||||
delete sessions[id];
|
||||
if (get().activeConnectionId === id) set({ activeConnectionId: null, tree: [], sessions });
|
||||
if (get().activeConnectionId === id) set({ activeConnectionId: null, sidebarFilter: "", tree: [], sessions });
|
||||
else set({ sessions });
|
||||
},
|
||||
|
||||
@@ -721,8 +751,12 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
newQueryTab: () => {
|
||||
const n = get().tabs.length + 1;
|
||||
newQueryTab: (database) => {
|
||||
const current = get();
|
||||
const existingTabs = current.tabs.filter(t => !isEmptyStartupTab(t));
|
||||
const n = existingTabs.length + 1;
|
||||
const conn = get().connections.find((c) => c.id === get().activeConnectionId);
|
||||
const requestedDatabase = database ?? conn?.database;
|
||||
const tab: QueryTab = {
|
||||
id: crypto.randomUUID(),
|
||||
title: `查询 ${n}`,
|
||||
@@ -730,8 +764,9 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
savedSql: "",
|
||||
dirty: false,
|
||||
connectionId: get().activeConnectionId ?? undefined,
|
||||
database: conn?.driver.toLowerCase() === "redis" ? normalizeRedisDatabase(requestedDatabase, conn) : requestedDatabase,
|
||||
};
|
||||
const tabs = [...get().tabs, tab];
|
||||
const tabs = [...existingTabs, tab];
|
||||
set({ tabs, activeTabId: tab.id });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: tab.id }));
|
||||
},
|
||||
@@ -743,13 +778,42 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
|
||||
},
|
||||
|
||||
setTabDatabase: (database) => {
|
||||
setTabDatabase: (database, tabId) => {
|
||||
const s = get();
|
||||
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, database } : t));
|
||||
const id = tabId ?? s.activeTabId;
|
||||
const target = s.tabs.find(t => t.id === id);
|
||||
if (!target) return;
|
||||
const conn = s.connections.find((c) => c.id === target.connectionId);
|
||||
const nextDatabase = conn?.driver.toLowerCase() === "redis" ? normalizeRedisDatabase(database, conn) : database;
|
||||
const tabs = s.tabs.map((t) => (t.id === id ? { ...t, database: nextDatabase } : t));
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
|
||||
},
|
||||
|
||||
selectConnection: (id) => {
|
||||
if (!get().connections.some(c => c.id === id)) return;
|
||||
get().set("activeConnectionId", id);
|
||||
const s = get();
|
||||
const conn = s.connections.find(c => c.id === id)!;
|
||||
const normalizeTab = (tabId: string) => {
|
||||
if (conn.driver.toLowerCase() !== "redis") return;
|
||||
const current = get();
|
||||
const tabs = current.tabs.map(t => t.id === tabId ? { ...t, database: normalizeRedisDatabase(t.database ?? conn.database, conn) } : t);
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: current.activeTabId }));
|
||||
};
|
||||
if (s.tabs.find(t => t.id === s.activeTabId)?.connectionId === id) {
|
||||
normalizeTab(s.activeTabId);
|
||||
return;
|
||||
}
|
||||
const existing = s.tabs.find(t => t.connectionId === id);
|
||||
if (existing) {
|
||||
get().set("activeTabId", existing.id);
|
||||
normalizeTab(existing.id);
|
||||
}
|
||||
else get().newQueryTab();
|
||||
},
|
||||
|
||||
closeTab: (id) => {
|
||||
const tabs = get().tabs.filter((t) => t.id !== id);
|
||||
const next = tabs.length ? tabs : [defaultTab()];
|
||||
@@ -805,6 +869,15 @@ export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: id }));
|
||||
},
|
||||
|
||||
updateTabSqlIfUnchanged: (id, originalSql, connectionId, database, sql) => {
|
||||
const current = get().tabs.find(t => t.id === id);
|
||||
if (!current || current.sql !== originalSql || current.connectionId !== connectionId || current.database !== database) return false;
|
||||
const tabs = get().tabs.map(t => t.id === id ? { ...t, sql, dirty: sql !== (t.savedSql ?? "") } : t);
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: get().activeTabId }));
|
||||
return true;
|
||||
},
|
||||
|
||||
pushAi: (role, content) =>
|
||||
set((s) => ({
|
||||
aiMessages: [
|
||||
|
||||
Reference in new issue
Block a user