feat(dameng): support proxy job administration

* Feature: add Dameng agent job management

* fix(duckdb): stabilize worker process tests

* fix(dameng): address job admin review

---------

Co-authored-by: zipg <4047349+zipg@users.noreply.github.com>
This commit is contained in:
zipgandzipg authored and GitHub committed 2026-07-09 00:39:06 +08:00
1 parent a07f115407
commit 47d9257ac2
20 files changed
+1189 -7

No files matched your search

@@ -0,0 +1,471 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { AlertTriangle, CalendarClock, DatabaseZap, Loader2, Play, Plus, Power, RefreshCcw, Square, Trash2, XCircle } from "@lucide/vue";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { useConnectionStore } from "@/stores/connectionStore";
import { useToast } from "@/composables/useToast";
import type { ConnectionConfig } from "@/types/database";
import * as api from "@/lib/backend/api";
import {
DAMENG_JOB_ENVIRONMENT_SQL,
damengClearJobHistoriesSql,
damengCreateJobSql,
damengDropJobSql,
damengEnableJobSql,
damengInitJobSystemSql,
damengJobHistoriesSql,
damengJobListSql,
damengJobSchedulesSql,
damengJobStepsSql,
damengRunJobSql,
damengStopJobSql,
isDamengJobEnvironmentMissingError,
parseDamengJobEnvironmentReady,
parseDamengJobs,
queryResultToObjects,
type DamengJob,
} from "@/lib/database/damengJobAdmin";
const props = defineProps<{
connection: ConnectionConfig;
}>();
type DetailTab = "steps" | "schedules" | "histories";
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
const jobs = ref<DamengJob[]>([]);
const selectedJobName = ref("");
const search = ref("");
const loadingJobs = ref(false);
const loadingDetails = ref(false);
const applying = ref(false);
const jobEnvironmentReady = ref(true);
const loadError = ref("");
const detailError = ref("");
const detailTab = ref<DetailTab>("steps");
const detailRows = ref<Record<string, unknown>[]>([]);
const createDialogOpen = ref(false);
const previewDialogOpen = ref(false);
const pendingSql = ref("");
const pendingAfterApply = ref<(() => Promise<void>) | undefined>();
const pendingDanger = ref(false);
const pendingUseTransaction = ref(false);
const createName = ref("JOB_TEST");
const createEnabled = ref(true);
const createDescription = ref("");
const createStepName = ref("STEP1");
const createCommand = ref("SELECT 1;");
const createScheduleName = ref("SCHEDULE1");
const createScheduleMode = ref<"once" | "daily">("daily");
const createStartDate = ref("CURDATE");
const createStartTime = ref("00:00:00");
const createEndTime = ref("");
const createMinuteInterval = ref(0);
const supported = computed(() => props.connection.db_type === "dameng");
const executionDatabase = computed(() => (props.connection.db_type === "dameng" ? "" : props.connection.database || ""));
const useSystemJobTables = computed(() => props.connection.username?.trim().toUpperCase() === "SYSDBA");
const selectedJob = computed(() => jobs.value.find((job) => job.name === selectedJobName.value));
const filteredJobs = computed(() => {
const query = search.value.trim().toLowerCase();
if (!query) return jobs.value;
return jobs.value.filter((job) => `${job.name} ${job.owner || ""} ${job.description || ""}`.toLowerCase().includes(query));
});
const detailColumns = computed(() => {
const set = new Set<string>();
for (const row of detailRows.value) {
Object.keys(row).forEach((key) => set.add(key));
}
return Array.from(set);
});
const canCreateJob = computed(() => createName.value.trim() && createStepName.value.trim() && createScheduleName.value.trim() && createCommand.value.trim());
async function ensureConnection() {
await connectionStore.ensureConnected(props.connection.id);
}
async function loadJobs() {
if (!supported.value) return;
loadingJobs.value = true;
loadError.value = "";
try {
await ensureConnection();
const environment = await api.executeQuery(props.connection.id, executionDatabase.value, DAMENG_JOB_ENVIRONMENT_SQL, undefined, undefined, {
maxRows: 1,
});
jobEnvironmentReady.value = parseDamengJobEnvironmentReady(environment);
if (!jobEnvironmentReady.value) {
jobs.value = [];
detailRows.value = [];
selectedJobName.value = "";
return;
}
const result = await api.executeQuery(props.connection.id, executionDatabase.value, damengJobListSql(useSystemJobTables.value), undefined, undefined, {
maxRows: 5000,
});
jobs.value = parseDamengJobs(result);
if (!selectedJob.value) selectedJobName.value = jobs.value[0]?.name || "";
await loadDetails();
} catch (error: any) {
const message = error?.message || String(error);
if (isDamengJobEnvironmentMissingError(message)) {
jobEnvironmentReady.value = false;
loadError.value = "";
} else {
loadError.value = message;
}
jobs.value = [];
detailRows.value = [];
} finally {
loadingJobs.value = false;
}
}
async function loadDetails() {
const job = selectedJob.value;
if (!job) {
detailRows.value = [];
return;
}
loadingDetails.value = true;
detailError.value = "";
try {
const sql = detailTab.value === "steps" ? damengJobStepsSql(job.id, useSystemJobTables.value) : detailTab.value === "schedules" ? damengJobSchedulesSql(job.id, useSystemJobTables.value) : damengJobHistoriesSql(job, useSystemJobTables.value);
const result = await api.executeQuery(props.connection.id, executionDatabase.value, sql, undefined, undefined, {
maxRows: 1000,
});
detailRows.value = queryResultToObjects(result);
} catch (error: any) {
detailError.value = error?.message || String(error);
detailRows.value = [];
} finally {
loadingDetails.value = false;
}
}
function selectJob(job: DamengJob) {
selectedJobName.value = job.name;
}
function previewSql(sql: string, options: { danger?: boolean; afterApply?: () => Promise<void>; useTransaction?: boolean } = {}) {
pendingSql.value = sql;
pendingDanger.value = !!options.danger;
pendingUseTransaction.value = !!options.useTransaction;
pendingAfterApply.value = options.afterApply;
previewDialogOpen.value = true;
}
async function applyPendingSql() {
if (!pendingSql.value.trim()) return;
applying.value = true;
try {
await ensureConnection();
await api.executeMulti(props.connection.id, executionDatabase.value, pendingSql.value, undefined, undefined, {
maxRows: 1000,
useTransaction: pendingUseTransaction.value,
});
toast(t("damengJobAdmin.applySuccess"), 2500);
previewDialogOpen.value = false;
await (pendingAfterApply.value?.() ?? Promise.resolve());
await loadJobs();
} catch (error: any) {
toast(t("damengJobAdmin.applyFailed", { message: error?.message || String(error) }), 5000);
} finally {
applying.value = false;
}
}
function previewCreateEnvironment() {
previewSql(damengInitJobSystemSql());
}
function previewCreateJob() {
if (!canCreateJob.value) return;
previewSql(
damengCreateJobSql({
name: createName.value,
enabled: createEnabled.value,
description: createDescription.value,
stepName: createStepName.value,
command: createCommand.value,
scheduleName: createScheduleName.value,
scheduleMode: createScheduleMode.value,
startDate: createStartDate.value,
startTime: createStartTime.value,
endTime: createEndTime.value,
minuteInterval: createMinuteInterval.value,
}),
{
afterApply: async () => {
createDialogOpen.value = false;
},
useTransaction: true,
},
);
}
function previewEnableJob(enabled: boolean) {
const job = selectedJob.value;
if (!job) return;
previewSql(damengEnableJobSql(job.name, enabled));
}
function previewDropJob() {
const job = selectedJob.value;
if (!job) return;
previewSql(damengDropJobSql(job.name), { danger: true });
}
function previewRunJob() {
const job = selectedJob.value;
if (!job) return;
const sql = damengRunJobSql(job.id);
if (sql) previewSql(sql);
}
function previewStopJob() {
const job = selectedJob.value;
if (!job) return;
const sql = damengStopJobSql(job.id);
if (sql) previewSql(sql, { danger: true });
}
function previewClearHistories() {
const job = selectedJob.value;
if (!job) return;
previewSql(damengClearJobHistoriesSql(job.name), { danger: true });
}
function cellText(value: unknown): string {
return value == null ? "" : String(value);
}
watch(selectedJobName, () => void loadDetails());
watch(detailTab, () => void loadDetails());
onMounted(() => void loadJobs());
</script>
<template>
<div class="flex h-full min-h-0 flex-col bg-background">
<div class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
<CalendarClock class="h-4 w-4 text-primary" />
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold">{{ t("damengJobAdmin.title") }}</div>
<div class="truncate text-[11px] text-muted-foreground">{{ connection.name }}</div>
</div>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="loadingJobs" @click="loadJobs">
<Loader2 v-if="loadingJobs" class="h-3.5 w-3.5 animate-spin" />
<RefreshCcw v-else class="h-3.5 w-3.5" />
{{ t("contextMenu.refreshChildren") }}
</Button>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="jobEnvironmentReady" :title="jobEnvironmentReady ? t('damengJobAdmin.environmentReady') : t('damengJobAdmin.initEnvironment')" @click="previewCreateEnvironment">
<DatabaseZap class="h-3.5 w-3.5" />
{{ jobEnvironmentReady ? t("damengJobAdmin.environmentReady") : t("damengJobAdmin.initEnvironment") }}
</Button>
<Button size="sm" class="h-8 gap-1.5" @click="createDialogOpen = true">
<Plus class="h-3.5 w-3.5" />
{{ t("damengJobAdmin.newJob") }}
</Button>
</div>
<div v-if="!supported" class="m-4 rounded border border-dashed p-4 text-sm text-muted-foreground">
{{ t("damengJobAdmin.unsupported") }}
</div>
<div v-else class="grid min-h-0 flex-1 grid-cols-[300px_minmax(0,1fr)]">
<aside class="flex min-h-0 flex-col border-r">
<div class="border-b p-2">
<Input v-model="search" class="h-8 text-xs" :placeholder="t('damengJobAdmin.searchJob')" />
</div>
<div v-if="loadError" class="m-2 rounded border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
<div class="mb-1 flex items-center gap-1 font-medium"><AlertTriangle class="h-3.5 w-3.5" />{{ t("damengJobAdmin.loadFailed") }}</div>
<div class="break-all">{{ loadError }}</div>
</div>
<div v-else-if="!jobEnvironmentReady" class="m-2 rounded border border-amber-500/30 bg-amber-500/5 p-2 text-xs text-amber-700 dark:text-amber-300">
<div class="mb-1 flex items-center gap-1 font-medium"><AlertTriangle class="h-3.5 w-3.5" />{{ t("damengJobAdmin.environmentMissingTitle") }}</div>
<div>{{ t("damengJobAdmin.environmentMissingHint") }}</div>
</div>
<div class="min-h-0 flex-1 overflow-auto p-2">
<button v-for="job in filteredJobs" :key="job.name" type="button" class="mb-1 w-full rounded border px-2 py-2 text-left text-xs transition hover:bg-accent" :class="selectedJobName === job.name ? 'border-primary bg-primary/10' : 'border-transparent'" @click="selectJob(job)">
<div class="flex items-center gap-2">
<span class="min-w-0 flex-1 truncate font-medium">{{ job.name }}</span>
<Badge v-if="job.running" variant="outline" class="h-5 border-amber-500/50 px-1.5 text-[10px] text-amber-700 dark:text-amber-300">{{ t("damengJobAdmin.running") }}</Badge>
<Badge :variant="job.enabled ? 'default' : 'secondary'" class="h-5 px-1.5 text-[10px]">{{ job.enabled ? t("damengJobAdmin.enabled") : t("damengJobAdmin.disabled") }}</Badge>
</div>
<div class="mt-1 truncate text-[11px] text-muted-foreground">{{ job.owner || "-" }} · {{ job.valid || "-" }}</div>
</button>
<div v-if="!loadingJobs && filteredJobs.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("damengJobAdmin.emptyJobs") }}
</div>
</div>
</aside>
<main class="flex min-h-0 flex-col">
<div v-if="selectedJob" class="flex shrink-0 flex-wrap items-center gap-2 border-b p-3">
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-semibold">{{ selectedJob.name }}</div>
<div class="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
<Badge v-if="selectedJob.running" variant="outline" class="h-5 border-amber-500/50 px-1.5 text-[10px] text-amber-700 dark:text-amber-300">{{ t("damengJobAdmin.running") }}</Badge>
<span class="truncate">{{ selectedJob.description || t("damengJobAdmin.noDescription") }}</span>
</div>
</div>
<Button size="sm" variant="outline" class="h-8 gap-1.5" @click="previewRunJob">
<Play class="h-3.5 w-3.5" />
{{ t("damengJobAdmin.runAsync") }}
</Button>
<Button size="sm" variant="outline" class="h-8 gap-1.5" :disabled="!selectedJob.running" :title="selectedJob.running ? t('damengJobAdmin.stopRunning') : t('damengJobAdmin.stopRunningDisabled')" @click="previewStopJob">
<Square class="h-3.5 w-3.5" />
{{ t("damengJobAdmin.stopRunning") }}
</Button>
<Button size="sm" variant="outline" class="h-8 gap-1.5" @click="previewEnableJob(!selectedJob.enabled)">
<Power class="h-3.5 w-3.5" />
{{ selectedJob.enabled ? t("damengJobAdmin.disable") : t("damengJobAdmin.enable") }}
</Button>
<Button size="sm" variant="outline" class="h-8 gap-1.5" @click="previewClearHistories">
<XCircle class="h-3.5 w-3.5" />
{{ t("damengJobAdmin.clearHistories") }}
</Button>
<Button size="sm" variant="destructive" class="h-8 gap-1.5" @click="previewDropJob">
<Trash2 class="h-3.5 w-3.5" />
{{ t("damengJobAdmin.dropJob") }}
</Button>
</div>
<div v-if="selectedJob" class="flex h-10 shrink-0 items-center gap-1 border-b px-3">
<Button size="sm" :variant="detailTab === 'steps' ? 'secondary' : 'ghost'" class="h-7 px-2 text-xs" @click="detailTab = 'steps'">{{ t("damengJobAdmin.steps") }}</Button>
<Button size="sm" :variant="detailTab === 'schedules' ? 'secondary' : 'ghost'" class="h-7 px-2 text-xs" @click="detailTab = 'schedules'">{{ t("damengJobAdmin.schedules") }}</Button>
<Button size="sm" :variant="detailTab === 'histories' ? 'secondary' : 'ghost'" class="h-7 px-2 text-xs" @click="detailTab = 'histories'">{{ t("damengJobAdmin.histories") }}</Button>
<Loader2 v-if="loadingDetails" class="ml-auto h-3.5 w-3.5 animate-spin text-muted-foreground" />
</div>
<div class="min-h-0 flex-1 overflow-auto p-3">
<div v-if="!jobEnvironmentReady" class="flex h-full items-center justify-center">
<div class="max-w-xl rounded-lg border border-dashed p-5 text-sm">
<div class="mb-2 flex items-center gap-2 font-semibold"><DatabaseZap class="h-4 w-4 text-primary" />{{ t("damengJobAdmin.environmentMissingTitle") }}</div>
<p class="mb-4 text-xs leading-5 text-muted-foreground">{{ t("damengJobAdmin.environmentMissingDescription") }}</p>
<Button size="sm" class="gap-1.5" @click="previewCreateEnvironment">
<DatabaseZap class="h-3.5 w-3.5" />
{{ t("damengJobAdmin.initEnvironment") }}
</Button>
</div>
</div>
<div v-else-if="!selectedJob" class="flex h-full items-center justify-center text-sm text-muted-foreground">
{{ t("damengJobAdmin.selectJob") }}
</div>
<div v-else-if="detailError" class="rounded border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{{ detailError }}
</div>
<table v-else class="w-full min-w-max border-collapse text-xs">
<thead>
<tr class="border-b bg-muted/40">
<th v-for="column in detailColumns" :key="column" class="px-2 py-2 text-left font-medium">{{ column }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, index) in detailRows" :key="index" class="border-b hover:bg-muted/30">
<td v-for="column in detailColumns" :key="column" class="max-w-[360px] px-2 py-2 align-top">
<span class="block truncate" :title="cellText(row[column])">{{ cellText(row[column]) || "-" }}</span>
</td>
</tr>
</tbody>
</table>
<div v-if="selectedJob && !loadingDetails && !detailError && detailRows.length === 0" class="p-8 text-center text-xs text-muted-foreground">
{{ t("damengJobAdmin.emptyDetails") }}
</div>
</div>
</main>
</div>
<Dialog v-model:open="createDialogOpen">
<DialogContent class="max-w-3xl">
<DialogHeader>
<DialogTitle>{{ t("damengJobAdmin.newJob") }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3 text-xs">
<div class="grid grid-cols-2 gap-3">
<label class="grid gap-1">
<span>{{ t("damengJobAdmin.jobName") }}</span>
<Input v-model="createName" class="h-8 text-xs" />
</label>
<label class="grid gap-1">
<span>{{ t("damengJobAdmin.description") }}</span>
<Input v-model="createDescription" class="h-8 text-xs" />
</label>
</div>
<label class="flex items-center gap-2">
<input v-model="createEnabled" type="checkbox" />
<span>{{ t("damengJobAdmin.enabled") }}</span>
</label>
<div class="grid grid-cols-2 gap-3">
<label class="grid gap-1">
<span>{{ t("damengJobAdmin.stepName") }}</span>
<Input v-model="createStepName" class="h-8 text-xs" />
</label>
<label class="grid gap-1">
<span>{{ t("damengJobAdmin.scheduleName") }}</span>
<Input v-model="createScheduleName" class="h-8 text-xs" />
</label>
</div>
<label class="grid gap-1">
<span>{{ t("damengJobAdmin.sqlCommand") }}</span>
<textarea v-model="createCommand" class="min-h-28 rounded-md border bg-background px-3 py-2 font-mono text-xs outline-none focus:ring-2 focus:ring-ring" />
</label>
<div class="grid grid-cols-5 gap-3">
<label class="grid gap-1">
<span>{{ t("damengJobAdmin.scheduleMode") }}</span>
<select v-model="createScheduleMode" class="h-8 rounded-md border bg-background px-2 text-xs">
<option value="daily">{{ t("damengJobAdmin.daily") }}</option>
<option value="once">{{ t("damengJobAdmin.once") }}</option>
</select>
</label>
<label class="grid gap-1">
<span>{{ t("damengJobAdmin.startDate") }}</span>
<Input v-model="createStartDate" class="h-8 text-xs" />
</label>
<label class="grid gap-1">
<span>{{ t("damengJobAdmin.startTime") }}</span>
<Input v-model="createStartTime" class="h-8 text-xs" />
</label>
<label class="grid gap-1">
<span>{{ t("damengJobAdmin.endTime") }}</span>
<Input v-model="createEndTime" class="h-8 text-xs" placeholder="23:59:59" />
</label>
<label class="grid gap-1">
<span>{{ t("damengJobAdmin.minuteInterval") }}</span>
<Input v-model.number="createMinuteInterval" type="number" min="0" max="1439" class="h-8 text-xs" />
</label>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="createDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button :disabled="!canCreateJob" @click="previewCreateJob">{{ t("damengJobAdmin.previewSql") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog v-model:open="previewDialogOpen">
<DialogContent class="max-w-3xl">
<DialogHeader>
<DialogTitle>{{ t("damengJobAdmin.sqlPreview") }}</DialogTitle>
</DialogHeader>
<pre class="max-h-[50vh] overflow-auto rounded-md border bg-muted/30 p-3 text-xs"><code>{{ pendingSql }}</code></pre>
<DialogFooter>
<Button variant="outline" @click="previewDialogOpen = false">{{ t("common.cancel") }}</Button>
<Button :variant="pendingDanger ? 'destructive' : 'default'" :disabled="applying" @click="applyPendingSql">
<Loader2 v-if="applying" class="mr-1 h-3.5 w-3.5 animate-spin" />
{{ t("damengJobAdmin.applySql") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
@@ -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
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
<input
@@ -744,6 +746,7 @@ function onOverflowItemKeydown(event: KeyboardEvent, tabId: string, kind: "regul
<Network v-else-if="tab.mode === 'nacos'" class="h-3.5 w-3.5" />
<TableProperties v-else-if="tab.mode === 'objects'" class="h-3.5 w-3.5" />
<PencilRuler v-else-if="tab.mode === 'structure'" class="h-3.5 w-3.5" />
<CalendarClock v-else-if="tab.mode === 'dameng-jobs'" class="h-3.5 w-3.5" />
<Code2 v-else class="h-3.5 w-3.5" />
</span>
<input
@@ -48,6 +48,7 @@ const NacosAdminConsole = defineAsyncComponent(() => 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
<template v-else-if="activeTab.mode === 'users' && activeConnection">
<DatabaseUserAdmin :key="activeTab.id" :connection="activeConnection" />
</template>
<template v-else-if="activeTab.mode === 'dameng-jobs' && activeConnection">
<DamengJobAdmin :key="activeTab.id" :connection="activeConnection" />
</template>
</div>
</template>
@@ -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) {
+49
View File
@@ -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",
+49
View File
@@ -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",
+49
View File
@@ -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",
+49
View File
@@ -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: "テーブル",
+49
View File
@@ -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",
+49
View File
@@ -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: "表",
+49
View File
@@ -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",
@@ -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);
});
});
+1
View File
@@ -662,6 +662,7 @@ export async function executeMulti(
resultSessionId?: string;
clientSessionId?: string;
timeoutSecs?: number;
useTransaction?: boolean;
},
): Promise<QueryResult[]> {
return post("/api/query/execute-multi", { connectionId, database, sql, schema, executionId, ...options });
@@ -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<string, unknown>;
}
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<string, unknown>[] {
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<string, unknown> {
const object: Record<string, unknown> = {};
columns.forEach((column, index) => {
object[column] = row[index] ?? null;
});
return object;
}
function valueAt(row: Record<string, unknown>, 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);
}
+17 -2
View File
@@ -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[] {
+26
View File
@@ -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,
+2 -1
View File
@@ -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;