mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 16:18:55 +00:00
feat: optimize connection error alerts
This commit is contained in:
+16
-4
@@ -215,9 +215,15 @@ function newQuery() {
|
||||
async function openConnectionQuery(connectionId: string) {
|
||||
const connection = connectionStore.getConfig(connectionId);
|
||||
if (!connection) return;
|
||||
const options = await getDatabaseOptions(connectionId);
|
||||
connectionStore.activeConnectionId = connectionId;
|
||||
queryStore.createTab(connectionId, connection.database || options[0] || "");
|
||||
const tabId = queryStore.createTab(connectionId, connection.database || "");
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
const options = await getDatabaseOptions(connectionId);
|
||||
if (!connection.database && options[0]) queryStore.updateDatabase(tabId, options[0]);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function changeActiveConnection(connectionId: string) {
|
||||
@@ -225,9 +231,15 @@ async function changeActiveConnection(connectionId: string) {
|
||||
if (!tab) return;
|
||||
const connection = connectionStore.getConfig(connectionId);
|
||||
if (!connection) return;
|
||||
const options = await getDatabaseOptions(connectionId);
|
||||
queryStore.updateConnection(tab.id, connectionId, connection.database || options[0] || "");
|
||||
queryStore.updateConnection(tab.id, connectionId, connection.database || "");
|
||||
connectionStore.activeConnectionId = connectionId;
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
const options = await getDatabaseOptions(connectionId);
|
||||
if (!connection.database && options[0]) queryStore.updateDatabase(tab.id, options[0]);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function changeActiveDatabase(database: string) {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { AlertTriangle, X } from "lucide-vue-next";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
connectionId?: string | null;
|
||||
triggerClass?: string;
|
||||
}>(),
|
||||
{
|
||||
connectionId: null,
|
||||
triggerClass: "",
|
||||
},
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
const errorMessage = computed(() =>
|
||||
props.connectionId
|
||||
? connectionStore.connectionErrors[props.connectionId]
|
||||
: "",
|
||||
);
|
||||
|
||||
function clearError() {
|
||||
if (props.connectionId)
|
||||
connectionStore.clearConnectionError(props.connectionId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Popover v-if="errorMessage">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-4 w-4 shrink-0 items-center justify-center rounded text-amber-500 hover:bg-amber-500/10 hover:text-amber-600 focus:outline-none focus:ring-1 focus:ring-amber-500/40"
|
||||
:class="triggerClass"
|
||||
:title="t('connection.lastError')"
|
||||
@click.stop
|
||||
>
|
||||
<AlertTriangle class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="top" class="w-72 gap-2 p-2 text-xs" @click.stop>
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-medium text-foreground">
|
||||
{{ t("connection.lastError") }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-1 max-h-36 overflow-auto whitespace-pre-wrap break-words text-muted-foreground"
|
||||
>
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
:title="t('connection.clearError')"
|
||||
@click="clearError"
|
||||
>
|
||||
<X class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
@@ -42,6 +42,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIconType } from "@/lib/connectionPresentation";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { buildAiContext, runAiStream, type AiAction } from "@/lib/ai";
|
||||
import {
|
||||
aiTestConnection,
|
||||
@@ -59,6 +60,7 @@ const { t } = useI18n();
|
||||
const settings = useSettingsStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const { toast } = useToast();
|
||||
|
||||
interface ChatMessage {
|
||||
role: "user" | "assistant";
|
||||
@@ -133,7 +135,7 @@ async function loadDatabases() {
|
||||
await loadDatabaseOptions(props.connection.id);
|
||||
}
|
||||
|
||||
function changeConnection(connectionId: string) {
|
||||
async function changeConnection(connectionId: string) {
|
||||
const conn = connectionStore.getConfig(connectionId);
|
||||
if (!conn) return;
|
||||
connectionStore.activeConnectionId = connectionId;
|
||||
@@ -143,6 +145,11 @@ function changeConnection(connectionId: string) {
|
||||
} else {
|
||||
queryStore.createTab(connectionId, conn.database || "");
|
||||
}
|
||||
try {
|
||||
await loadDatabaseOptions(connectionId);
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function changeDatabase(database: string) {
|
||||
|
||||
@@ -60,6 +60,7 @@ import { qualifiedTableName as buildQualifiedTableName, quoteTableIdentifier } f
|
||||
import { treeNodeRowAction } from "@/lib/treeNodeClick";
|
||||
import { isTauriRuntime } from "@/lib/tauriRuntime";
|
||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||
import ConnectionErrorIndicator from "@/components/connection/ConnectionErrorIndicator.vue";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -304,9 +305,13 @@ async function openData() {
|
||||
async function newQuery() {
|
||||
const node = props.node;
|
||||
if (!node.connectionId) return;
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
connectionStore.activeConnectionId = node.connectionId;
|
||||
queryStore.createTab(node.connectionId, node.database || "", undefined, "query");
|
||||
try {
|
||||
await connectionStore.ensureConnected(node.connectionId);
|
||||
connectionStore.activeConnectionId = node.connectionId;
|
||||
queryStore.createTab(node.connectionId, node.database || "", undefined, "query");
|
||||
} catch (e: any) {
|
||||
toast(t("connection.connectFailed", { message: e?.message || String(e) }), 5000);
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
@@ -919,6 +924,11 @@ const isDragging = computed(() => dragState.active && dragState.draggedId === pr
|
||||
"
|
||||
class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0"
|
||||
/>
|
||||
<ConnectionErrorIndicator
|
||||
v-if="node.type === 'connection'"
|
||||
:connection-id="node.connectionId"
|
||||
trigger-class="h-4 w-4"
|
||||
/>
|
||||
<button
|
||||
v-if="canPin"
|
||||
class="rounded p-0.5 text-muted-foreground hover:bg-muted-foreground/15 hover:text-foreground focus:opacity-100"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverRootEmits, PopoverRootProps } from 'reka-ui'
|
||||
import { PopoverRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
|
||||
const props = defineProps<PopoverRootProps>()
|
||||
const emits = defineEmits<PopoverRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverRoot
|
||||
v-slot="slotProps"
|
||||
data-slot="popover"
|
||||
v-bind="forwarded"
|
||||
>
|
||||
<slot v-bind="slotProps" />
|
||||
</PopoverRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverAnchorProps } from 'reka-ui'
|
||||
import { PopoverAnchor } from 'reka-ui'
|
||||
|
||||
const props = defineProps<PopoverAnchorProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverAnchor
|
||||
data-slot="popover-anchor"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverAnchor>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverContentEmits, PopoverContentProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { reactiveOmit } from '@vueuse/core'
|
||||
import {
|
||||
PopoverContent,
|
||||
PopoverPortal,
|
||||
useForwardPropsEmits,
|
||||
} from 'reka-ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<PopoverContentProps & { class?: HTMLAttributes['class'] }>(),
|
||||
{
|
||||
align: 'center',
|
||||
sideOffset: 4,
|
||||
},
|
||||
)
|
||||
const emits = defineEmits<PopoverContentEmits>()
|
||||
|
||||
const delegatedProps = reactiveOmit(props, 'class')
|
||||
|
||||
const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
data-slot="popover-content"
|
||||
v-bind="{ ...$attrs, ...forwarded }"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 flex flex-col gap-2.5 rounded-lg p-2.5 text-sm shadow-md ring-1 duration-100 z-50 w-72 origin-(--reka-popover-content-transform-origin) outline-hidden',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p
|
||||
data-slot="popover-description"
|
||||
:class="cn('text-muted-foreground', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
:class="cn('flex flex-col gap-0.5 text-sm', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
data-slot="popover-title"
|
||||
:class="cn('font-medium cn-font-heading', props.class)"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { PopoverTriggerProps } from 'reka-ui'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const props = defineProps<PopoverTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PopoverTrigger
|
||||
data-slot="popover-trigger"
|
||||
v-bind="props"
|
||||
>
|
||||
<slot />
|
||||
</PopoverTrigger>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as Popover } from './Popover.vue'
|
||||
export { default as PopoverAnchor } from './PopoverAnchor.vue'
|
||||
export { default as PopoverContent } from './PopoverContent.vue'
|
||||
export { default as PopoverDescription } from './PopoverDescription.vue'
|
||||
export { default as PopoverHeader } from './PopoverHeader.vue'
|
||||
export { default as PopoverTitle } from './PopoverTitle.vue'
|
||||
export { default as PopoverTrigger } from './PopoverTrigger.vue'
|
||||
@@ -76,6 +76,8 @@ export default {
|
||||
connecting: "Connecting to {name}...",
|
||||
connectSuccess: "Connected to {name}",
|
||||
connectFailed: "Connection failed: {message}",
|
||||
lastError: "Connection error",
|
||||
clearError: "Clear connection error",
|
||||
saveFailed: "Failed to save connection: {message}",
|
||||
loadFailed: "Failed to load saved connections: {message}",
|
||||
sshTunnel: "SSH Tunnel",
|
||||
|
||||
@@ -75,6 +75,8 @@ export default {
|
||||
connecting: "正在连接 {name}...",
|
||||
connectSuccess: "已连接 {name}",
|
||||
connectFailed: "连接失败:{message}",
|
||||
lastError: "连接错误",
|
||||
clearError: "清除连接错误",
|
||||
saveFailed: "保存连接失败:{message}",
|
||||
loadFailed: "加载已保存连接失败:{message}",
|
||||
sshTunnel: "SSH 隧道",
|
||||
|
||||
@@ -35,6 +35,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const treeNodes = ref<TreeNode[]>([]);
|
||||
const pinnedTreeNodeIds = ref<Set<string>>(loadPinnedTreeNodeIds());
|
||||
const connectedIds = ref<Set<string>>(new Set());
|
||||
const connectionErrors = ref<Record<string, string>>({});
|
||||
const editingConnectionId = ref<string | null>(null);
|
||||
const completionTablesCache = ref<Record<string, SqlCompletionTable[]>>({});
|
||||
const completionColumnsCache = ref<Record<string, ColumnInfo[]>>({});
|
||||
@@ -86,6 +87,31 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return connections.value.find((c) => c.id === connectionId);
|
||||
}
|
||||
|
||||
function connectionErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function setConnectionError(connectionId: string, message: string) {
|
||||
connectionErrors.value = {
|
||||
...connectionErrors.value,
|
||||
[connectionId]: message,
|
||||
};
|
||||
}
|
||||
|
||||
function clearConnectionError(connectionId: string) {
|
||||
if (!connectionErrors.value[connectionId]) return;
|
||||
const next = { ...connectionErrors.value };
|
||||
delete next[connectionId];
|
||||
connectionErrors.value = next;
|
||||
}
|
||||
|
||||
function recordConnectionError(connectionId: string, error: unknown): string {
|
||||
const message = connectionErrorMessage(error);
|
||||
setConnectionError(connectionId, message);
|
||||
return message;
|
||||
}
|
||||
|
||||
function normalizeConnection(config: ConnectionConfig): ConnectionConfig {
|
||||
const labelMap: Record<string, string> = {
|
||||
mysql: "MySQL",
|
||||
@@ -203,6 +229,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const nextConnections = connections.value.filter((c) => c.id !== id);
|
||||
await persistConnections(nextConnections);
|
||||
connections.value = nextConnections;
|
||||
clearConnectionError(id);
|
||||
sidebarLayout.value = removeConnectionFromSidebarLayout(sidebarLayout.value, id);
|
||||
rebuildTreeNodes();
|
||||
persistSidebarLayoutDebounced();
|
||||
@@ -233,6 +260,8 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const id = await api.connectDb(config);
|
||||
activeConnectionId.value = id;
|
||||
connectedIds.value.add(id);
|
||||
clearConnectionError(config.id);
|
||||
if (id !== config.id) clearConnectionError(id);
|
||||
|
||||
const node: TreeNode = {
|
||||
id,
|
||||
@@ -249,6 +278,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
treeNodes.value.push(node);
|
||||
}
|
||||
return id;
|
||||
} catch (e) {
|
||||
recordConnectionError(config.id, e);
|
||||
throw e;
|
||||
} finally {
|
||||
const node = findNode(treeNodes.value, config.id);
|
||||
if (node) node.isLoading = false;
|
||||
@@ -272,10 +304,20 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
async function ensureConnected(connectionId: string) {
|
||||
if (connectedIds.value.has(connectionId)) return;
|
||||
const config = getConfig(connectionId);
|
||||
if (!config) throw new Error("Connection config not found");
|
||||
await api.connectDb(config);
|
||||
connectedIds.value.add(connectionId);
|
||||
activeConnectionId.value = connectionId;
|
||||
if (!config) {
|
||||
const error = new Error("Connection config not found");
|
||||
recordConnectionError(connectionId, error);
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
await api.connectDb(config);
|
||||
connectedIds.value.add(connectionId);
|
||||
activeConnectionId.value = connectionId;
|
||||
clearConnectionError(connectionId);
|
||||
} catch (e) {
|
||||
recordConnectionError(connectionId, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDatabases(connectionId: string) {
|
||||
@@ -858,6 +900,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
connections.value.push(normalized);
|
||||
}
|
||||
connectedIds.value.add(normalized.id);
|
||||
clearConnectionError(normalized.id);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -865,6 +908,10 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
activeConnectionId,
|
||||
treeNodes,
|
||||
connectedIds,
|
||||
connectionErrors,
|
||||
setConnectionError,
|
||||
clearConnectionError,
|
||||
recordConnectionError,
|
||||
sidebarLayout,
|
||||
getConfig,
|
||||
isTreeNodePinned,
|
||||
|
||||
Reference in New Issue
Block a user