diff --git a/apps/desktop/src/components/admin/DamengJobAdmin.vue b/apps/desktop/src/components/admin/DamengJobAdmin.vue new file mode 100644 index 0000000000..945fe656ba --- /dev/null +++ b/apps/desktop/src/components/admin/DamengJobAdmin.vue @@ -0,0 +1,471 @@ + + + + + + + + {{ t("damengJobAdmin.title") }} + {{ connection.name }} + + + + + {{ t("contextMenu.refreshChildren") }} + + + + {{ jobEnvironmentReady ? t("damengJobAdmin.environmentReady") : t("damengJobAdmin.initEnvironment") }} + + + + {{ t("damengJobAdmin.newJob") }} + + + + + {{ t("damengJobAdmin.unsupported") }} + + + + + + + + + {{ selectedJob.name }} + + {{ t("damengJobAdmin.running") }} + {{ selectedJob.description || t("damengJobAdmin.noDescription") }} + + + + + {{ t("damengJobAdmin.runAsync") }} + + + + {{ t("damengJobAdmin.stopRunning") }} + + + + {{ selectedJob.enabled ? t("damengJobAdmin.disable") : t("damengJobAdmin.enable") }} + + + + {{ t("damengJobAdmin.clearHistories") }} + + + + {{ t("damengJobAdmin.dropJob") }} + + + + + {{ t("damengJobAdmin.steps") }} + {{ t("damengJobAdmin.schedules") }} + {{ t("damengJobAdmin.histories") }} + + + + + + + {{ t("damengJobAdmin.environmentMissingTitle") }} + {{ t("damengJobAdmin.environmentMissingDescription") }} + + + {{ t("damengJobAdmin.initEnvironment") }} + + + + + {{ t("damengJobAdmin.selectJob") }} + + + {{ detailError }} + + + + + {{ column }} + + + + + + {{ cellText(row[column]) || "-" }} + + + + + + {{ t("damengJobAdmin.emptyDetails") }} + + + + + + + + + {{ t("damengJobAdmin.newJob") }} + + + + + {{ t("damengJobAdmin.jobName") }} + + + + {{ t("damengJobAdmin.description") }} + + + + + + {{ t("damengJobAdmin.enabled") }} + + + + {{ t("damengJobAdmin.stepName") }} + + + + {{ t("damengJobAdmin.scheduleName") }} + + + + + {{ t("damengJobAdmin.sqlCommand") }} + + + + + {{ t("damengJobAdmin.scheduleMode") }} + + {{ t("damengJobAdmin.daily") }} + {{ t("damengJobAdmin.once") }} + + + + {{ t("damengJobAdmin.startDate") }} + + + + {{ t("damengJobAdmin.startTime") }} + + + + {{ t("damengJobAdmin.endTime") }} + + + + {{ t("damengJobAdmin.minuteInterval") }} + + + + + + {{ t("common.cancel") }} + {{ t("damengJobAdmin.previewSql") }} + + + + + + + + {{ t("damengJobAdmin.sqlPreview") }} + + {{ pendingSql }} + + {{ t("common.cancel") }} + + + {{ t("damengJobAdmin.applySql") }} + + + + + + diff --git a/apps/desktop/src/components/layout/AppTabBar.vue b/apps/desktop/src/components/layout/AppTabBar.vue index e97e9835cd..c9fb781cfd 100644 --- a/apps/desktop/src/components/layout/AppTabBar.vue +++ b/apps/desktop/src/components/layout/AppTabBar.vue @@ -2,7 +2,7 @@ import { computed, ref, watch, nextTick, onUnmounted } from "vue"; import type { CSSProperties } from "vue"; import { useI18n } from "vue-i18n"; -import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings } from "@lucide/vue"; +import { X, Pin, ChevronDown, Table2, Code2, TableProperties, PencilRuler, KeyRound, Pencil, Package, Lock, Copy, AlertTriangle, Network, Minimize2, Maximize2, Settings, CalendarClock } from "@lucide/vue"; import CustomContextMenu, { type ContextMenuItem } from "@/components/ui/CustomContextMenu.vue"; import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; @@ -433,6 +433,7 @@ function tabMenuIcon(tab: QueryTab) { if (tab.mode === "nacos") return Network; if (tab.mode === "objects") return TableProperties; if (tab.mode === "structure") return PencilRuler; + if (tab.mode === "dameng-jobs") return CalendarClock; return Code2; } @@ -565,6 +566,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul + + import("@/components/nacos/ const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue")); const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue")); const DatabaseUserAdmin = defineAsyncComponent(() => import("@/components/admin/DatabaseUserAdmin.vue")); +const DamengJobAdmin = defineAsyncComponent(() => import("@/components/admin/DamengJobAdmin.vue")); const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue")); const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue")); import { useQueryStore } from "@/stores/queryStore"; @@ -1515,6 +1516,10 @@ defineExpose({ focusSearch, refreshData, handleModRTarget, requestQueryEditorExe + + + + diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 014ab40a34..79ad3846c5 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -50,6 +50,7 @@ import { Clipboard, Check, UsersRound, + CalendarClock, Lock, HardDriveDownload, FilePlus, @@ -336,6 +337,8 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: TableProperties, colorClass: "text-primary" }; case "user-admin": return { icon: UsersRound, colorClass: "text-primary" }; + case "dameng-job-admin": + return { icon: CalendarClock, colorClass: "text-primary" }; case "index": return { icon: Key, colorClass: "text-amber-400" }; case "fkey": @@ -410,7 +413,7 @@ function isGroupLabel(node: TreeNode): boolean { function displayLabel(node: TreeNode): string { if (node.type === "load-more") return t(node.label); if (node.type === "object-browser") return t(node.label, { count: node.objectCount ?? 0 }); - if (node.type === "user-admin") return t(node.label); + if (node.type === "user-admin" || node.type === "dameng-job-admin") return t(node.label); if (node.type === "linked-server-root") return t(node.label); if (node.label === "tree.defaultDatabase") return t(node.label); return isGroupLabel(node) ? t(node.label) : node.label; @@ -616,6 +619,9 @@ async function toggle() { } else if (node.type === "user-admin" && node.connectionId) { await connectionStore.ensureConnected(node.connectionId); queryStore.openUserAdmin(node.connectionId); + } else if (node.type === "dameng-job-admin" && node.connectionId) { + await connectionStore.ensureConnected(node.connectionId); + queryStore.openDamengJobAdmin(node.connectionId); } else if (node.type === "mongo-db" && node.connectionId && node.database) { await connectionStore.loadMongoCollections(node.connectionId, node.database); } else if (node.type === "vector-database" && node.connectionId && node.database) { @@ -1125,6 +1131,18 @@ async function openUserAdmin() { } } +async function openDamengJobAdmin() { + const node = props.node; + if (!node.connectionId) return; + try { + await connectionStore.ensureConnected(node.connectionId); + connectionStore.activeConnectionId = node.connectionId; + queryStore.openDamengJobAdmin(node.connectionId); + } catch (e: any) { + toast(t("connection.connectFailed", { message: translateBackendError(t, e?.message || String(e)) }), 5000); + } +} + async function openData() { const node = props.node; if (!(node.type === "table" || node.type === "view" || node.type === "materialized_view") || !hasNodeDatabaseContext(node)) return; @@ -4502,6 +4520,9 @@ function treeItemMenuItems(): ContextMenuItem[] { if (supportsDatabaseUserAdmin(currentDatabaseType())) { items.push({ label: t("contextMenu.userAdmin"), action: openUserAdmin, icon: UsersRound }); } + if (currentDatabaseType() === "dameng") { + items.push({ label: t("contextMenu.damengJobAdmin"), action: openDamengJobAdmin, icon: CalendarClock }); + } if (canCopyFinalProxyPort.value) { items.push({ label: t("contextMenu.copyFinalProxyPort"), action: copyFinalProxyPort, icon: Network }); } @@ -4723,6 +4744,11 @@ function treeItemMenuItems(): ContextMenuItem[] { return items; } + if (node.type === "dameng-job-admin") { + items.push({ label: t("contextMenu.openDamengJobAdmin"), action: openDamengJobAdmin, icon: CalendarClock }); + return items; + } + if (node.type === "redis-db" || node.type === "mongo-db") { items.push({ label: t("contextMenu.newQuery"), action: newQuery, icon: TerminalSquare }); if (!isNodeDefaultDatabase.value) { diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index bde1635e5d..5be1e87337 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1347,6 +1347,8 @@ export default { configureVisibleObjects: "Visible Object Filter", userAdmin: "Users & Privileges", openUserAdmin: "Open Users & Privileges", + damengJobAdmin: "Dameng Agent Jobs", + openDamengJobAdmin: "Open Dameng Agent Jobs", duplicateConnection: "Duplicate Connection", duplicateSelectedConnections: "Duplicate Selected {count} Connections", newQuery: "New Query", @@ -1599,6 +1601,7 @@ export default { tree: { savedSql: "SQL Library", userAdmin: "Users & Privileges", + damengJobAdmin: "Agent Jobs", linkedServers: "Linked Servers", defaultDatabase: "Default DB", columns: "Columns", @@ -1676,6 +1679,52 @@ export default { applySuccess: "User privileges updated", applyFailed: "Execution failed: {message}", }, + damengJobAdmin: { + title: "Dameng Agent Jobs", + unsupported: "This page only supports Dameng connections.", + initEnvironment: "Initialize Job Environment", + environmentReady: "Job Environment Ready", + newJob: "New Job", + searchJob: "Search jobs", + loadFailed: "Load failed", + environmentMissingTitle: "Job environment is not initialized", + environmentMissingHint: "This database does not have the SYSJOB job environment yet.", + environmentMissingDescription: "Dameng agent jobs require system tables and views under the SYSJOB schema. Initialize the job environment first; administrator privileges are usually required.", + emptyJobs: "No jobs found, or the current account cannot read the job list.", + emptyDetails: "No detail rows", + selectJob: "Select a job", + enabled: "Enabled", + disabled: "Disabled", + running: "Running", + enable: "Enable", + disable: "Disable", + runAsync: "Run Now", + stopRunning: "Stop Current Run", + stopRunningDisabled: "Only currently running jobs can be stopped", + clearHistories: "Clear History", + dropJob: "Drop", + steps: "Steps", + schedules: "Schedules", + histories: "History", + noDescription: "No description", + jobName: "Job name", + description: "Description", + stepName: "Step name", + scheduleName: "Schedule name", + sqlCommand: "SQL script", + scheduleMode: "Schedule type", + daily: "Daily", + once: "Once", + startDate: "Start date", + startTime: "Start time", + endTime: "End time", + minuteInterval: "Minute interval", + previewSql: "Preview SQL", + sqlPreview: "SQL Preview", + applySql: "Apply SQL", + applySuccess: "Job operation applied", + applyFailed: "Execution failed: {message}", + }, objects: { all: "All", tables: "Tables", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 2d086a8b01..8d19ce4fb3 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -1293,6 +1293,8 @@ export default withEnglishFallback({ configureVisibleObjects: "Filtro de objetos visibles", userAdmin: "Usuarios y Privilegios", openUserAdmin: "Abrir Usuarios y Privilegios", + damengJobAdmin: "Trabajos del agente Dameng", + openDamengJobAdmin: "Abrir trabajos del agente Dameng", duplicateConnection: "Duplicar conexión", duplicateSelectedConnections: "Duplicar {count} conexiones seleccionadas", instanceInfo: "Información de la instancia", @@ -1545,6 +1547,7 @@ export default withEnglishFallback({ tree: { savedSql: "Biblioteca SQL", userAdmin: "Usuarios y Privilegios", + damengJobAdmin: "Trabajos del agente", linkedServers: "Servidores vinculados", defaultDatabase: "Base predeterminada", columns: "Columnas", @@ -1608,6 +1611,52 @@ export default withEnglishFallback({ applySuccess: "Privilegios de usuario actualizados", applyFailed: "Ejecución fallida: {message}", }, + damengJobAdmin: { + title: "Trabajos del agente Dameng", + unsupported: "Esta página solo admite conexiones Dameng.", + initEnvironment: "Inicializar entorno de trabajos", + environmentReady: "Entorno de trabajos inicializado", + newJob: "Nuevo trabajo", + searchJob: "Buscar trabajos", + loadFailed: "Error al cargar", + environmentMissingTitle: "El entorno de trabajos no está inicializado", + environmentMissingHint: "Esta base de datos aún no tiene el entorno de trabajos SYSJOB.", + environmentMissingDescription: "Los trabajos del agente Dameng dependen de tablas y vistas del sistema en el esquema SYSJOB. Inicializa primero el entorno de trabajos; normalmente se requieren privilegios de administrador.", + emptyJobs: "No se encontraron trabajos, o la cuenta actual no puede leer la lista de trabajos.", + emptyDetails: "No hay filas de detalle", + selectJob: "Selecciona un trabajo", + enabled: "Habilitado", + disabled: "Deshabilitado", + running: "En ejecución", + enable: "Habilitar", + disable: "Deshabilitar", + runAsync: "Ejecutar ahora", + stopRunning: "Detener ejecución actual", + stopRunningDisabled: "Solo se pueden detener trabajos que se estén ejecutando", + clearHistories: "Limpiar historial", + dropJob: "Eliminar", + steps: "Pasos", + schedules: "Programaciones", + histories: "Historial", + noDescription: "Sin descripción", + jobName: "Nombre del trabajo", + description: "Descripción", + stepName: "Nombre del paso", + scheduleName: "Nombre de programación", + sqlCommand: "Script SQL", + scheduleMode: "Tipo de programación", + daily: "Diario", + once: "Una vez", + startDate: "Fecha de inicio", + startTime: "Hora de inicio", + endTime: "Hora de fin", + minuteInterval: "Intervalo en minutos", + previewSql: "Vista previa de SQL", + sqlPreview: "Vista previa de SQL", + applySql: "Aplicar SQL", + applySuccess: "Operación de trabajo aplicada", + applyFailed: "Ejecución fallida: {message}", + }, objects: { all: "Todos", tables: "Tablas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 9de710d067..c676733557 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -1291,6 +1291,8 @@ export default withEnglishFallback({ configureVisibleObjects: "Filtro Oggetti Visibili", userAdmin: "Utenti e Privilegi", openUserAdmin: "Apri Utenti e Privilegi", + damengJobAdmin: "Job agente Dameng", + openDamengJobAdmin: "Apri job agente Dameng", duplicateConnection: "Duplica Connessione", duplicateSelectedConnections: "Duplica {count} connessioni selezionate", newQuery: "Nuova Query", @@ -1543,6 +1545,7 @@ export default withEnglishFallback({ tree: { savedSql: "Libreria SQL", userAdmin: "Utenti e Privilegi", + damengJobAdmin: "Job agente", linkedServers: "Server Collegati", defaultDatabase: "DB Predefinito", columns: "Colonne", @@ -1606,6 +1609,52 @@ export default withEnglishFallback({ applySuccess: "Privilegi utente aggiornati", applyFailed: "Esecuzione non riuscita: {message}", }, + damengJobAdmin: { + title: "Job agente Dameng", + unsupported: "Questa pagina supporta solo connessioni Dameng.", + initEnvironment: "Inizializza ambiente job", + environmentReady: "Ambiente job inizializzato", + newJob: "Nuovo job", + searchJob: "Cerca job", + loadFailed: "Caricamento non riuscito", + environmentMissingTitle: "Ambiente job non inizializzato", + environmentMissingHint: "Questo database non ha ancora l'ambiente SYSJOB.", + environmentMissingDescription: "I job agente Dameng dipendono da tabelle e viste di sistema nello schema SYSJOB. Inizializza prima l'ambiente job; di solito sono necessari privilegi amministrativi.", + emptyJobs: "Nessun job trovato, oppure l'account corrente non puo leggere l'elenco dei job.", + emptyDetails: "Nessuna riga di dettaglio", + selectJob: "Seleziona un job", + enabled: "Abilitato", + disabled: "Disabilitato", + running: "In esecuzione", + enable: "Abilita", + disable: "Disabilita", + runAsync: "Esegui ora", + stopRunning: "Ferma esecuzione corrente", + stopRunningDisabled: "Solo i job attualmente in esecuzione possono essere fermati", + clearHistories: "Cancella cronologia", + dropJob: "Elimina", + steps: "Passi", + schedules: "Pianificazioni", + histories: "Cronologia", + noDescription: "Nessuna descrizione", + jobName: "Nome job", + description: "Descrizione", + stepName: "Nome passo", + scheduleName: "Nome pianificazione", + sqlCommand: "Script SQL", + scheduleMode: "Tipo pianificazione", + daily: "Giornaliero", + once: "Una volta", + startDate: "Data inizio", + startTime: "Ora inizio", + endTime: "Ora fine", + minuteInterval: "Intervallo in minuti", + previewSql: "Anteprima SQL", + sqlPreview: "Anteprima SQL", + applySql: "Applica SQL", + applySuccess: "Operazione job applicata", + applyFailed: "Esecuzione non riuscita: {message}", + }, objects: { all: "Tutti", tables: "Tabelle", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index dd83f573d3..5bb49504dd 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -1290,6 +1290,8 @@ export default withEnglishFallback({ selectVisibleDatabases: "表示するデータベースを選択", userAdmin: "ユーザーと権限", openUserAdmin: "ユーザーと権限を開く", + damengJobAdmin: "Dameng エージェントジョブ", + openDamengJobAdmin: "Dameng エージェントジョブを開く", duplicateConnection: "接続を複製", duplicateSelectedConnections: "選択した{count}件の接続を複製", newQuery: "新しいクエリ", @@ -1543,6 +1545,7 @@ export default withEnglishFallback({ tree: { savedSql: "SQLライブラリ", userAdmin: "ユーザーと権限", + damengJobAdmin: "エージェントジョブ", defaultDatabase: "デフォルトDB", columns: "列", indexes: "インデックス", @@ -1640,6 +1643,52 @@ export default withEnglishFallback({ applySuccess: "ユーザー権限を更新しました", applyFailed: "実行に失敗しました: {message}", }, + damengJobAdmin: { + title: "Dameng エージェントジョブ", + unsupported: "このページは Dameng 接続のみ対応しています。", + initEnvironment: "ジョブ環境を初期化", + environmentReady: "ジョブ環境は初期化済みです", + newJob: "新しいジョブ", + searchJob: "ジョブを検索", + loadFailed: "読み込みに失敗しました", + environmentMissingTitle: "ジョブ環境が初期化されていません", + environmentMissingHint: "このデータベースにはまだ SYSJOB ジョブ環境がありません。", + environmentMissingDescription: "Dameng エージェントジョブは SYSJOB スキーマ配下のシステムテーブルとビューに依存します。先にジョブ環境を初期化してください。通常は管理者権限が必要です。", + emptyJobs: "ジョブが見つからないか、現在のアカウントにジョブ一覧の読み取り権限がありません。", + emptyDetails: "詳細行はありません", + selectJob: "ジョブを選択してください", + enabled: "有効", + disabled: "無効", + running: "実行中", + enable: "有効化", + disable: "無効化", + runAsync: "今すぐ実行", + stopRunning: "現在の実行を停止", + stopRunningDisabled: "現在実行中のジョブのみ停止できます", + clearHistories: "履歴をクリア", + dropJob: "削除", + steps: "ステップ", + schedules: "スケジュール", + histories: "履歴", + noDescription: "説明なし", + jobName: "ジョブ名", + description: "説明", + stepName: "ステップ名", + scheduleName: "スケジュール名", + sqlCommand: "SQLスクリプト", + scheduleMode: "スケジュール種別", + daily: "毎日", + once: "1回", + startDate: "開始日", + startTime: "開始時刻", + endTime: "終了時刻", + minuteInterval: "分間隔", + previewSql: "SQLをプレビュー", + sqlPreview: "SQLプレビュー", + applySql: "SQLを適用", + applySuccess: "ジョブ操作を適用しました", + applyFailed: "実行に失敗しました: {message}", + }, objects: { all: "すべて", tables: "テーブル", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 8c870b1664..c828a27db8 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -1292,6 +1292,8 @@ export default withEnglishFallback({ configureVisibleObjects: "Filtro de Objetos Visíveis", userAdmin: "Usuários e Privilégios", openUserAdmin: "Abrir Usuários e Privilégios", + damengJobAdmin: "Jobs do agente Dameng", + openDamengJobAdmin: "Abrir jobs do agente Dameng", duplicateConnection: "Duplicar Conexão", duplicateSelectedConnections: "Duplicar {count} conexões selecionadas", instanceInfo: "Informações da Instância", @@ -1544,6 +1546,7 @@ export default withEnglishFallback({ tree: { savedSql: "Biblioteca SQL", userAdmin: "Usuários e Privilégios", + damengJobAdmin: "Jobs do agente", linkedServers: "Servidores Vinculados", materializedViews: "Visualizações Materializadas", defaultDatabase: "Banco padrão", @@ -1607,6 +1610,52 @@ export default withEnglishFallback({ applySuccess: "Privilégios do usuário atualizados", applyFailed: "Execução falhou: {message}", }, + damengJobAdmin: { + title: "Jobs do agente Dameng", + unsupported: "Esta página oferece suporte apenas a conexões Dameng.", + initEnvironment: "Inicializar ambiente de jobs", + environmentReady: "Ambiente de jobs inicializado", + newJob: "Novo job", + searchJob: "Pesquisar jobs", + loadFailed: "Falha ao carregar", + environmentMissingTitle: "O ambiente de jobs não foi inicializado", + environmentMissingHint: "Este banco de dados ainda não tem o ambiente de jobs SYSJOB.", + environmentMissingDescription: "Os jobs do agente Dameng dependem de tabelas e visões do sistema no schema SYSJOB. Inicialize primeiro o ambiente de jobs; normalmente são necessários privilégios de administrador.", + emptyJobs: "Nenhum job encontrado, ou a conta atual não pode ler a lista de jobs.", + emptyDetails: "Nenhuma linha de detalhe", + selectJob: "Selecione um job", + enabled: "Habilitado", + disabled: "Desabilitado", + running: "Em execução", + enable: "Habilitar", + disable: "Desabilitar", + runAsync: "Executar agora", + stopRunning: "Parar execução atual", + stopRunningDisabled: "Somente jobs em execução podem ser parados", + clearHistories: "Limpar histórico", + dropJob: "Remover", + steps: "Etapas", + schedules: "Agendamentos", + histories: "Histórico", + noDescription: "Sem descrição", + jobName: "Nome do job", + description: "Descrição", + stepName: "Nome da etapa", + scheduleName: "Nome do agendamento", + sqlCommand: "Script SQL", + scheduleMode: "Tipo de agendamento", + daily: "Diário", + once: "Uma vez", + startDate: "Data de início", + startTime: "Hora de início", + endTime: "Hora de término", + minuteInterval: "Intervalo em minutos", + previewSql: "Visualizar SQL", + sqlPreview: "Visualização SQL", + applySql: "Aplicar SQL", + applySuccess: "Operação de job aplicada", + applyFailed: "Execução falhou: {message}", + }, objects: { all: "Todos", tables: "Tabelas", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4bfb351ff6..f59eb9a7f6 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1351,6 +1351,8 @@ export default withEnglishFallback({ duplicateSelectedConnections: "复制选中的 {count} 个连接", userAdmin: "用户与权限", openUserAdmin: "打开用户与权限", + damengJobAdmin: "达梦代理作业", + openDamengJobAdmin: "打开达梦代理作业", newQuery: "新建查询", instanceInfo: "实例信息", newInsert: "新建新增", @@ -1601,6 +1603,7 @@ export default withEnglishFallback({ tree: { savedSql: "SQL 库", userAdmin: "用户与权限", + damengJobAdmin: "代理作业", linkedServers: "链接服务器", defaultDatabase: "默认库", columns: "字段", @@ -1676,6 +1679,52 @@ export default withEnglishFallback({ applySuccess: "用户权限已更新", applyFailed: "执行失败:{message}", }, + damengJobAdmin: { + title: "达梦代理作业", + unsupported: "当前页面仅支持达梦数据库连接。", + initEnvironment: "初始化作业环境", + environmentReady: "作业环境已初始化", + newJob: "新建作业", + searchJob: "搜索作业", + loadFailed: "加载失败", + environmentMissingTitle: "未初始化作业环境", + environmentMissingHint: "当前库还没有 SYSJOB 作业环境。", + environmentMissingDescription: "达梦代理作业依赖 SYSJOB 模式下的系统表和视图。可以先执行初始化作业环境,通常需要管理员权限。", + emptyJobs: "暂无作业,或当前账号没有读取作业列表的权限。", + emptyDetails: "暂无明细数据", + selectJob: "请选择一个作业", + enabled: "启用", + disabled: "停用", + running: "运行中", + enable: "启用", + disable: "停用", + runAsync: "立即执行", + stopRunning: "停止当前执行", + stopRunningDisabled: "仅作业正在执行时可停止", + clearHistories: "清理历史", + dropJob: "删除", + steps: "步骤", + schedules: "调度", + histories: "历史", + noDescription: "暂无描述", + jobName: "作业名", + description: "描述", + stepName: "步骤名", + scheduleName: "调度名", + sqlCommand: "SQL 脚本", + scheduleMode: "调度类型", + daily: "每天", + once: "一次", + startDate: "开始日期", + startTime: "开始时间", + endTime: "结束时间", + minuteInterval: "分钟间隔", + previewSql: "预览 SQL", + sqlPreview: "SQL 预览", + applySql: "执行 SQL", + applySuccess: "作业操作已执行", + applyFailed: "执行失败:{message}", + }, objects: { all: "全部", tables: "表", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 990fbe5a4f..eb829335c8 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -1292,6 +1292,8 @@ export default withEnglishFallback({ configureVisibleObjects: "可見物件過濾", userAdmin: "使用者與權限", openUserAdmin: "開啟使用者與權限", + damengJobAdmin: "達夢代理作業", + openDamengJobAdmin: "開啟達夢代理作業", duplicateConnection: "複製連線", duplicateSelectedConnections: "複製選取的 {count} 個連線", newQuery: "建立查詢", @@ -1544,6 +1546,7 @@ export default withEnglishFallback({ tree: { savedSql: "SQL 庫", userAdmin: "使用者與權限", + damengJobAdmin: "代理作業", linkedServers: "連結伺服器", materializedViews: "具體化檢視", defaultDatabase: "預設庫", @@ -3342,6 +3345,52 @@ export default withEnglishFallback({ applySuccess: "使用者權限已更新", applyFailed: "執行失敗:{message}", }, + damengJobAdmin: { + title: "達夢代理作業", + unsupported: "目前頁面僅支援達夢資料庫連線。", + initEnvironment: "初始化作業環境", + environmentReady: "作業環境已初始化", + newJob: "新增作業", + searchJob: "搜尋作業", + loadFailed: "載入失敗", + environmentMissingTitle: "未初始化作業環境", + environmentMissingHint: "目前資料庫還沒有 SYSJOB 作業環境。", + environmentMissingDescription: "達夢代理作業依賴 SYSJOB Schema 下的系統資料表和檢視。可以先執行初始化作業環境,通常需要管理員權限。", + emptyJobs: "暫無作業,或目前帳號沒有讀取作業清單的權限。", + emptyDetails: "暫無明細資料", + selectJob: "請選擇一個作業", + enabled: "啟用", + disabled: "停用", + running: "執行中", + enable: "啟用", + disable: "停用", + runAsync: "立即執行", + stopRunning: "停止目前執行", + stopRunningDisabled: "僅作業正在執行時可停止", + clearHistories: "清理歷史", + dropJob: "刪除", + steps: "步驟", + schedules: "排程", + histories: "歷史", + noDescription: "暫無描述", + jobName: "作業名稱", + description: "描述", + stepName: "步驟名稱", + scheduleName: "排程名稱", + sqlCommand: "SQL 指令碼", + scheduleMode: "排程類型", + daily: "每天", + once: "一次", + startDate: "開始日期", + startTime: "開始時間", + endTime: "結束時間", + minuteInterval: "分鐘間隔", + previewSql: "預覽 SQL", + sqlPreview: "SQL 預覽", + applySql: "執行 SQL", + applySuccess: "作業操作已執行", + applyFailed: "執行失敗:{message}", + }, etcd: { prefixPlaceholder: "前綴,例如 /app/", newKey: "新增 Key", diff --git a/apps/desktop/src/lib/__tests__/database/damengJobAdmin.spec.ts b/apps/desktop/src/lib/__tests__/database/damengJobAdmin.spec.ts new file mode 100644 index 0000000000..f9f9eaef33 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/database/damengJobAdmin.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { damengCreateJobSql, damengEnableJobSql, damengJobHistoriesSql, damengJobListSql, damengJobStepsSql, damengRunJobSql, isDamengJobEnvironmentMissingError, parseDamengJobEnvironmentReady, parseDamengJobs, quoteDamengString } from "@/lib/database/damengJobAdmin"; +import type { QueryResult } from "@/types/database"; + +describe("damengJobAdmin", () => { + it("quotes Dameng string literals", () => { + expect(quoteDamengString("job's test")).toBe("'job''s test'"); + }); + + it("builds job action sql with quoted names", () => { + expect(damengEnableJobSql("JOB_A", false)).toBe("SP_ENABLE_JOB('JOB_A', 0);"); + expect(damengJobStepsSql("12")).toBe("SELECT * FROM SYSJOB.USER_JOBSTEPS_VIEW WHERE JOBID = 12"); + expect(damengJobStepsSql("12", true)).toBe("SELECT * FROM SYSJOB.SYSJOBSTEPS WHERE JOBID = 12"); + expect(damengJobHistoriesSql({ id: "12", name: "JOB'A" }, true)).toBe("SELECT * FROM SYSJOB.SYSJOBHISTORIES2 WHERE JOBID = 12"); + expect(damengRunJobSql("12")).toBe("SP_DBMS_JOB_RUN_ASYNC(12);"); + }); + + it("avoids DBA running view for normal user job list", () => { + expect(damengJobListSql()).toContain("FROM SYSJOB.USER_JOBS_VIEW J"); + expect(damengJobListSql()).toContain("0 AS RUNNING"); + expect(damengJobListSql()).not.toContain("DBA_JOBS_RUNNING"); + }); + + it("uses system job tables with running state for SYSDBA job list", () => { + expect(damengJobListSql(true)).toContain("FROM SYSJOB.SYSJOBS J"); + expect(damengJobListSql(true)).toContain("SYSJOB.DBA_JOBS_RUNNING"); + }); + + it("builds simple sql job creation script", () => { + const sql = damengCreateJobSql({ + name: "JOB_TEST", + enabled: true, + description: "demo", + stepName: "STEP1", + command: "SELECT 1;", + scheduleName: "SCHEDULE1", + scheduleMode: "daily", + startDate: "CURDATE", + startTime: "00:00:00", + minuteInterval: 5, + }); + + expect(sql).toContain("SP_CREATE_JOB('JOB_TEST', 1"); + expect(sql).toContain("SP_ADD_JOB_STEP('JOB_TEST', 'STEP1', 0, 'SELECT 1;', 1, 1, 0, 0, NULL, 0);"); + expect(sql).toContain("SP_ADD_JOB_SCHEDULE('JOB_TEST', 'SCHEDULE1', 1, 1, 1, 0, 5, '00:00:00', NULL, CURDATE, NULL, '');"); + expect(sql).toContain("SP_JOB_CONFIG_COMMIT('JOB_TEST');"); + }); + + it("parses job rows case-insensitively", () => { + const result: QueryResult = { + columns: ["id", "name", "enable", "username", "valid", "describe", "running", "running_sid"], + rows: [[12, "JOB_TEST", 1, "SYSDBA", "Y", "demo", 1, 1234]], + affected_rows: 0, + execution_time_ms: 1, + }; + + expect(parseDamengJobs(result)).toEqual([ + { + id: "12", + name: "JOB_TEST", + enabled: true, + running: true, + runningSid: "1234", + owner: "SYSDBA", + valid: "Y", + createdAt: "", + modifiedAt: "", + description: "demo", + raw: { + id: 12, + name: "JOB_TEST", + enable: 1, + username: "SYSDBA", + valid: "Y", + describe: "demo", + running: 1, + running_sid: 1234, + }, + }, + ]); + }); + + it("parses job environment availability", () => { + expect(parseDamengJobEnvironmentReady({ columns: ["CNT"], rows: [[1]], affected_rows: 0, execution_time_ms: 1 })).toBe(true); + expect(parseDamengJobEnvironmentReady({ columns: ["CNT"], rows: [[0]], affected_rows: 0, execution_time_ms: 1 })).toBe(false); + }); + + it("detects missing SYSJOB schema errors", () => { + expect(isDamengJobEnvironmentMissingError("无效的模式名[SYSJOB]")).toBe(true); + expect(isDamengJobEnvironmentMissingError("invalid schema name SYSJOB")).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 2987a001cb..d9e3ddfbcc 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -662,6 +662,7 @@ export async function executeMulti( resultSessionId?: string; clientSessionId?: string; timeoutSecs?: number; + useTransaction?: boolean; }, ): Promise { return post("/api/query/execute-multi", { connectionId, database, sql, schema, executionId, ...options }); diff --git a/apps/desktop/src/lib/database/damengJobAdmin.ts b/apps/desktop/src/lib/database/damengJobAdmin.ts new file mode 100644 index 0000000000..3bddcc2537 --- /dev/null +++ b/apps/desktop/src/lib/database/damengJobAdmin.ts @@ -0,0 +1,176 @@ +import type { QueryResult } from "@/types/database"; + +export interface DamengJob { + id: string; + name: string; + enabled: boolean; + running: boolean; + runningSid?: string; + valid?: string; + owner?: string; + createdAt?: string; + modifiedAt?: string; + description?: string; + raw: Record; +} + +export interface DamengJobCreateInput { + name: string; + enabled: boolean; + description?: string; + stepName: string; + command: string; + scheduleName: string; + scheduleMode: "once" | "daily"; + startDate: string; + startTime: string; + endTime?: string; + minuteInterval?: number; +} + +export function damengJobListSql(useSystemTables = false): string { + if (!useSystemTables) { + return `SELECT J.ID, J.NAME, J.ENABLE, J.USERNAME, J.CREATETIME, J.MODIFYTIME, J.VALID, J.DESCRIBE, + 0 AS RUNNING, + NULL AS RUNNING_SID +FROM SYSJOB.USER_JOBS_VIEW J +ORDER BY J.NAME`; + } + + return `SELECT J.ID, J.NAME, J.ENABLE, J.USERNAME, J.CREATETIME, J.MODIFYTIME, J.VALID, J.DESCRIBE, + CASE WHEN R.JOB IS NULL THEN 0 ELSE 1 END AS RUNNING, + R.SID AS RUNNING_SID +FROM SYSJOB.SYSJOBS J +LEFT JOIN SYSJOB.DBA_JOBS_RUNNING R ON R.JOB = J.ID +ORDER BY J.NAME`; +} + +export const DAMENG_JOB_ENVIRONMENT_SQL = `SELECT COUNT(*) AS CNT +FROM ALL_OBJECTS +WHERE OWNER = 'SYSJOB' + AND OBJECT_NAME = 'USER_JOBS_VIEW'`; + +export function damengJobStepsSql(jobId: string | number, useSystemTables = false): string { + return `SELECT * FROM ${useSystemTables ? "SYSJOB.SYSJOBSTEPS" : "SYSJOB.USER_JOBSTEPS_VIEW"} WHERE JOBID = ${quoteDamengNumber(jobId)}`; +} + +export function damengJobSchedulesSql(jobId: string | number, useSystemTables = false): string { + return `SELECT * FROM ${useSystemTables ? "SYSJOB.SYSJOBSCHEDULES" : "SYSJOB.USER_JOBSCHEDULES_VIEW"} WHERE JOBID = ${quoteDamengNumber(jobId)}`; +} + +export function damengJobHistoriesSql(job: { id: string | number; name: string }, useSystemTables = false): string { + if (useSystemTables) { + return `SELECT * FROM SYSJOB.SYSJOBHISTORIES2 WHERE JOBID = ${quoteDamengNumber(job.id)}`; + } + return `SELECT * FROM SYSJOB.USER_JOBHISTORIES_VIEW WHERE JOBID = ${quoteDamengNumber(job.id)}`; +} + +export function damengInitJobSystemSql(): string { + return "SP_INIT_JOB_SYS(1);"; +} + +export function damengEnableJobSql(jobName: string, enabled: boolean): string { + return `SP_ENABLE_JOB(${quoteDamengString(jobName)}, ${enabled ? 1 : 0});`; +} + +export function damengDropJobSql(jobName: string): string { + return `SP_DROP_JOB(${quoteDamengString(jobName)});`; +} + +export function damengRunJobSql(jobId: string | number): string { + const id = Number(jobId); + if (!Number.isFinite(id)) return ""; + return `SP_DBMS_JOB_RUN_ASYNC(${Math.trunc(id)});`; +} + +export function damengStopJobSql(jobId: string | number): string { + const id = Number(jobId); + if (!Number.isFinite(id)) return ""; + return `SP_STOP_RUNNING_JOB(${Math.trunc(id)});`; +} + +export function damengClearJobHistoriesSql(jobName: string): string { + return `SP_JOB_CLEAR_HISTORIES(${quoteDamengString(jobName)});`; +} + +export function damengCreateJobSql(input: DamengJobCreateInput): string { + const name = input.name.trim(); + const stepName = input.stepName.trim() || "STEP1"; + const scheduleName = input.scheduleName.trim() || "SCHEDULE1"; + const startDate = input.startDate.trim() || "CURDATE"; + const startTime = input.startTime.trim() || "CURTIME"; + const endTime = input.endTime?.trim(); + const interval = Math.max(0, Math.min(1439, Math.trunc(input.minuteInterval ?? 0))); + const scheduleType = input.scheduleMode === "once" ? 0 : 1; + const freqInterval = input.scheduleMode === "once" ? 0 : 1; + const freqMinuteInterval = input.scheduleMode === "once" ? 0 : interval; + const startTimeSql = input.scheduleMode === "once" ? "NULL" : quoteDamengString(startTime); + const endTimeSql = endTime ? quoteDamengString(endTime) : "NULL"; + const startDateSql = startDate.toUpperCase() === "CURDATE" ? "CURDATE" : quoteDamengString(startDate); + + return [ + `SP_CREATE_JOB(${quoteDamengString(name)}, ${input.enabled ? 1 : 0}, 0, '', 0, 0, '', 0, ${quoteDamengString(input.description ?? "")});`, + `SP_JOB_CONFIG_START(${quoteDamengString(name)});`, + `SP_ADD_JOB_STEP(${quoteDamengString(name)}, ${quoteDamengString(stepName)}, 0, ${quoteDamengString(input.command)}, 1, 1, 0, 0, NULL, 0);`, + `SP_ADD_JOB_SCHEDULE(${quoteDamengString(name)}, ${quoteDamengString(scheduleName)}, 1, ${scheduleType}, ${freqInterval}, 0, ${freqMinuteInterval}, ${startTimeSql}, ${endTimeSql}, ${startDateSql}, NULL, '');`, + `SP_JOB_CONFIG_COMMIT(${quoteDamengString(name)});`, + ].join("\n"); +} + +export function parseDamengJobs(result: QueryResult): DamengJob[] { + return result.rows.map((row) => { + const raw = rowToObject(result.columns, row); + const name = valueAt(raw, "NAME"); + const id = valueAt(raw, "ID"); + return { + id: id || name, + name, + enabled: valueAt(raw, "ENABLE") === "1", + running: valueAt(raw, "RUNNING") === "1", + runningSid: valueAt(raw, "RUNNING_SID"), + valid: valueAt(raw, "VALID"), + owner: valueAt(raw, "USERNAME"), + createdAt: valueAt(raw, "CREATETIME"), + modifiedAt: valueAt(raw, "MODIFYTIME"), + description: valueAt(raw, "DESCRIBE"), + raw, + }; + }); +} + +export function parseDamengJobEnvironmentReady(result: QueryResult): boolean { + const value = result.rows[0]?.[0]; + return Number(value) > 0; +} + +export function isDamengJobEnvironmentMissingError(message: string): boolean { + return /SYSJOB/i.test(message) && (/无效的模式名/.test(message) || /invalid schema/i.test(message)); +} + +export function queryResultToObjects(result: QueryResult): Record[] { + return result.rows.map((row) => rowToObject(result.columns, row)); +} + +export function quoteDamengString(value: string): string { + return `'${value.replace(/'/g, "''")}'`; +} + +function quoteDamengNumber(value: string | number): string { + const id = Number(value); + if (!Number.isFinite(id)) return "NULL"; + return String(Math.trunc(id)); +} + +function rowToObject(columns: string[], row: QueryResult["rows"][number]): Record { + const object: Record = {}; + columns.forEach((column, index) => { + object[column] = row[index] ?? null; + }); + return object; +} + +function valueAt(row: Record, column: string): string { + const key = Object.keys(row).find((candidate) => candidate.toLowerCase() === column.toLowerCase()); + const value = key ? row[key] : undefined; + return value == null ? "" : String(value); +} diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 3e12aae58f..56cd58aae5 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -878,7 +878,7 @@ export const useConnectionStore = defineStore("connection", () => { } function isConnectionUtilityNode(node: TreeNode): boolean { - return node.type === "user-admin"; + return node.type === "user-admin" || node.type === "dameng-job-admin"; } function connectionMetadataChildren(children: TreeNode[] | undefined): TreeNode[] { @@ -939,10 +939,25 @@ export const useConnectionStore = defineStore("connection", () => { }; } + function buildDamengJobAdminNode(connectionId: string, existingConnectionNode?: TreeNode): TreeNode | undefined { + const config = getConfig(connectionId); + if (effectiveDatabaseTypeForConnection(config) !== "dameng") return undefined; + const existing = existingConnectionNode?.children?.find((child) => child.type === "dameng-job-admin"); + return { + id: `${connectionId}:__dameng_jobs`, + label: "tree.damengJobAdmin", + type: "dameng-job-admin", + connectionId, + database: "", + isExpanded: existing?.isExpanded ?? false, + }; + } + function withConnectionUtilityNodes(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] { const nonUtilityChildren = connectionMetadataChildren(children); const userAdminNode = buildUserAdminNode(connectionId, existingConnectionNode); - return [...nonUtilityChildren, userAdminNode].filter(Boolean) as TreeNode[]; + const damengJobAdminNode = buildDamengJobAdminNode(connectionId, existingConnectionNode); + return [...nonUtilityChildren, userAdminNode, damengJobAdminNode].filter(Boolean) as TreeNode[]; } function withSavedSqlRoot(connectionId: string, children: TreeNode[], existingConnectionNode?: TreeNode): TreeNode[] { diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index eb20601dea..fcd7f430db 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -778,6 +778,31 @@ export const useQueryStore = defineStore("query", () => { return id; } + function openDamengJobAdmin(connectionId: string) { + const existing = tabs.value.find((tab) => tab.mode === "dameng-jobs" && tab.connectionId === connectionId); + if (existing) { + activeTabId.value = existing.id; + return existing.id; + } + + const conn = useConnectionStore().getConfig(connectionId); + const id = uuid(); + const tab: QueryTab = { + id, + title: t("damengJobAdmin.title"), + connectionId, + database: conn?.database || "", + sql: "", + isExecuting: false, + isCancelling: false, + isExplaining: false, + mode: "dameng-jobs", + }; + tabs.value.push(tab); + activeTabId.value = id; + return id; + } + function openMongoBucket(connectionId: string, database: string, bucketName: string) { const title = `${database}.${bucketName}`; const existing = tabs.value.find((tab) => tab.mode === "mongo-bucket" && tab.connectionId === connectionId && tab.database === database && tab.mongoBucket?.bucketName === bucketName); @@ -3059,6 +3084,7 @@ export const useQueryStore = defineStore("query", () => { openMongoGridFs, openMongoBucket, openUserAdmin, + openDamengJobAdmin, openMqAdmin, openNacosAdmin, openTableStructure, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 9c4d9a82fe..e6147a6c3e 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -542,6 +542,7 @@ export type TreeNodeType = | "extension" | "object-browser" | "user-admin" + | "dameng-job-admin" | "saved-sql-root" | "saved-sql-folder" | "saved-sql-file" @@ -690,7 +691,7 @@ export interface QueryTab { executionId?: string; isExplaining?: boolean; explainExecutionId?: string; - mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users"; + mode: "data" | "query" | "redis" | "redis-dashboard" | "mongo" | "mongo-gridfs" | "mongo-bucket" | "vector" | "etcd" | "zookeeper" | "mq" | "nacos" | "objects" | "structure" | "users" | "dameng-jobs"; mqTenant?: string; mqInitialTab?: "topics"; nacosNamespace?: string; diff --git a/crates/dbx-core/src/db/duckdb_worker_process.rs b/crates/dbx-core/src/db/duckdb_worker_process.rs index 796c41b8b1..ca2f0fc0af 100644 --- a/crates/dbx-core/src/db/duckdb_worker_process.rs +++ b/crates/dbx-core/src/db/duckdb_worker_process.rs @@ -547,6 +547,7 @@ fn is_transient_duckdb_file_lock_error(message: &str) -> bool { || lower.contains("file is already open"); let mentions_lock = lower.contains("file is already open") || lower.contains("being used by another process") + || lower.contains("conflicting lock is held") || lower.contains("process cannot access the file") || lower.contains("sharing violation") || lower.contains("resource temporarily unavailable") diff --git a/crates/dbx-core/tests/duckdb_worker_process.rs b/crates/dbx-core/tests/duckdb_worker_process.rs index 7995556ef1..5836c4fdac 100644 --- a/crates/dbx-core/tests/duckdb_worker_process.rs +++ b/crates/dbx-core/tests/duckdb_worker_process.rs @@ -3,6 +3,7 @@ use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::OnceLock; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use dbx_core::db::duckdb_worker_process::DuckDbWorkerClient; @@ -19,6 +20,7 @@ static TEMP_DB_COUNTER: AtomicU64 = AtomicU64::new(0); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn worker_process_recovers_immediately_after_cancelled_long_query() { + let _guard = duckdb_worker_process_test_guard().await; let executable = PathBuf::from(env!("CARGO_BIN_EXE_duckdb-worker-test-host")); let db_path = temp_duckdb_path(); let _ = std::fs::remove_file(&db_path); @@ -69,6 +71,7 @@ async fn worker_process_recovers_immediately_after_cancelled_long_query() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn worker_process_recovers_after_registered_cancel_interrupt() { + let _guard = duckdb_worker_process_test_guard().await; let executable = PathBuf::from(env!("CARGO_BIN_EXE_duckdb-worker-test-host")); let db_path = temp_duckdb_path(); let _ = std::fs::remove_file(&db_path); @@ -131,6 +134,7 @@ async fn worker_process_recovers_after_registered_cancel_interrupt() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn worker_process_recovers_after_parser_error() { + let _guard = duckdb_worker_process_test_guard().await; let executable = PathBuf::from(env!("CARGO_BIN_EXE_duckdb-worker-test-host")); let db_path = temp_duckdb_path(); let _ = std::fs::remove_file(&db_path); @@ -180,6 +184,7 @@ async fn worker_process_recovers_after_parser_error() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn worker_process_keeps_session_state_after_benign_error() { + let _guard = duckdb_worker_process_test_guard().await; let executable = PathBuf::from(env!("CARGO_BIN_EXE_duckdb-worker-test-host")); let db_path = temp_duckdb_path(); let _ = std::fs::remove_file(&db_path); @@ -227,6 +232,7 @@ async fn worker_process_keeps_session_state_after_benign_error() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn worker_process_exits_when_stdin_closes_during_active_query() { + let _guard = duckdb_worker_process_test_guard().await; let executable = PathBuf::from(env!("CARGO_BIN_EXE_duckdb-worker-test-host")); let db_path = temp_duckdb_path(); let _ = std::fs::remove_file(&db_path); @@ -280,6 +286,7 @@ async fn worker_process_exits_when_stdin_closes_during_active_query() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn worker_process_is_killed_after_connect_timeout() { + let _guard = duckdb_worker_process_test_guard().await; let executable = PathBuf::from(env!("CARGO_BIN_EXE_duckdb-worker-hanging-connect-test-host")); let db_path = temp_duckdb_path(); let pid_file = temp_pid_path(); @@ -292,7 +299,7 @@ async fn worker_process_is_killed_after_connect_timeout() { db_path.to_string_lossy().to_string(), Vec::new(), 4, - Duration::from_millis(200), + Duration::from_secs(1), Duration::from_secs(5), ); @@ -312,6 +319,7 @@ async fn worker_process_is_killed_after_connect_timeout() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn worker_process_is_killed_after_connect_error() { + let _guard = duckdb_worker_process_test_guard().await; let executable = PathBuf::from(env!("CARGO_BIN_EXE_duckdb-worker-pid-test-host")); let db_path = temp_duckdb_path(); let pid_file = temp_pid_path(); @@ -336,7 +344,12 @@ async fn worker_process_is_killed_after_connect_error() { .expect_err("connect should fail while another process owns the DuckDB file"); std::env::remove_var("DBX_DUCKDB_PID_TEST_HOST_PID_FILE"); assert!( - err.contains("Cannot open file") || err.contains("already open") || err.contains("另一个程序正在使用"), + err.contains("Cannot open file") + || err.contains("Could not set lock") + || err.contains("Conflicting lock is held") + || err.contains("already open") + || err.contains("timed out") + || err.contains("另一个程序正在使用"), "unexpected error: {err}" ); @@ -350,6 +363,7 @@ async fn worker_process_is_killed_after_connect_error() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn worker_process_retries_connect_after_transient_file_lock() { + let _guard = duckdb_worker_process_test_guard().await; let executable = PathBuf::from(env!("CARGO_BIN_EXE_duckdb-worker-test-host")); let db_path = temp_duckdb_path(); let _ = std::fs::remove_file(&db_path); @@ -435,6 +449,11 @@ fn temp_duckdb_path() -> PathBuf { std::env::temp_dir().join(format!("dbx-duckdb-worker-process-{pid}-{suffix}-{counter}.duckdb")) } +async fn duckdb_worker_process_test_guard() -> tokio::sync::MutexGuard<'static, ()> { + static GUARD: OnceLock> = OnceLock::new(); + GUARD.get_or_init(|| tokio::sync::Mutex::new(())).lock().await +} + fn temp_pid_path() -> PathBuf { let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos(); let pid = std::process::id(); diff --git a/crates/dbx-web/src/routes/query.rs b/crates/dbx-web/src/routes/query.rs index d05a3989e1..44ddb7cd16 100644 --- a/crates/dbx-web/src/routes/query.rs +++ b/crates/dbx-web/src/routes/query.rs @@ -22,6 +22,7 @@ pub struct ExecuteQueryRequest { pub result_session_id: Option, pub client_session_id: Option, pub timeout_secs: Option, + pub use_transaction: Option, } #[derive(Deserialize)] @@ -315,6 +316,7 @@ pub async fn execute_query( client_session_id: req.client_session_id, timeout_secs: req.timeout_secs, execution_id: Some(execution_id), + use_transaction: req.use_transaction, ..Default::default() }, ) @@ -352,6 +354,7 @@ pub async fn execute_multi( client_session_id: req.client_session_id, timeout_secs: req.timeout_secs, execution_id: Some(execution_id), + use_transaction: req.use_transaction, ..Default::default() }, )
{{ t("damengJobAdmin.environmentMissingDescription") }}
{{ pendingSql }}