feat(tabs): persist inactive tab UI state
This commit is contained in:
39 files changed
+1066
-192
No files matched your search
@@ -69,7 +69,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
|
||||
import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { externalSqlFileOpenErrorMessage, externalSqlEditorMaxBytes, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
|
||||
import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TreeNode } from "@/types/database";
|
||||
import type { ConnectionConfig, DatabaseType, ObjectBrowserFilter, ObjectSourceKind, QueryTab, TabOutputView, TreeNode } from "@/types/database";
|
||||
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
|
||||
import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink";
|
||||
import { activeDesktopAiRuns, blockingDesktopAiRunsForQuit } from "@/lib/ai/desktopAiRunRegistry";
|
||||
@@ -361,7 +361,13 @@ const cursorPos = ref(0);
|
||||
const previewChangesAvailable = ref(false);
|
||||
const formatSqlRequest = ref<{ id: number; tabId: string } | null>(null);
|
||||
const compressSqlRequest = ref<{ id: number; tabId: string } | null>(null);
|
||||
const activeOutputView = ref<"result" | "summary" | "explain" | "chart" | "messages" | "profile">("result");
|
||||
const activeOutputView = computed<TabOutputView>({
|
||||
get: () => activeTab.value?.uiState?.activeOutputView ?? "result",
|
||||
set: (view) => {
|
||||
const tab = activeTab.value;
|
||||
if (tab) queryStore.updateTabUiState(tab.id, { activeOutputView: view });
|
||||
},
|
||||
});
|
||||
const newQueryContextSource = ref<"tab" | "sidebar">("tab");
|
||||
const queryEditorDdlTarget = ref<{ connectionId: string; database: string; catalog?: string; schema?: string; tableName: string; objectType?: ObjectSourceKind } | null>(null);
|
||||
const queryEditorObjectSourceTarget = ref<{
|
||||
@@ -657,7 +663,7 @@ async function setupDetachedWindowEvents() {
|
||||
async function detachTab(tab: QueryTab, position?: { x: number; y: number }) {
|
||||
if (isDetachedWindowContext || !isDetachableTab(tab)) return;
|
||||
try {
|
||||
const handoff = await queryStore.prepareDetachedTab(tab.id, { activeOutputView: tab.id === queryStore.activeTabId ? activeOutputView.value : "result" });
|
||||
const handoff = await queryStore.prepareDetachedTab(tab.id, { activeOutputView: tab.id === queryStore.activeTabId ? activeOutputView.value : (tab.uiState?.activeOutputView ?? "result") });
|
||||
await api.saveDetachedTabHandoff(tab.id, handoff);
|
||||
const result = await openDetachedTabWindow(tab.id, tab.title, position);
|
||||
if (!result.opened) {
|
||||
@@ -1249,8 +1255,10 @@ watch(
|
||||
if (id) newQueryContextSource.value = "tab";
|
||||
if (id) activateQuerySurface();
|
||||
else if (previousId) activateOpenSpecialPageFallback();
|
||||
selectedSql.value = "";
|
||||
activeOutputView.value = "result";
|
||||
const tab = id ? queryStore.tabs.find((candidate) => candidate.id === id) : undefined;
|
||||
const selection = tab?.editorSelection;
|
||||
selectedSql.value = tab && selection && selection.anchor !== selection.head ? tab.sql.slice(Math.min(selection.anchor, selection.head), Math.max(selection.anchor, selection.head)) : "";
|
||||
cursorPos.value = selection?.head ?? 0;
|
||||
if (id) queryStore.reloadEvictedTab(id);
|
||||
},
|
||||
);
|
||||
@@ -3644,8 +3652,8 @@ onUnmounted(() => {
|
||||
:block-dangerous-redis-commands="blockDangerousRedisCommands"
|
||||
:zen-mode="isZenMode"
|
||||
@update:active-output-view="
|
||||
(tabId: string, view: 'result' | 'summary' | 'explain' | 'chart' | 'messages' | 'profile') => {
|
||||
if (tabId === queryStore.activeTabId) activeOutputView = view;
|
||||
(tabId: string, view: TabOutputView) => {
|
||||
if (tabId === queryStore.activeTabId) queryStore.updateTabUiState(tabId, { activeOutputView: view });
|
||||
}
|
||||
"
|
||||
@preview-changes-available="
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
queryResultToObjects,
|
||||
type DamengJob,
|
||||
} from "@/lib/database/damengJobAdmin";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
@@ -40,17 +41,18 @@ type DetailTab = "steps" | "schedules" | "histories";
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ selectedJobName?: string; search?: string; detailTab?: DetailTab }>({}, "DamengJobAdmin");
|
||||
|
||||
const jobs = ref<DamengJob[]>([]);
|
||||
const selectedJobName = ref("");
|
||||
const search = ref("");
|
||||
const selectedJobName = ref(restoredUiState.selectedJobName ?? "");
|
||||
const search = ref(restoredUiState.search ?? "");
|
||||
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 detailTab = ref<DetailTab>(["steps", "schedules", "histories"].includes(restoredUiState.detailTab ?? "") ? restoredUiState.detailTab! : "steps");
|
||||
const detailRows = ref<Record<string, unknown>[]>([]);
|
||||
|
||||
const createDialogOpen = ref(false);
|
||||
@@ -72,6 +74,8 @@ const createStartTime = ref("00:00:00");
|
||||
const createEndTime = ref("");
|
||||
const createMinuteInterval = ref(0);
|
||||
|
||||
trackUiState(() => ({ selectedJobName: selectedJobName.value, search: search.value, detailTab: detailTab.value }));
|
||||
|
||||
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");
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type DamengSysPrivilege,
|
||||
type DamengUser,
|
||||
} from "@/lib/database/damengPrincipalAdmin";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
@@ -52,17 +53,18 @@ type DetailTab = "members" | "privileges";
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ selectedRoleName?: string; search?: string; detailTab?: DetailTab }>({}, "DamengRoleAdmin");
|
||||
|
||||
const roles = ref<DamengRole[]>([]);
|
||||
const allUsers = ref<DamengUser[]>([]);
|
||||
const selectedRoleName = ref("");
|
||||
const search = ref("");
|
||||
const selectedRoleName = ref(restoredUiState.selectedRoleName ?? "");
|
||||
const search = ref(restoredUiState.search ?? "");
|
||||
const loadingRoles = ref(false);
|
||||
const loadingDetails = ref(false);
|
||||
const applying = ref(false);
|
||||
const loadError = ref("");
|
||||
const detailError = ref("");
|
||||
const detailTab = ref<DetailTab>("members");
|
||||
const detailTab = ref<DetailTab>(restoredUiState.detailTab === "privileges" ? "privileges" : "members");
|
||||
const members = ref<DamengGrant[]>([]);
|
||||
const grantedPrivileges = ref<DamengSysPrivilege[]>([]);
|
||||
const systemPrivilegeMap = ref<Set<string> | null>(null);
|
||||
@@ -81,6 +83,8 @@ const pendingSql = ref("");
|
||||
const pendingAfterApply = ref<(() => void | Promise<void>) | undefined>();
|
||||
const pendingDanger = ref(false);
|
||||
|
||||
trackUiState(() => ({ selectedRoleName: selectedRoleName.value, search: search.value, detailTab: detailTab.value }));
|
||||
|
||||
const supported = computed(() => props.connection.db_type === "dameng");
|
||||
const executionDatabase = computed(() => (props.connection.db_type === "dameng" ? "" : props.connection.database || ""));
|
||||
const selectedRole = computed(() => roles.value.find((role) => role.role === selectedRoleName.value));
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
isDamengAnyPrivilege,
|
||||
parseDamengEnableDdlAnyPriv,
|
||||
} from "@/lib/database/damengPrincipalAdmin";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
@@ -70,19 +71,25 @@ type DetailTab = "roles" | "privileges";
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{
|
||||
selectedUsername?: string;
|
||||
search?: string;
|
||||
detailTab?: DetailTab;
|
||||
collapsedGroups?: Record<string, boolean>;
|
||||
}>({}, "DamengUserAdmin");
|
||||
|
||||
const users = ref<DamengUser[]>([]);
|
||||
const allRoles = ref<DamengRole[]>([]);
|
||||
const tablespaces = ref<string[]>([]);
|
||||
const selectedUsername = ref("");
|
||||
const search = ref("");
|
||||
const selectedUsername = ref(restoredUiState.selectedUsername ?? "");
|
||||
const search = ref(restoredUiState.search ?? "");
|
||||
const loadingUsers = ref(false);
|
||||
const loadingRoles = ref(false);
|
||||
const loadingDetails = ref(false);
|
||||
const applying = ref(false);
|
||||
const loadError = ref("");
|
||||
const detailError = ref("");
|
||||
const detailTab = ref<DetailTab>("roles");
|
||||
const detailTab = ref<DetailTab>(restoredUiState.detailTab === "privileges" ? "privileges" : "roles");
|
||||
const grantedRoles = ref<DamengGrant[]>([]);
|
||||
const grantedPrivileges = ref<DamengSysPrivilege[]>([]);
|
||||
const systemPrivilegeMap = ref<Set<string> | null>(null);
|
||||
@@ -96,7 +103,9 @@ const createLocked = ref(false);
|
||||
const createUserType = ref<Exclude<DamengUserGroup, "other">>("admin");
|
||||
const createdUsername = ref("");
|
||||
const userRoleGrants = ref<Map<string, Set<string>>>(new Map());
|
||||
const collapsedGroups = ref<Record<string, boolean>>({});
|
||||
const collapsedGroups = ref<Record<string, boolean>>(restoredUiState.collapsedGroups ?? {});
|
||||
|
||||
trackUiState(() => ({ selectedUsername: selectedUsername.value, search: search.value, detailTab: detailTab.value, collapsedGroups: collapsedGroups.value }));
|
||||
|
||||
function groupLabel(group: DamengUserGroup): string {
|
||||
if (group === "other") return t("damengUserAdmin.groupOther");
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type AuthorizationStepResult,
|
||||
type DatabaseAuthorizationSelection,
|
||||
} from "@/lib/database/databaseAuthorizationPlan";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
@@ -39,11 +40,24 @@ const connectionStore = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
const { highlight } = useSqlHighlighter();
|
||||
|
||||
interface DatabaseUserAdminTabUiState {
|
||||
selectedUserKey?: string;
|
||||
search?: string;
|
||||
privilegeDatabase?: string;
|
||||
privilegeTable?: string;
|
||||
privilegeScope?: PrivilegeScope;
|
||||
privilegeRole?: string;
|
||||
grantOption?: boolean;
|
||||
selectedPrivileges?: string[];
|
||||
}
|
||||
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<DatabaseUserAdminTabUiState>({}, "DatabaseUserAdmin");
|
||||
const privilegeScopes: PrivilegeScope[] = ["mysql", "database", "schema", "table", "role"];
|
||||
const users = ref<DatabaseUserIdentity[]>([]);
|
||||
const selectedUserKey = ref("");
|
||||
const selectedUserKey = ref(restoredUiState.selectedUserKey ?? "");
|
||||
const grants = ref<string[]>([]);
|
||||
const grantsLoaded = ref(false);
|
||||
const search = ref("");
|
||||
const search = ref(restoredUiState.search ?? "");
|
||||
const loadingUsers = ref(false);
|
||||
const loadingGrants = ref(false);
|
||||
const applying = ref(false);
|
||||
@@ -66,12 +80,12 @@ const createHost = ref("%");
|
||||
const createPassword = ref("");
|
||||
const newHost = ref("");
|
||||
const newPassword = ref("");
|
||||
const privilegeDatabase = ref(props.connection.database || "*");
|
||||
const privilegeTable = ref("*");
|
||||
const privilegeScope = ref<PrivilegeScope>("mysql");
|
||||
const privilegeRole = ref("");
|
||||
const grantOption = ref(false);
|
||||
const selectedPrivileges = ref<string[]>(["SELECT"]);
|
||||
const privilegeDatabase = ref(restoredUiState.privilegeDatabase ?? (props.connection.database || "*"));
|
||||
const privilegeTable = ref(restoredUiState.privilegeTable ?? "*");
|
||||
const privilegeScope = ref<PrivilegeScope>(privilegeScopes.includes(restoredUiState.privilegeScope as PrivilegeScope) ? restoredUiState.privilegeScope! : "mysql");
|
||||
const privilegeRole = ref(restoredUiState.privilegeRole ?? "");
|
||||
const grantOption = ref(restoredUiState.grantOption ?? false);
|
||||
const selectedPrivileges = ref<string[]>(restoredUiState.selectedPrivileges ?? ["SELECT"]);
|
||||
const createCanLogin = ref(true);
|
||||
const createAccountType = ref<AuthorizationAccountType>("standard");
|
||||
const createDatabases = ref<string[]>([]);
|
||||
@@ -83,6 +97,18 @@ const createDatabaseTablesLoading = ref<Record<string, boolean>>({});
|
||||
const createDatabaseTableErrors = ref<Record<string, string>>({});
|
||||
const createDatabaseTableSearch = ref<Record<string, string>>({});
|
||||
let createPlanRequestId = 0;
|
||||
let preserveRestoredPrivilegeSelection = restoredUiState.selectedPrivileges !== undefined || restoredUiState.grantOption !== undefined;
|
||||
|
||||
trackUiState(() => ({
|
||||
selectedUserKey: selectedUserKey.value,
|
||||
search: search.value,
|
||||
privilegeDatabase: privilegeDatabase.value,
|
||||
privilegeTable: privilegeTable.value,
|
||||
privilegeScope: privilegeScope.value,
|
||||
privilegeRole: privilegeRole.value,
|
||||
grantOption: grantOption.value,
|
||||
selectedPrivileges: selectedPrivileges.value,
|
||||
}));
|
||||
|
||||
const provider = computed(() => resolveDatabaseUserAdminProviderForConnection(props.connection));
|
||||
const supported = computed(() => provider.value !== null);
|
||||
@@ -136,6 +162,10 @@ const canPreviewHostChange = computed(() => {
|
||||
function syncPrivilegeSelectionFromGrants() {
|
||||
const selectionFromGrants = provider.value?.privilegeSelectionFromGrants;
|
||||
if (!grantsLoaded.value || !selectionFromGrants) return;
|
||||
if (preserveRestoredPrivilegeSelection) {
|
||||
preserveRestoredPrivilegeSelection = false;
|
||||
return;
|
||||
}
|
||||
const selection = selectionFromGrants({
|
||||
grants: grants.value,
|
||||
database: privilegeDatabase.value,
|
||||
@@ -617,9 +647,14 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
let initialProviderSync = true;
|
||||
watch(
|
||||
() => provider.value,
|
||||
() => {
|
||||
if (initialProviderSync) {
|
||||
initialProviderSync = false;
|
||||
if (restoredUiState.privilegeScope !== undefined) return;
|
||||
}
|
||||
privilegeScope.value = provider.value?.defaultScope ?? "mysql";
|
||||
resetPrivilegeDefaults(privilegeScope.value);
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import MetricLineChart from "@/components/chart/MetricLineChart.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { computeQps, computeRate, formatBytes, formatBytesPerSec, formatNumber, formatRate, formatUptime, GLOBAL_STATUS_SQL, GLOBAL_VARIABLES_SQL, innodbBufferHitRatio, MAX_SAMPLES, parseStatusResult, statusEntries, statusNumber, type StatusSample } from "@/lib/database/mysqlServerStatus";
|
||||
import { useVerticalOverlayScrollbar } from "@/composables/useVerticalOverlayScrollbar";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
@@ -19,15 +20,16 @@ const props = defineProps<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ autoRefreshInterval?: number; statusSearch?: string; showStatusTable?: boolean }>({}, "MySqlDashboard");
|
||||
|
||||
const loading = ref(false);
|
||||
const fetching = ref(false);
|
||||
const error = ref("");
|
||||
const variables = ref<Record<string, string>>({});
|
||||
const samples = ref<StatusSample[]>([]);
|
||||
const autoRefreshInterval = ref(5);
|
||||
const statusSearch = ref("");
|
||||
const showStatusTable = ref(true);
|
||||
const autoRefreshInterval = ref([0, 1, 2, 5, 10].includes(restoredUiState.autoRefreshInterval ?? -1) ? restoredUiState.autoRefreshInterval! : 5);
|
||||
const statusSearch = ref(restoredUiState.statusSearch ?? "");
|
||||
const showStatusTable = ref(restoredUiState.showStatusTable ?? true);
|
||||
const scrollerRef = ref<HTMLElement | null>(null);
|
||||
const scrollerContentRef = ref<HTMLElement | null>(null);
|
||||
const scrollbarTrackRef = ref<HTMLElement | null>(null);
|
||||
@@ -42,6 +44,8 @@ const {
|
||||
} = useVerticalOverlayScrollbar(scrollerRef, scrollerContentRef, scrollbarTrackRef);
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
trackUiState(() => ({ autoRefreshInterval: autoRefreshInterval.value, statusSearch: statusSearch.value, showStatusTable: showStatusTable.value }));
|
||||
|
||||
const connectionName = computed(() => connectionStore.getConfig(props.connectionId)?.name ?? "");
|
||||
const latest = computed(() => samples.value[samples.value.length - 1]);
|
||||
const previous = computed(() => (samples.value.length >= 2 ? samples.value[samples.value.length - 2] : undefined));
|
||||
|
||||
@@ -11,6 +11,7 @@ import MetricLineChart from "@/components/chart/MetricLineChart.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { computePgTps, computeRate, formatBytesPerSec, formatNumber, formatRate, formatUptime, MAX_SAMPLES, parsePgStatusRow, pgCacheHitRatio, resolveServerDashboardDriverForConnection, statusNumber, type StatusSample } from "@/lib/database/postgresServerStatus";
|
||||
import { useVerticalOverlayScrollbar } from "@/composables/useVerticalOverlayScrollbar";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
@@ -18,13 +19,14 @@ const props = defineProps<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ autoRefreshInterval?: number }>({}, "PostgresDashboard");
|
||||
|
||||
const loading = ref(false);
|
||||
const fetching = ref(false);
|
||||
const error = ref("");
|
||||
const variables = ref<Record<string, string>>({});
|
||||
const samples = ref<StatusSample[]>([]);
|
||||
const autoRefreshInterval = ref(5);
|
||||
const autoRefreshInterval = ref([0, 1, 2, 5, 10].includes(restoredUiState.autoRefreshInterval ?? -1) ? restoredUiState.autoRefreshInterval! : 5);
|
||||
const scrollerRef = ref<HTMLElement | null>(null);
|
||||
const scrollerContentRef = ref<HTMLElement | null>(null);
|
||||
const scrollbarTrackRef = ref<HTMLElement | null>(null);
|
||||
@@ -42,6 +44,8 @@ const {
|
||||
const fallbackStatusSql = ref<string | null>(null);
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
trackUiState(() => ({ autoRefreshInterval: autoRefreshInterval.value }));
|
||||
|
||||
const connection = computed(() => connectionStore.getConfig(props.connectionId));
|
||||
const statusDriver = computed(() => resolveServerDashboardDriverForConnection(connection.value));
|
||||
const connectionName = computed(() => connection.value?.name ?? "");
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as api from "@/lib/backend/api";
|
||||
import { executeWithProductionSqlGuard } from "@/lib/database/productionExecutionGuard";
|
||||
import { clampInterval, createProcessListLoadCoordinator, DEFAULT_REFRESH_SECONDS, processListExecutionError, processListSessionCount } from "@/lib/database/mysqlProcessList";
|
||||
import { resolveProcessListDriverForConnection, type ProcessRow } from "@/lib/database/processListDrivers";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
@@ -21,6 +22,13 @@ const props = defineProps<{
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { toast } = useToast();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{
|
||||
search?: string;
|
||||
sortKey?: string;
|
||||
sortDir?: "asc" | "desc";
|
||||
autoRefresh?: boolean;
|
||||
intervalSeconds?: number;
|
||||
}>({}, "ProcessListPanel");
|
||||
|
||||
// The panel is only opened for supported engines; guard the driver defensively so
|
||||
// a missing one degrades to an empty table rather than crashing the render.
|
||||
@@ -34,12 +42,13 @@ const ownSessionId = ref<number | null>(null);
|
||||
const loading = ref(false);
|
||||
const loadCoordinator = createProcessListLoadCoordinator();
|
||||
const loadError = ref("");
|
||||
const search = ref("");
|
||||
const sortKey = ref<string>(driver.value?.defaultSortKey ?? "time");
|
||||
const sortDir = ref<"asc" | "desc">("desc");
|
||||
const search = ref(restoredUiState.search ?? "");
|
||||
const restoredSortKey = columns.value.some((column) => column.key === restoredUiState.sortKey) ? restoredUiState.sortKey : undefined;
|
||||
const sortKey = ref<string>(restoredSortKey ?? driver.value?.defaultSortKey ?? "time");
|
||||
const sortDir = ref<"asc" | "desc">(restoredUiState.sortDir === "asc" ? "asc" : "desc");
|
||||
|
||||
const autoRefresh = ref(false);
|
||||
const intervalSeconds = ref(DEFAULT_REFRESH_SECONDS);
|
||||
const autoRefresh = ref(restoredUiState.autoRefresh ?? false);
|
||||
const intervalSeconds = ref(clampInterval(restoredUiState.intervalSeconds ?? DEFAULT_REFRESH_SECONDS));
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const cancelTarget = ref<ProcessRow | null>(null);
|
||||
@@ -57,6 +66,8 @@ const fallbackListSql = ref<string | null>(null);
|
||||
// Full-text preview for long cells (SQL statement / info), opened by clicking them.
|
||||
const previewText = ref<string | null>(null);
|
||||
|
||||
trackUiState(() => ({ search: search.value, sortKey: sortKey.value, sortDir: sortDir.value, autoRefresh: autoRefresh.value, intervalSeconds: intervalSeconds.value }));
|
||||
|
||||
function openPreview(value: string | number | null) {
|
||||
if (value === null || value === undefined || String(value).length === 0) return;
|
||||
previewText.value = String(value);
|
||||
|
||||
@@ -40,8 +40,10 @@ import {
|
||||
type XuguTablespaceSummary,
|
||||
type XuguTransactionSummary,
|
||||
} from "@/lib/database/xuguServerStatus";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ refreshSeconds?: number }>({}, "XuguServerDashboard");
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
@@ -72,9 +74,11 @@ const availability = reactive({
|
||||
lockModes: false,
|
||||
tablespaces: false,
|
||||
});
|
||||
const refreshSeconds = ref(5);
|
||||
const refreshSeconds = ref([0, 5, 10, 30].includes(restoredUiState.refreshSeconds ?? -1) ? restoredUiState.refreshSeconds! : 5);
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
trackUiState(() => ({ refreshSeconds: refreshSeconds.value }));
|
||||
|
||||
const connection = computed(() => connectionStore.getConfig(props.connectionId));
|
||||
const connectionName = computed(() => connection.value?.name ?? "");
|
||||
const supported = computed(() => connectionSupportsXuguServerDashboard(connection.value));
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useConsulStore } from "@/stores/consulStore";
|
||||
import { consulMeshWorkspaceVisible } from "@/lib/consul/meshVisibility";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const ConsulKeyBrowser = defineAsyncComponent(() => import("@/components/consul/ConsulKeyBrowser.vue"));
|
||||
const ConsulServices = defineAsyncComponent(() => import("@/components/consul/ConsulServices.vue"));
|
||||
@@ -27,10 +28,16 @@ const ConsulOperator = defineAsyncComponent(() => import("@/components/consul/Co
|
||||
type WorkspaceTab = "kv" | "services" | "health" | "sessions" | "acl" | "scope" | "mesh" | "tools" | "operator";
|
||||
type Refreshable = { refresh?: () => boolean | Promise<unknown>; focusSearch?: () => boolean };
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
interface ConsulTabUiState {
|
||||
activeTab?: WorkspaceTab;
|
||||
scopeOpen?: boolean;
|
||||
scopeDraft?: { datacenter: string; partition: string; namespace: string };
|
||||
}
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<ConsulTabUiState>({}, "ConsulWorkspace");
|
||||
const connectionStore = useConnectionStore();
|
||||
const consulStore = useConsulStore();
|
||||
const { t } = useI18n();
|
||||
const activeTab = ref<WorkspaceTab>("kv");
|
||||
const activeTab = ref<WorkspaceTab>(restoredUiState.activeTab ?? "kv");
|
||||
const keyRef = ref<Refreshable>();
|
||||
const servicesRef = ref<Refreshable>();
|
||||
const healthRef = ref<Refreshable>();
|
||||
@@ -43,11 +50,13 @@ const operatorRef = ref<Refreshable>();
|
||||
const capabilities = ref<ConsulCapabilities | null>(null);
|
||||
const discoveredDatacenters = ref<string[]>([]);
|
||||
const scopeRevision = ref(0);
|
||||
const scopeOpen = ref(false);
|
||||
const scopeOpen = ref(restoredUiState.scopeOpen ?? false);
|
||||
const scopeSaving = ref(false);
|
||||
const scopeError = ref("");
|
||||
const scopeDraft = ref({ datacenter: "", partition: "", namespace: "" });
|
||||
const scopeDraft = ref(restoredUiState.scopeDraft ?? { datacenter: "", partition: "", namespace: "" });
|
||||
const pendingSearchFocus = ref(false);
|
||||
|
||||
trackUiState(() => ({ activeTab: activeTab.value, scopeOpen: scopeOpen.value, scopeDraft: scopeDraft.value }));
|
||||
let contextSequence = 0;
|
||||
const activeScope = computed(() => {
|
||||
const external = connectionStore.getConfig(props.connectionId)?.external_config;
|
||||
|
||||
@@ -253,7 +253,7 @@ describe("Consul Catalog and Health watch views", () => {
|
||||
});
|
||||
|
||||
it("starts with KV and keeps the cluster overview as a sidebar surface", () => {
|
||||
expect(workspace).toContain('const activeTab = ref<WorkspaceTab>("kv");');
|
||||
expect(workspace).toContain('const activeTab = ref<WorkspaceTab>(restoredUiState.activeTab ?? "kv");');
|
||||
expect(workspace).not.toContain('<TabsTrigger value="overview"');
|
||||
expect(workspace).not.toContain('<TabsContent value="overview"');
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { doltCellCopyText, type DoltCellSide, type DoltDiffCellTarget } from "@/lib/dolt/doltCellDiff";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
@@ -80,6 +81,26 @@ function baseDatabaseName(database: string): string {
|
||||
const slash = database.indexOf("/");
|
||||
return slash > 0 ? database.slice(0, slash) : database;
|
||||
}
|
||||
|
||||
interface DoltVersionControlTabUiState {
|
||||
selectedRef?: string;
|
||||
selectedRevisionKeys?: string[];
|
||||
comparedFrom?: string;
|
||||
comparedTo?: string;
|
||||
selectedTableName?: string;
|
||||
tableDiffPage?: number;
|
||||
tableDiffPageSize?: number;
|
||||
refFilter?: string;
|
||||
refListTab?: "branches" | "tags";
|
||||
branchTreeView?: boolean;
|
||||
collapsedBranchPaths?: string[];
|
||||
mainTopPaneSize?: number;
|
||||
leftPaneSize?: number;
|
||||
commitMessageDraft?: string;
|
||||
}
|
||||
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<DoltVersionControlTabUiState>({}, "DoltVersionControl");
|
||||
const uiStateReady = ref(false);
|
||||
const loading = ref(false);
|
||||
const graphLoading = ref(false);
|
||||
const comparisonLoading = ref(false);
|
||||
@@ -97,7 +118,7 @@ const branches = ref<DoltRef[]>([]);
|
||||
const tags = ref<DoltRef[]>([]);
|
||||
const commits = ref<DoltCommit[]>([]);
|
||||
const workingChanges = ref<DoltWorkingChange[]>([]);
|
||||
const selectedRef = ref("");
|
||||
const selectedRef = ref(restoredUiState.selectedRef ?? "");
|
||||
const selectedRevisionKeys = ref<string[]>([]);
|
||||
const comparedFrom = ref("");
|
||||
const comparedTo = ref("");
|
||||
@@ -108,10 +129,10 @@ const selectedDiffCell = ref<DoltDiffCellTarget | null>(null);
|
||||
const diffCellContextTarget = ref<DoltDiffCellTarget | null>(null);
|
||||
const diffCellDetailTarget = ref<DoltDiffCellTarget | null>(null);
|
||||
const diffCellDetailOpen = ref(false);
|
||||
const refFilter = ref("");
|
||||
const refListTab = ref<"branches" | "tags">("branches");
|
||||
const branchTreeView = ref(true);
|
||||
const collapsedBranchPaths = ref<Set<string>>(new Set());
|
||||
const refFilter = ref(restoredUiState.refFilter ?? "");
|
||||
const refListTab = ref<"branches" | "tags">(restoredUiState.refListTab === "tags" ? "tags" : "branches");
|
||||
const branchTreeView = ref(restoredUiState.branchTreeView ?? true);
|
||||
const collapsedBranchPaths = ref<Set<string>>(new Set(restoredUiState.collapsedBranchPaths ?? []));
|
||||
const branchDialog = ref<"create" | "merge" | "delete" | "create-tag" | "delete-tag" | null>(null);
|
||||
const branchNameDraft = ref("");
|
||||
const branchSourceRevision = ref("HEAD");
|
||||
@@ -119,7 +140,7 @@ const branchActionTarget = ref("");
|
||||
const mutationLoading = ref(false);
|
||||
const mutationError = ref("");
|
||||
const commitDialogOpen = ref(false);
|
||||
const commitMessageDraft = ref("");
|
||||
const commitMessageDraft = ref(restoredUiState.commitMessageDraft ?? "");
|
||||
const commitError = ref("");
|
||||
const commitHistoryOperation = ref<"revert" | "hard-reset" | null>(null);
|
||||
const commitHistoryTarget = ref<DoltCommit | null>(null);
|
||||
@@ -137,8 +158,12 @@ const DOLT_DIFF_DEFAULT_PAGE_SIZE = 100;
|
||||
const DOLT_LEFT_PANE_DEFAULT_SIZE = 20;
|
||||
const DOLT_LEFT_PANE_MIN_SIZE = 15;
|
||||
const DOLT_LEFT_PANE_MAX_SIZE = 40;
|
||||
const DOLT_MAIN_TOP_PANE_DEFAULT_SIZE = 64;
|
||||
const DOLT_MAIN_TOP_PANE_MIN_SIZE = 38;
|
||||
const DOLT_MAIN_TOP_PANE_MAX_SIZE = 78;
|
||||
const GRAPH_COLORS = ["#2f6fdb", "#1f8f55", "#b7791f", "#c2413a", "#7c3aed", "#0f766e", "#b45309", "#0891b2", "#be185d", "#4d7c0f", "#4338ca", "#a16207", "#0e7490", "#9f1239", "#166534", "#6d28d9", "#92400e", "#475569"];
|
||||
const doltLeftPaneSize = ref(DOLT_LEFT_PANE_DEFAULT_SIZE);
|
||||
const doltMainTopPaneSize = ref(Math.min(DOLT_MAIN_TOP_PANE_MAX_SIZE, Math.max(DOLT_MAIN_TOP_PANE_MIN_SIZE, restoredUiState.mainTopPaneSize ?? DOLT_MAIN_TOP_PANE_DEFAULT_SIZE)));
|
||||
const doltLeftPaneSize = ref(Math.min(DOLT_LEFT_PANE_MAX_SIZE, Math.max(DOLT_LEFT_PANE_MIN_SIZE, restoredUiState.leftPaneSize ?? DOLT_LEFT_PANE_DEFAULT_SIZE)));
|
||||
|
||||
const allRefs = computed(() => [...branches.value, ...tags.value]);
|
||||
const normalizedRefFilter = computed(() => refFilter.value.trim().toLowerCase());
|
||||
@@ -237,7 +262,8 @@ let diffScrollSyncRetry = 0;
|
||||
let diffScrollSyncRetryTimer = 0;
|
||||
let diffScrollSyncing = false;
|
||||
const tableDiffPage = ref(1);
|
||||
const tableDiffPageSize = ref(DOLT_DIFF_DEFAULT_PAGE_SIZE);
|
||||
const restoredTableDiffPageSize = restoredUiState.tableDiffPageSize;
|
||||
const tableDiffPageSize = ref(Number.isSafeInteger(restoredTableDiffPageSize) && restoredTableDiffPageSize! > 0 ? restoredTableDiffPageSize! : DOLT_DIFF_DEFAULT_PAGE_SIZE);
|
||||
const tableDiffTotalRows = ref(0);
|
||||
const tableDiffMaximumPage = computed(() => Math.max(1, Math.ceil(tableDiffTotalRows.value / tableDiffPageSize.value)));
|
||||
const selectedRevisionKeySet = computed(() => new Set(selectedRevisionKeys.value));
|
||||
@@ -271,6 +297,44 @@ const commitHistoryOperationSql = computed(() => {
|
||||
});
|
||||
const discardWorkingTreeSqlPreview = computed(() => doltDiscardWorkingTreeSql());
|
||||
|
||||
trackUiState(() =>
|
||||
uiStateReady.value
|
||||
? {
|
||||
selectedRef: selectedRef.value,
|
||||
selectedRevisionKeys: selectedRevisionKeys.value,
|
||||
comparedFrom: comparedFrom.value,
|
||||
comparedTo: comparedTo.value,
|
||||
selectedTableName: selectedTableName.value,
|
||||
tableDiffPage: tableDiffPage.value,
|
||||
tableDiffPageSize: tableDiffPageSize.value,
|
||||
refFilter: refFilter.value,
|
||||
refListTab: refListTab.value,
|
||||
branchTreeView: branchTreeView.value,
|
||||
collapsedBranchPaths: [...collapsedBranchPaths.value],
|
||||
mainTopPaneSize: doltMainTopPaneSize.value,
|
||||
leftPaneSize: doltLeftPaneSize.value,
|
||||
commitMessageDraft: commitMessageDraft.value,
|
||||
}
|
||||
: restoredUiState,
|
||||
);
|
||||
|
||||
type SplitpanesResizeEvent = { panes?: Array<{ size?: number }> };
|
||||
|
||||
function firstPaneSize(event: SplitpanesResizeEvent): number | undefined {
|
||||
const size = Number(event.panes?.[0]?.size);
|
||||
return Number.isFinite(size) ? size : undefined;
|
||||
}
|
||||
|
||||
function handleMainSplitResized(event: SplitpanesResizeEvent) {
|
||||
const size = firstPaneSize(event);
|
||||
if (size !== undefined) doltMainTopPaneSize.value = Math.min(DOLT_MAIN_TOP_PANE_MAX_SIZE, Math.max(DOLT_MAIN_TOP_PANE_MIN_SIZE, size));
|
||||
}
|
||||
|
||||
function handleLeftSplitResized(event: SplitpanesResizeEvent) {
|
||||
const size = firstPaneSize(event);
|
||||
if (size !== undefined) doltLeftPaneSize.value = Math.min(DOLT_LEFT_PANE_MAX_SIZE, Math.max(DOLT_LEFT_PANE_MIN_SIZE, size));
|
||||
}
|
||||
|
||||
const graphEdges = computed(() => {
|
||||
const edges: Array<{ key: string; path: string; color: string; diagonal: boolean }> = [];
|
||||
const commitIndexes = new Map(commits.value.map((commit, index) => [commit.hash, index]));
|
||||
@@ -480,6 +544,24 @@ async function reloadDatabaseContext(database: string, requestedBranch = props.i
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreNavigationAfterInitialLoad() {
|
||||
const restoredFrom = restoredUiState.comparedFrom?.trim();
|
||||
const restoredTo = restoredUiState.comparedTo?.trim();
|
||||
if (restoredFrom && restoredTo && restoredFrom !== restoredTo && (restoredFrom !== comparedFrom.value || restoredTo !== comparedTo.value)) {
|
||||
await loadComparison(restoredFrom, restoredTo);
|
||||
}
|
||||
|
||||
const restoredTable = restoredUiState.selectedTableName;
|
||||
const change = restoredTable ? changes.value.find((item) => item.tableName === restoredTable) : undefined;
|
||||
if (change) {
|
||||
const requestedPage = Number.isSafeInteger(restoredUiState.tableDiffPage) && restoredUiState.tableDiffPage! > 0 ? restoredUiState.tableDiffPage! : 1;
|
||||
await loadTableDiff(change, comparisonGeneration, { page: requestedPage, countTotal: selectedTableName.value !== change.tableName });
|
||||
}
|
||||
|
||||
const restoredSelections = restoredUiState.selectedRevisionKeys?.filter((key) => !!selectionForKey(key)) ?? [];
|
||||
selectedRevisionKeys.value = restoredSelections;
|
||||
}
|
||||
|
||||
async function loadGraph(revision: string, parentGeneration = ++loadGeneration) {
|
||||
graphLoading.value = true;
|
||||
error.value = "";
|
||||
@@ -1196,11 +1278,13 @@ watch(
|
||||
() => clearDiffCellInteraction(),
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
selectedDatabase.value = baseDatabaseName(props.database);
|
||||
const tab = workspaceTab();
|
||||
if (tab && tab.database !== selectedDatabase.value) queryStore.updateDatabase(tab.id, selectedDatabase.value);
|
||||
void reloadDatabaseContext(props.database, props.initialBranch);
|
||||
await reloadDatabaseContext(props.database, props.initialBranch);
|
||||
await restoreNavigationAfterInitialLoad();
|
||||
uiStateReady.value = true;
|
||||
scheduleDiffScrollSync();
|
||||
});
|
||||
|
||||
@@ -1255,9 +1339,9 @@ onUnmounted(() => {
|
||||
|
||||
<div v-if="error" class="shrink-0 border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">{{ error }}</div>
|
||||
|
||||
<Splitpanes horizontal class="dolt-main-splitpanes min-h-0 flex-1">
|
||||
<Pane :size="64" :min-size="38">
|
||||
<Splitpanes class="dolt-diff-splitpanes h-full min-h-0">
|
||||
<Splitpanes horizontal class="dolt-main-splitpanes min-h-0 flex-1" @resized="handleMainSplitResized">
|
||||
<Pane :size="doltMainTopPaneSize" :min-size="DOLT_MAIN_TOP_PANE_MIN_SIZE">
|
||||
<Splitpanes class="dolt-diff-splitpanes h-full min-h-0" @resized="handleLeftSplitResized">
|
||||
<Pane :size="doltLeftPaneSize" :min-size="DOLT_LEFT_PANE_MIN_SIZE" :max-size="DOLT_LEFT_PANE_MAX_SIZE">
|
||||
<section class="flex h-full min-h-0 flex-col">
|
||||
<div class="flex h-8 shrink-0 items-center gap-1.5 border-b px-2 text-xs font-medium">
|
||||
@@ -1343,8 +1427,8 @@ onUnmounted(() => {
|
||||
</Splitpanes>
|
||||
</Pane>
|
||||
|
||||
<Pane :size="36" :min-size="22">
|
||||
<Splitpanes class="dolt-history-splitpanes h-full min-h-0">
|
||||
<Pane :size="100 - doltMainTopPaneSize" :min-size="100 - DOLT_MAIN_TOP_PANE_MAX_SIZE">
|
||||
<Splitpanes class="dolt-history-splitpanes h-full min-h-0" @resized="handleLeftSplitResized">
|
||||
<Pane :size="doltLeftPaneSize" :min-size="DOLT_LEFT_PANE_MIN_SIZE" :max-size="DOLT_LEFT_PANE_MAX_SIZE">
|
||||
<aside class="flex h-full min-h-0 flex-col bg-muted/10">
|
||||
<div class="flex items-center gap-1.5 border-b p-2">
|
||||
|
||||
@@ -9,23 +9,25 @@ import { Input } from "@/components/ui/input";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
type AccessView = "users" | "roles";
|
||||
type PermissionAccess = "read" | "write" | "readwrite";
|
||||
type PermissionResource = "all" | "key" | "prefix";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ view?: AccessView; selectedUser?: string; selectedRole?: string }>({}, "EtcdAccessControl");
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const view = ref<AccessView>("users");
|
||||
const view = ref<AccessView>(restoredUiState.view === "roles" ? "roles" : "users");
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
const error = ref("");
|
||||
const notice = ref("");
|
||||
const users = ref<string[]>([]);
|
||||
const roles = ref<string[]>([]);
|
||||
const selectedUser = ref("");
|
||||
const selectedRole = ref("");
|
||||
const selectedUser = ref(restoredUiState.selectedUser ?? "");
|
||||
const selectedRole = ref(restoredUiState.selectedRole ?? "");
|
||||
const userDetail = ref<api.EtcdAuthUserDetail | null>(null);
|
||||
const roleDetail = ref<api.EtcdAuthRoleDetail | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
@@ -53,6 +55,8 @@ const approvalExpected = ref("");
|
||||
let pendingApproval: (() => Promise<void>) | null = null;
|
||||
let detailRequest = 0;
|
||||
|
||||
trackUiState(() => ({ view: view.value, selectedUser: selectedUser.value, selectedRole: selectedRole.value }));
|
||||
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const selectedUserRoles = computed(() => userDetail.value?.roles ?? []);
|
||||
const grantableRoles = computed(() => roles.value.filter((role) => !selectedUserRoles.value.includes(role)));
|
||||
|
||||
@@ -7,19 +7,23 @@ import { Badge } from "@/components/ui/badge";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { classifyEtcdDashboardError } from "@/lib/kv/etcdDashboardError";
|
||||
import { counterMapRates, counterRate, histogramAverageMilliseconds, histogramMapAverageMilliseconds, ratePercentage } from "@/lib/kv/etcdDashboardMetrics";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ refreshSeconds?: number }>({}, "EtcdDashboard");
|
||||
const { t } = useI18n();
|
||||
const status = ref<api.KvStatusResponse | null>(null);
|
||||
const previousMetrics = ref<api.KvPrometheusMetrics | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const unsupported = ref(false);
|
||||
const refreshSeconds = ref(0);
|
||||
const refreshSeconds = ref([0, 5, 10, 30, 60].includes(restoredUiState.refreshSeconds ?? -1) ? restoredUiState.refreshSeconds! : 0);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let loadGeneration = 0;
|
||||
let loadingConnectionId: string | null = null;
|
||||
|
||||
trackUiState(() => ({ refreshSeconds: refreshSeconds.value }));
|
||||
|
||||
const reachable = computed(() => status.value?.members.filter((member) => member.reachable).length ?? 0);
|
||||
const totalDbSize = computed(() => status.value?.members.reduce((sum, member) => sum + Number(member.dbSize || 0), 0) ?? 0);
|
||||
const totalDbSizeInUse = computed(() => status.value?.members.reduce((sum, member) => sum + Number(member.dbSizeInUse || 0), 0) ?? 0);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSepara
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { isKeyInKvExportScope, kvExportFilenameStem, kvValueByteIdentity, type KvExportScopeKind, type KvExportScopeRequest } from "@/lib/kv/kvExportScope";
|
||||
@@ -95,6 +96,23 @@ const TRANSFER_PREVIEW_PAGE_SIZE = 100;
|
||||
const TARGET_LOOKUP_CONCURRENCY = 8;
|
||||
|
||||
const props = defineProps<{ connectionId: string }>();
|
||||
interface EtcdTabUiState {
|
||||
mode?: WorkbenchMode;
|
||||
activeOperation?: "maintenance" | "watch" | "lease";
|
||||
searchQuery?: string;
|
||||
searchPrefix?: string;
|
||||
searchScope?: SearchScope;
|
||||
transferOpen?: boolean;
|
||||
transferMode?: "import" | "sync";
|
||||
targetConnectionId?: string;
|
||||
transferKeyFilter?: string;
|
||||
syncPrefix?: string;
|
||||
syncScope?: EtcdSyncScope;
|
||||
transferConflictPolicy?: EtcdConflictPolicy;
|
||||
transferCurrentPage?: number;
|
||||
syncConfigurationExpanded?: boolean;
|
||||
}
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<EtcdTabUiState>({}, "EtcdKeyBrowser");
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
@@ -106,11 +124,11 @@ const ttlCapabilityRefreshIntervalMs = 5000;
|
||||
let ttlCapabilityRequest = 0;
|
||||
let ttlCapabilityInFlightConnection: string | null = null;
|
||||
let ttlCapabilityRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const mode = ref<WorkbenchMode>("keys");
|
||||
const mode = ref<WorkbenchMode>(restoredUiState.mode ?? "keys");
|
||||
const operationsStatus = ref<api.KvStatusResponse | null>(null);
|
||||
const operationsLoading = ref(false);
|
||||
const watchPreset = ref<EtcdWatchPreset | null>(null);
|
||||
const activeOperation = ref<"maintenance" | "watch" | "lease">("maintenance");
|
||||
const activeOperation = ref<"maintenance" | "watch" | "lease">(restoredUiState.activeOperation ?? "maintenance");
|
||||
const isOperationsMode = computed(() => mode.value === "maintenance" || mode.value === "watch" || mode.value === "lease");
|
||||
const keyBytesByDisplay = new Map<string, Map<string, api.KvValue>>();
|
||||
const keySuggestionVersion = ref(0);
|
||||
@@ -123,9 +141,9 @@ const selectedTreeKeys = ref<EtcdMultiSelection[]>([]);
|
||||
const batchDeleteOpen = ref(false);
|
||||
const batchDeleting = ref(false);
|
||||
|
||||
const searchQuery = ref("");
|
||||
const searchPrefix = ref("");
|
||||
const searchScope = ref<SearchScope>("all");
|
||||
const searchQuery = ref(restoredUiState.searchQuery ?? "");
|
||||
const searchPrefix = ref(restoredUiState.searchPrefix ?? "");
|
||||
const searchScope = ref<SearchScope>(restoredUiState.searchScope ?? "all");
|
||||
const searchResults = ref<SearchResult[]>([]);
|
||||
const searchRunning = ref(false);
|
||||
const searchScanned = ref(0);
|
||||
@@ -137,22 +155,39 @@ const searchError = ref("");
|
||||
let searchCancelled = false;
|
||||
let transferPreviewGeneration = 0;
|
||||
|
||||
const transferOpen = ref(false);
|
||||
const transferMode = ref<"import" | "sync">("import");
|
||||
const transferOpen = ref(restoredUiState.transferOpen ?? false);
|
||||
const transferMode = ref<"import" | "sync">(restoredUiState.transferMode ?? "import");
|
||||
const transferBundle = ref<EtcdBundle | null>(null);
|
||||
const targetConnectionId = ref("");
|
||||
const targetConnectionId = ref(restoredUiState.targetConnectionId ?? "");
|
||||
const transferRows = ref<TransferRow[]>([]);
|
||||
const transferLoading = ref(false);
|
||||
const transferApplying = ref(false);
|
||||
const transferError = ref("");
|
||||
const transferKeyFilter = ref("");
|
||||
const syncPrefix = ref("");
|
||||
const syncScope = ref<EtcdSyncScope>("prefix");
|
||||
const transferConflictPolicy = ref<EtcdConflictPolicy>("ABORT");
|
||||
const transferCurrentPage = ref(1);
|
||||
const transferKeyFilter = ref(restoredUiState.transferKeyFilter ?? "");
|
||||
const syncPrefix = ref(restoredUiState.syncPrefix ?? "");
|
||||
const syncScope = ref<EtcdSyncScope>(restoredUiState.syncScope ?? "prefix");
|
||||
const transferConflictPolicy = ref<EtcdConflictPolicy>(restoredUiState.transferConflictPolicy ?? "ABORT");
|
||||
const transferCurrentPage = ref(restoredUiState.transferCurrentPage ?? 1);
|
||||
const transferLoadingDetail = ref("");
|
||||
const transferPreviewLoaded = ref(false);
|
||||
const syncConfigurationExpanded = ref(true);
|
||||
const syncConfigurationExpanded = ref(restoredUiState.syncConfigurationExpanded ?? true);
|
||||
|
||||
trackUiState(() => ({
|
||||
mode: mode.value,
|
||||
activeOperation: activeOperation.value,
|
||||
searchQuery: searchQuery.value,
|
||||
searchPrefix: searchPrefix.value,
|
||||
searchScope: searchScope.value,
|
||||
transferOpen: transferOpen.value,
|
||||
transferMode: transferMode.value,
|
||||
targetConnectionId: targetConnectionId.value,
|
||||
transferKeyFilter: transferKeyFilter.value,
|
||||
syncPrefix: syncPrefix.value,
|
||||
syncScope: syncScope.value,
|
||||
transferConflictPolicy: transferConflictPolicy.value,
|
||||
transferCurrentPage: transferCurrentPage.value,
|
||||
syncConfigurationExpanded: syncConfigurationExpanded.value,
|
||||
}));
|
||||
|
||||
const etcdAccess = computed(() => connectionStore.getEtcdAccessCapabilities(props.connectionId));
|
||||
const canManageEtcd = computed(() => etcdAccess.value.admin);
|
||||
|
||||
@@ -15,12 +15,13 @@ import { useToast } from "@/composables/useToast";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { CellValue } from "@/lib/dataGrid/cellValue";
|
||||
import { encodeHBaseTextInput, hbaseCellInput } from "@/lib/hbase/hbaseValues";
|
||||
import { loadHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences";
|
||||
import { loadHBaseRowLimit, normalizeHBaseRowLimit, saveHBaseRowLimit } from "@/lib/hbase/hbaseBrowserPreferences";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import type { HBaseCellInput, HBasePutRowInput, HBaseRow, HBaseTableSchema, HBaseValueEncoding } from "@/types/hbase";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
tabId: string;
|
||||
@@ -30,6 +31,20 @@ const props = defineProps<{
|
||||
createTableOnOpen?: boolean;
|
||||
}>();
|
||||
|
||||
type HBaseTabUiState = {
|
||||
lookupMode?: LookupMode;
|
||||
rowKeyInput?: string;
|
||||
rowLimit?: string;
|
||||
schemaDialogOpen?: boolean;
|
||||
writeDialogOpen?: boolean;
|
||||
writeJson?: string;
|
||||
createTableDialogOpen?: boolean;
|
||||
createTableName?: string;
|
||||
createColumnFamilies?: string;
|
||||
deleteTableDialogOpen?: boolean;
|
||||
};
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<HBaseTabUiState>({}, "HBaseBrowser");
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
@@ -40,27 +55,42 @@ type LookupMode = "prefix" | "exact";
|
||||
const rows = ref<HBaseRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const lookupMode = ref<LookupMode>("prefix");
|
||||
const rowKeyInput = ref("");
|
||||
const rowLimit = ref(loadHBaseRowLimit());
|
||||
const lookupMode = ref<LookupMode>(restoredUiState.lookupMode ?? "prefix");
|
||||
const rowKeyInput = ref(restoredUiState.rowKeyInput ?? "");
|
||||
const rowLimit = ref(normalizeHBaseRowLimit(restoredUiState.rowLimit ?? loadHBaseRowLimit()));
|
||||
const truncated = ref(false);
|
||||
const elapsedMs = ref(0);
|
||||
const schema = ref<HBaseTableSchema>();
|
||||
const schemaLoading = ref(false);
|
||||
const schemaDialogOpen = ref(false);
|
||||
const writeDialogOpen = ref(false);
|
||||
const writeJson = ref("");
|
||||
const schemaDialogOpen = ref(restoredUiState.schemaDialogOpen ?? false);
|
||||
const writeDialogOpen = ref(restoredUiState.writeDialogOpen ?? false);
|
||||
const writeJson = ref(restoredUiState.writeJson ?? "");
|
||||
const writeLoading = ref(false);
|
||||
const writeError = ref("");
|
||||
const createTableDialogOpen = ref(false);
|
||||
const createTableName = ref("");
|
||||
const createColumnFamilies = ref("");
|
||||
const createTableDialogOpen = ref(restoredUiState.createTableDialogOpen ?? false);
|
||||
const createTableName = ref(restoredUiState.createTableName ?? "");
|
||||
const createColumnFamilies = ref(restoredUiState.createColumnFamilies ?? "");
|
||||
const createTableLoading = ref(false);
|
||||
const createTableError = ref("");
|
||||
const deleteTableDialogOpen = ref(false);
|
||||
const deleteTableDialogOpen = ref(restoredUiState.deleteTableDialogOpen ?? false);
|
||||
const deleteTableLoading = ref(false);
|
||||
const deleteTableError = ref("");
|
||||
|
||||
trackUiState(() => ({
|
||||
lookupMode: lookupMode.value,
|
||||
rowKeyInput: rowKeyInput.value,
|
||||
rowLimit: rowLimit.value,
|
||||
schemaDialogOpen: schemaDialogOpen.value,
|
||||
writeDialogOpen: writeDialogOpen.value,
|
||||
writeJson: writeJson.value,
|
||||
createTableDialogOpen: createTableDialogOpen.value,
|
||||
createTableName: createTableName.value,
|
||||
createColumnFamilies: createColumnFamilies.value,
|
||||
deleteTableDialogOpen: deleteTableDialogOpen.value,
|
||||
}));
|
||||
|
||||
let initialHBaseInputsRestored = false;
|
||||
|
||||
const hasTable = computed(() => props.table.trim().length > 0);
|
||||
const readOnly = computed(() => connectionIsEffectivelyReadOnly(connectionStore.getConfig(props.connectionId)));
|
||||
const qualifiedTableLabel = computed(() => {
|
||||
@@ -108,7 +138,8 @@ watch(
|
||||
() => {
|
||||
rows.value = [];
|
||||
schema.value = undefined;
|
||||
rowKeyInput.value = "";
|
||||
if (!initialHBaseInputsRestored) initialHBaseInputsRestored = true;
|
||||
else rowKeyInput.value = "";
|
||||
if (hasTable.value) void refreshRows();
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
type LazyKvPathStyle,
|
||||
} from "@/lib/kv/slashPrefixLazyKeyTree";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
import { detectKvValueFormat, validateKvValue, type KvValueFormat } from "@/lib/kv/kvValueFormat";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
@@ -240,12 +241,36 @@ const emit = defineEmits<{
|
||||
selectionChange: [selection: KvMultiSelection[]];
|
||||
}>();
|
||||
|
||||
interface KvTabUiState {
|
||||
prefix?: string;
|
||||
expandedGroupIds?: string[];
|
||||
selectedKey?: string | null;
|
||||
showEditDialog?: boolean;
|
||||
isCreating?: boolean;
|
||||
editKey?: string;
|
||||
editValue?: string;
|
||||
editTtl?: string | number;
|
||||
editFlags?: string;
|
||||
editExpiryMode?: KvExpiryMode;
|
||||
editLeaseId?: string;
|
||||
editFormat?: KvValueFormat;
|
||||
editEncoding?: "utf8" | "base64";
|
||||
showRenameDialog?: boolean;
|
||||
renameValue?: string;
|
||||
renameMode?: "rename" | "copy";
|
||||
selectedCreateMode?: KvCreateMode;
|
||||
selectedBase64ViewMode?: "utf8" | "base64";
|
||||
kvBrowserSplitSize?: number;
|
||||
}
|
||||
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<KvTabUiState>({}, "KvKeyBrowser");
|
||||
|
||||
const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const searchInputRef = ref<HTMLInputElement>();
|
||||
const prefix = ref("");
|
||||
const prefix = ref(restoredUiState.prefix ?? "");
|
||||
const keySuggestionOpen = ref(false);
|
||||
const keySuggestionIndex = ref(-1);
|
||||
const remoteKeySuggestions = ref<KvKeySummary[]>([]);
|
||||
@@ -256,23 +281,23 @@ const listFilteredByAcls = ref(false);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const listError = ref("");
|
||||
const expandedGroupIds = ref<Set<string>>(new Set());
|
||||
const selectedKey = ref<string | null>(null);
|
||||
const selectedKeyIdentity = ref<string | null>(null);
|
||||
const expandedGroupIds = ref<Set<string>>(new Set(restoredUiState.expandedGroupIds ?? []));
|
||||
const selectedKey = ref<string | null>(restoredUiState.selectedKey ?? null);
|
||||
const selectedKeyIdentity = ref<string | null>(restoredUiState.selectedKey ?? null);
|
||||
const selectedRouteKeyBytes = ref<KvValue | null>(null);
|
||||
const selectedValue = ref<KvGetResponse | null>(null);
|
||||
const detailLoading = ref(false);
|
||||
const detailError = ref("");
|
||||
const showEditDialog = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const editKey = ref("");
|
||||
const editValue = ref("");
|
||||
const editTtl = ref<string | number>("");
|
||||
const editFlags = ref("0");
|
||||
const editExpiryMode = ref<KvExpiryMode>("permanent");
|
||||
const editLeaseId = ref("");
|
||||
const editFormat = ref<KvValueFormat>("text");
|
||||
const editEncoding = ref<"utf8" | "base64">("utf8");
|
||||
const showEditDialog = ref(restoredUiState.showEditDialog ?? false);
|
||||
const isCreating = ref(restoredUiState.isCreating ?? false);
|
||||
const editKey = ref(restoredUiState.editKey ?? "");
|
||||
const editValue = ref(restoredUiState.editValue ?? "");
|
||||
const editTtl = ref<string | number>(restoredUiState.editTtl ?? "");
|
||||
const editFlags = ref(restoredUiState.editFlags ?? "0");
|
||||
const editExpiryMode = ref<KvExpiryMode>(restoredUiState.editExpiryMode ?? "permanent");
|
||||
const editLeaseId = ref(restoredUiState.editLeaseId ?? "");
|
||||
const editFormat = ref<KvValueFormat>(restoredUiState.editFormat ?? "text");
|
||||
const editEncoding = ref<"utf8" | "base64">(restoredUiState.editEncoding ?? "utf8");
|
||||
const editError = ref("");
|
||||
const editErrorKind = ref<KvMutationErrorKind>("request");
|
||||
const saving = ref(false);
|
||||
@@ -281,11 +306,11 @@ const showSaveDiff = ref(false);
|
||||
const pendingSave = ref<{ key: string; value: KvValue; options?: KvPutOptions } | null>(null);
|
||||
const showDeleteConfirm = ref(false);
|
||||
const deleting = ref(false);
|
||||
const showRenameDialog = ref(false);
|
||||
const renameValue = ref("");
|
||||
const showRenameDialog = ref(restoredUiState.showRenameDialog ?? false);
|
||||
const renameValue = ref(restoredUiState.renameValue ?? "");
|
||||
const renameError = ref("");
|
||||
const renaming = ref(false);
|
||||
const renameMode = ref<"rename" | "copy">("rename");
|
||||
const renameMode = ref<"rename" | "copy">(restoredUiState.renameMode ?? "rename");
|
||||
const showHistoryDialog = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const historyError = ref("");
|
||||
@@ -293,9 +318,9 @@ const historyEvents = ref<KvHistoryEvent[]>([]);
|
||||
const selectedHistoryEvent = ref<KvHistoryEvent | null>(null);
|
||||
const showHistoryDiff = ref(false);
|
||||
const restoring = ref(false);
|
||||
const selectedCreateMode = ref<KvCreateMode>("persistent");
|
||||
const selectedCreateMode = ref<KvCreateMode>(restoredUiState.selectedCreateMode ?? "persistent");
|
||||
const selectedPrettyValue = ref<string | null>(null);
|
||||
const selectedBase64ViewMode = ref<"utf8" | "base64">("utf8");
|
||||
const selectedBase64ViewMode = ref<"utf8" | "base64">(restoredUiState.selectedBase64ViewMode ?? "utf8");
|
||||
const selectedValueCopied = ref(false);
|
||||
const lazyTreeState = reactive(createLazyKvKeyTreeState(lazyKvRootPath(props.lazyPathStyle), props.lazyPathStyle));
|
||||
const multiSelectedKeys = ref<Map<string, KvMultiSelection>>(new Map());
|
||||
@@ -305,7 +330,29 @@ const keyListRefreshBaseIntervalMs = 2000;
|
||||
const keyListRefreshMaxIntervalMs = 30000;
|
||||
const kvBrowserSplitSizeStorageKey = "dbx-kv-browser-split-size";
|
||||
const savedKvBrowserSplitSize = Number(safeLocalStorageGet(kvBrowserSplitSizeStorageKey));
|
||||
const kvBrowserSplitSize = ref(savedKvBrowserSplitSize >= 20 && savedKvBrowserSplitSize <= 70 ? savedKvBrowserSplitSize : 38);
|
||||
const kvBrowserSplitSize = ref(restoredUiState.kvBrowserSplitSize ?? (savedKvBrowserSplitSize >= 20 && savedKvBrowserSplitSize <= 70 ? savedKvBrowserSplitSize : 38));
|
||||
|
||||
trackUiState(() => ({
|
||||
prefix: prefix.value,
|
||||
expandedGroupIds: [...expandedGroupIds.value].slice(0, 512),
|
||||
selectedKey: selectedKey.value,
|
||||
showEditDialog: showEditDialog.value,
|
||||
isCreating: isCreating.value,
|
||||
editKey: editKey.value,
|
||||
editValue: editValue.value,
|
||||
editTtl: editTtl.value,
|
||||
editFlags: editFlags.value,
|
||||
editExpiryMode: editExpiryMode.value,
|
||||
editLeaseId: editLeaseId.value,
|
||||
editFormat: editFormat.value,
|
||||
editEncoding: editEncoding.value,
|
||||
showRenameDialog: showRenameDialog.value,
|
||||
renameValue: renameValue.value,
|
||||
renameMode: renameMode.value,
|
||||
selectedCreateMode: selectedCreateMode.value,
|
||||
selectedBase64ViewMode: selectedBase64ViewMode.value,
|
||||
kvBrowserSplitSize: kvBrowserSplitSize.value,
|
||||
}));
|
||||
let keyLoadGeneration = 0;
|
||||
let detailRequestId = 0;
|
||||
let metadataRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -1829,7 +1876,7 @@ onMounted(() => {
|
||||
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
|
||||
}
|
||||
try {
|
||||
await loadKeys(true);
|
||||
await loadKeys(true, { preserveSelection: Boolean(restoredUiState.selectedKey) });
|
||||
} catch {
|
||||
// The browser's normal refresh path can retry after a transient failure.
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { hasQueryOutput as tabHasQueryOutput } from "@/lib/query/queryOutput";
|
||||
import { batchSqlRecoveryState, type BatchSqlRecoveryAction } from "@/lib/query/batchSqlRecovery";
|
||||
import type { CSSProperties } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { provideTabUiState } from "@/lib/tabs/tabUiState";
|
||||
import {
|
||||
Check,
|
||||
CheckSquare2,
|
||||
@@ -224,6 +225,11 @@ const emit = defineEmits<ContentAreaSurfaceEmits>();
|
||||
const { t, locale } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
provideTabUiState(() => {
|
||||
const tab = props.activeTab;
|
||||
const mode = tab.mode;
|
||||
return { snapshot: tab.uiState?.page?.[mode] ?? {}, update: (patch) => queryStore.updateTabPageUiState(tab.id, mode, patch, tab) };
|
||||
});
|
||||
const canAccessEtcdAdmin = computed(() => connectionStore.getEtcdAccessCapabilities(props.activeTab.connectionId).admin);
|
||||
watch(
|
||||
() => [props.activeTab.connectionId, props.activeTab.mode] as const,
|
||||
@@ -2449,7 +2455,18 @@ defineExpose({
|
||||
<!-- Vector mode: Qdrant and Milvus collections -->
|
||||
<template v-else-if="activeTab.mode === 'vector'">
|
||||
<div class="flex-1 min-h-0">
|
||||
<VectorBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :collection="activeTab.sql" :collection-label="activeTab.title" :database-type="activeEffectiveDatabaseType" :dimension="activeTabDimension" :tenant="activeVectorConnection?.username" />
|
||||
<VectorBrowser
|
||||
:key="activeTab.id"
|
||||
:connection-id="activeTab.connectionId"
|
||||
:database="activeTab.database"
|
||||
:collection="activeTab.sql"
|
||||
:collection-label="activeTab.title"
|
||||
:database-type="activeEffectiveDatabaseType"
|
||||
:dimension="activeTabDimension"
|
||||
:tenant="activeVectorConnection?.username"
|
||||
:result="activeTab.result"
|
||||
@update:result="queryStore.updateTabPageResult(activeTab.id, activeTab.mode, $event, activeTab)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2491,7 +2508,7 @@ defineExpose({
|
||||
|
||||
<template v-else-if="activeTab.mode === 'databases' && activeConnection">
|
||||
<div class="min-w-0 flex-1 min-h-0">
|
||||
<DatabaseBrowser ref="databaseBrowserRef" :connection="activeConnection" />
|
||||
<DatabaseBrowser ref="databaseBrowserRef" :key="activeTab.id" :connection="activeConnection" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -140,7 +140,13 @@ const SHARED_RESULT_PANE_DEFAULT_SIZE = 32;
|
||||
const SHARED_RESULT_PANE_STORAGE_KEY = "dbx-shared-results-pane-size";
|
||||
const storedResultPaneSize = Number(safeLocalStorageGet(SHARED_RESULT_PANE_STORAGE_KEY));
|
||||
const resultPaneSize = ref(Number.isFinite(storedResultPaneSize) && storedResultPaneSize >= SHARED_RESULT_PANE_MIN_SIZE && storedResultPaneSize <= SHARED_RESULT_PANE_MAX_SIZE ? storedResultPaneSize : SHARED_RESULT_PANE_DEFAULT_SIZE);
|
||||
const showResultPane = ref(true);
|
||||
const showResultPane = computed({
|
||||
get: () => activeTab.value?.uiState?.resultPaneOpen ?? true,
|
||||
set: (open: boolean) => {
|
||||
const tab = activeTab.value;
|
||||
if (tab?.mode === "query") queryStore.updateTabUiState(tab.id, { resultPaneOpen: open });
|
||||
},
|
||||
});
|
||||
const isResultPaneVisible = computed(() => hasSharedOutput.value && showResultPane.value);
|
||||
// Keep the pane mounted, but apply its target size immediately so available
|
||||
// results never wait for an expansion animation before becoming visible.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ConnectionConfig, ObjectBrowserFilter, ObjectBrowserViewport, QueryTab } from "@/types/database";
|
||||
import type { ConnectionConfig, ObjectBrowserFilter, ObjectBrowserViewport, QueryTab, TabOutputView } from "@/types/database";
|
||||
import type { DataGridReloadIntent } from "@/lib/dataGrid/dataGridToolbar";
|
||||
import type { DataGridSortMode } from "@/lib/dataGrid/dataGridSort";
|
||||
import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation";
|
||||
@@ -44,7 +44,7 @@ export interface ContentAreaSurfaceProps {
|
||||
activeTab: QueryTab;
|
||||
activeConnection?: ConnectionConfig;
|
||||
executableSql: string;
|
||||
activeOutputView: "result" | "summary" | "explain" | "chart" | "messages" | "profile";
|
||||
activeOutputView: TabOutputView;
|
||||
formatSqlRequest: { id: number; tabId: string } | null;
|
||||
compressSqlRequest: { id: number; tabId: string } | null;
|
||||
selectedSql: string;
|
||||
@@ -61,7 +61,7 @@ export interface ContentAreaSurfaceProps {
|
||||
* global active tab after an await (see redevelopment guide §6.1).
|
||||
*/
|
||||
export interface ContentAreaSurfaceEmits {
|
||||
"update:activeOutputView": [tabId: string, value: "result" | "summary" | "explain" | "chart" | "messages" | "profile"];
|
||||
"update:activeOutputView": [tabId: string, value: TabOutputView];
|
||||
fixWithAi: [tabId: string, errorMessage: string];
|
||||
sendSelectionToAi: [tabId: string, sql: string];
|
||||
previewChangesAvailable: [tabId: string, value: boolean];
|
||||
|
||||
@@ -17,6 +17,7 @@ import * as api from "@/lib/backend/api";
|
||||
import type { MeilisearchIndexOverview } from "@/lib/backend/tauri";
|
||||
import { formatBytes } from "@/lib/database/serverMetrics";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
import MeilisearchDocumentsPage from "./MeilisearchDocumentsPage.vue";
|
||||
import MeilisearchSettingsPage from "./MeilisearchSettingsPage.vue";
|
||||
import MeilisearchTasksPage from "./MeilisearchTasksPage.vue";
|
||||
@@ -42,12 +43,16 @@ const props = defineProps<{
|
||||
|
||||
type ActiveSection = "documents" | "tasks" | "settings";
|
||||
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ activeSection?: ActiveSection }>({}, "MeilisearchIndexView");
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const { toast } = useToast();
|
||||
|
||||
const activeSection = ref<ActiveSection>("documents");
|
||||
const activeSection = ref<ActiveSection>(restoredUiState.activeSection ?? "documents");
|
||||
const overview = ref<MeilisearchIndexOverview | null>(null);
|
||||
|
||||
trackUiState(() => ({ activeSection: activeSection.value }));
|
||||
|
||||
const navSections = computed<Array<{ value: ActiveSection; label: string; icon: typeof FileText }>>(() => [
|
||||
{ value: "documents", label: t("meilisearch.documents"), icon: FileText },
|
||||
{ value: "tasks", label: t("meilisearch.tasks"), icon: ListChecks },
|
||||
|
||||
@@ -3,14 +3,17 @@ import { computed, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Gauge, KeyRound, ListChecks } from "@lucide/vue";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
import MeilisearchOverviewPage from "./MeilisearchOverviewPage.vue";
|
||||
import MeilisearchKeysPage from "./MeilisearchKeysPage.vue";
|
||||
import MeilisearchTasksPage from "./MeilisearchTasksPage.vue";
|
||||
|
||||
defineProps<{ connectionId: string }>();
|
||||
type Section = "overview" | "keys" | "tasks";
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ active?: Section }>({}, "MeilisearchSystemWorkspace");
|
||||
const { t } = useI18n();
|
||||
const active = ref<Section>("overview");
|
||||
const active = ref<Section>(restoredUiState.active ?? "overview");
|
||||
trackUiState(() => ({ active: active.value }));
|
||||
const sections = computed(() => [
|
||||
{ value: "overview" as const, label: t("meilisearch.overview"), icon: Gauge },
|
||||
{ value: "keys" as const, label: t("meilisearch.apiKeys"), icon: KeyRound },
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/lib/mq/mqConsoleDefaults";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { AcceptableValue } from "reka-ui";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
import TenantsPanel from "./TenantsPanel.vue";
|
||||
import NamespacesPanel from "./NamespacesPanel.vue";
|
||||
import TopicsPanel from "./TopicsPanel.vue";
|
||||
@@ -53,6 +54,19 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
interface MqTabUiState {
|
||||
activeTab?: MqTab;
|
||||
selectedTenant?: string;
|
||||
selectedNamespace?: string;
|
||||
selectedTopic?: Pick<TopicInfo, "name" | "shortName" | "partitioned" | "persistent" | "namespace">;
|
||||
selectedSubscriptionName?: string;
|
||||
preferDlqTopic?: boolean;
|
||||
showCreateNamespaceDialog?: boolean;
|
||||
createNamespaceName?: string;
|
||||
}
|
||||
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<MqTabUiState>({}, "MqAdminConsole");
|
||||
const connectionStore = useConnectionStore();
|
||||
const { confirmMqWrite } = useMqMutationGuard(() => props.connectionId);
|
||||
const isProductionConnection = computed(() => !!connectionStore.getConfig(props.connectionId)?.is_production);
|
||||
@@ -77,30 +91,50 @@ function initialMqNamespace(systemKind: MqSystemKind | undefined): string | unde
|
||||
|
||||
// State
|
||||
const activeTab = ref<MqTab>(
|
||||
resolveInitialMqTab({
|
||||
initialTab: props.initialTab,
|
||||
initialTenant: props.initialTenant,
|
||||
systemKind: configuredSystemKind.value,
|
||||
}),
|
||||
restoredUiState.activeTab ??
|
||||
resolveInitialMqTab({
|
||||
initialTab: props.initialTab,
|
||||
initialTenant: props.initialTenant,
|
||||
systemKind: configuredSystemKind.value,
|
||||
}),
|
||||
);
|
||||
const selectedTenant = ref<string | undefined>(normalizeFlatMqTenant(props.initialTenant, configuredSystemKind.value));
|
||||
const selectedNamespace = ref<string | undefined>(initialMqNamespace(configuredSystemKind.value));
|
||||
const selectedTopic = ref<TopicInfo>();
|
||||
const selectedSubscriptionName = ref<string>();
|
||||
const selectedTenant = ref<string | undefined>(restoredUiState.selectedTenant ?? normalizeFlatMqTenant(props.initialTenant, configuredSystemKind.value));
|
||||
const selectedNamespace = ref<string | undefined>(restoredUiState.selectedNamespace ?? initialMqNamespace(configuredSystemKind.value));
|
||||
const selectedTopic = ref<TopicInfo | undefined>(restoredUiState.selectedTopic);
|
||||
const selectedSubscriptionName = ref<string | undefined>(restoredUiState.selectedSubscriptionName);
|
||||
const capabilities = ref<MqClusterInfo["capabilities"]>();
|
||||
const clusterInfo = ref<MqClusterInfo>();
|
||||
const loading = ref(false);
|
||||
const error = ref<string>();
|
||||
const preferDlqTopic = ref(props.initialTab === "dlq");
|
||||
const preferDlqTopic = ref(restoredUiState.preferDlqTopic ?? props.initialTab === "dlq");
|
||||
|
||||
// RabbitMQ vhost switcher (tab-bar namespace dropdown).
|
||||
const CREATE_NAMESPACE_VALUE = "__create_namespace__";
|
||||
const rabbitMqVhosts = ref<string[]>([]);
|
||||
const showCreateNamespaceDialog = ref(false);
|
||||
const createNamespaceName = ref("");
|
||||
const showCreateNamespaceDialog = ref(restoredUiState.showCreateNamespaceDialog ?? false);
|
||||
const createNamespaceName = ref(restoredUiState.createNamespaceName ?? "");
|
||||
const createNamespaceError = ref<string>();
|
||||
const creatingNamespace = ref(false);
|
||||
|
||||
trackUiState(() => ({
|
||||
activeTab: activeTab.value,
|
||||
selectedTenant: selectedTenant.value,
|
||||
selectedNamespace: selectedNamespace.value,
|
||||
selectedTopic: selectedTopic.value
|
||||
? {
|
||||
name: selectedTopic.value.name,
|
||||
shortName: selectedTopic.value.shortName,
|
||||
partitioned: selectedTopic.value.partitioned,
|
||||
persistent: selectedTopic.value.persistent,
|
||||
namespace: selectedTopic.value.namespace,
|
||||
}
|
||||
: undefined,
|
||||
selectedSubscriptionName: selectedSubscriptionName.value,
|
||||
preferDlqTopic: preferDlqTopic.value,
|
||||
showCreateNamespaceDialog: showCreateNamespaceDialog.value,
|
||||
createNamespaceName: createNamespaceName.value,
|
||||
}));
|
||||
|
||||
// Computed
|
||||
const mqSystemKind = computed<MqSystemKind | undefined>(() => clusterInfo.value?.systemKind ?? configuredSystemKind.value);
|
||||
const isFlatMqCluster = computed(() => isFlatMqSystemKind(mqSystemKind.value));
|
||||
|
||||
@@ -10,6 +10,7 @@ import MqttPublishPanel from "./MqttPublishDialog.vue";
|
||||
import { decodePayload, PAYLOAD_ENCODINGS, PAYLOAD_ENCODING_LABELS, type PayloadEncoding } from "@/lib/mqtt/mqttPayloadCodec";
|
||||
import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
|
||||
import { ChevronDown, ChevronUp, Pause, Play } from "@lucide/vue";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
interface Props {
|
||||
connectionId: string;
|
||||
@@ -18,28 +19,60 @@ interface Props {
|
||||
const props = defineProps<Props>();
|
||||
const { t } = useI18n();
|
||||
|
||||
interface MqttTabUiState {
|
||||
selectedTopic?: string;
|
||||
messagesPaused?: boolean;
|
||||
displayEncoding?: PayloadEncoding;
|
||||
topicSearch?: string;
|
||||
payloadSearch?: string;
|
||||
showSubscriptionDialog?: boolean;
|
||||
formTopic?: string;
|
||||
formQos?: MqttQoS;
|
||||
formNoLocal?: boolean;
|
||||
formEnabled?: boolean;
|
||||
editingTopic?: string | null;
|
||||
publishPanelCollapsed?: boolean;
|
||||
}
|
||||
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<MqttTabUiState>({}, "MqttAdminConsole");
|
||||
|
||||
const brokerInfo = ref<MqttBrokerInfo | null>(null);
|
||||
const savedTopics = ref<MqttSavedTopic[]>([]);
|
||||
const subscribedTopics = ref<[string, string][]>([]);
|
||||
const messages = ref<MqttMessage[]>([]);
|
||||
const noLocalSubscribe = ref(false);
|
||||
const selectedTopic = ref<string>(props.initialTopic ?? "");
|
||||
const selectedTopic = ref<string>(restoredUiState.selectedTopic ?? props.initialTopic ?? "");
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null);
|
||||
const messagesPaused = ref(false);
|
||||
const displayEncoding = ref<PayloadEncoding>("plaintext");
|
||||
const topicSearch = ref("");
|
||||
const payloadSearch = ref("");
|
||||
const showSubscriptionDialog = ref(false);
|
||||
const messagesPaused = ref(restoredUiState.messagesPaused ?? false);
|
||||
const displayEncoding = ref<PayloadEncoding>(restoredUiState.displayEncoding ?? "plaintext");
|
||||
const topicSearch = ref(restoredUiState.topicSearch ?? "");
|
||||
const payloadSearch = ref(restoredUiState.payloadSearch ?? "");
|
||||
const showSubscriptionDialog = ref(restoredUiState.showSubscriptionDialog ?? false);
|
||||
const savingSubscription = ref(false);
|
||||
const formTopic = ref("");
|
||||
const formQos = ref<MqttQoS>("atmostonce");
|
||||
const formNoLocal = ref(false);
|
||||
const formEnabled = ref(true);
|
||||
const editingTopic = ref<string | null>(null);
|
||||
const formTopic = ref(restoredUiState.formTopic ?? "");
|
||||
const formQos = ref<MqttQoS>(restoredUiState.formQos ?? "atmostonce");
|
||||
const formNoLocal = ref(restoredUiState.formNoLocal ?? false);
|
||||
const formEnabled = ref(restoredUiState.formEnabled ?? true);
|
||||
const editingTopic = ref<string | null>(restoredUiState.editingTopic ?? null);
|
||||
const MQTT_PUBLISH_PANEL_COLLAPSED_STORAGE_KEY = "dbx-mqtt-publish-panel-collapsed";
|
||||
const publishPanelCollapsed = ref(safeLocalStorageGet(MQTT_PUBLISH_PANEL_COLLAPSED_STORAGE_KEY) === "true");
|
||||
const publishPanelCollapsed = ref(restoredUiState.publishPanelCollapsed ?? safeLocalStorageGet(MQTT_PUBLISH_PANEL_COLLAPSED_STORAGE_KEY) === "true");
|
||||
|
||||
trackUiState(() => ({
|
||||
selectedTopic: selectedTopic.value,
|
||||
messagesPaused: messagesPaused.value,
|
||||
displayEncoding: displayEncoding.value,
|
||||
topicSearch: topicSearch.value,
|
||||
payloadSearch: payloadSearch.value,
|
||||
showSubscriptionDialog: showSubscriptionDialog.value,
|
||||
formTopic: formTopic.value,
|
||||
formQos: formQos.value,
|
||||
formNoLocal: formNoLocal.value,
|
||||
formEnabled: formEnabled.value,
|
||||
editingTopic: editingTopic.value,
|
||||
publishPanelCollapsed: publishPanelCollapsed.value,
|
||||
}));
|
||||
|
||||
const connected = computed(() => brokerInfo.value?.connected ?? false);
|
||||
const mqtt5 = computed(() => brokerInfo.value?.protocolVersion?.includes("5") ?? false);
|
||||
|
||||
@@ -9,6 +9,7 @@ import NacosRoleAccessControl from "@/components/nacos/NacosRoleAccessControl.vu
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import type { NacosConnectionInfo } from "@/types/nacos";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
@@ -20,13 +21,16 @@ type AccessControlWorkspace = { refresh: () => Promise<void> };
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ activeTab?: AccessTab }>({}, "NacosAccessControlConsole");
|
||||
const connectionInfo = ref<NacosConnectionInfo | null>(null);
|
||||
const connectionError = ref("");
|
||||
const loading = ref(false);
|
||||
const activeTab = ref<AccessTab>("users");
|
||||
const activeTab = ref<AccessTab>(restoredUiState.activeTab === "roles" ? "roles" : "users");
|
||||
const legacyWorkspace = ref<AccessControlWorkspace | null>(null);
|
||||
const enhancedWorkspaceRef = ref<AccessControlWorkspace | null>(null);
|
||||
|
||||
trackUiState(() => ({ activeTab: activeTab.value }));
|
||||
|
||||
const accessControl = computed(() => connectionInfo.value?.capabilities.accessControl);
|
||||
const supportsUsers = computed(() => accessControl.value?.listUsers.supported === true);
|
||||
const supportsRoles = computed(() => accessControl.value?.mode === "roleBindings" && accessControl.value.listRoleBindings.supported === true);
|
||||
|
||||
@@ -87,6 +87,7 @@ import type {
|
||||
} from "@/types/nacos";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
@@ -100,6 +101,28 @@ const props = defineProps<{
|
||||
zenMode?: boolean;
|
||||
}>();
|
||||
|
||||
interface NacosTabUiState {
|
||||
activeTab?: AdminTab;
|
||||
configGroup?: string;
|
||||
configDataId?: string;
|
||||
configAppName?: string;
|
||||
configPageNo?: number;
|
||||
configPageSize?: number;
|
||||
configAdvancedOpen?: boolean;
|
||||
historyOpen?: boolean;
|
||||
historyPageNo?: number;
|
||||
historyPageSize?: number;
|
||||
serviceGroup?: string;
|
||||
serviceName?: string;
|
||||
serviceCluster?: string;
|
||||
servicePageNo?: number;
|
||||
servicePageSize?: number;
|
||||
serviceDetailExpanded?: boolean;
|
||||
nacosSplitSize?: number;
|
||||
}
|
||||
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<NacosTabUiState>({}, "NacosAdminConsole");
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleZenMode: [];
|
||||
}>();
|
||||
@@ -113,21 +136,21 @@ const settingsStore = useSettingsStore();
|
||||
const connectionStore = useConnectionStore();
|
||||
const queryStore = useQueryStore();
|
||||
const { isDark, themePalette } = useTheme();
|
||||
const activeTab = ref<AdminTab>("configs");
|
||||
const activeTab = ref<AdminTab>(restoredUiState.activeTab ?? "configs");
|
||||
const connectionInfo = ref<NacosConnectionInfo | null>(null);
|
||||
const connectionError = ref("");
|
||||
const infoLoading = ref(false);
|
||||
|
||||
const configLoading = ref(false);
|
||||
const configError = ref("");
|
||||
const configGroup = ref("");
|
||||
const configDataId = ref("");
|
||||
const configAppName = ref("");
|
||||
const configPageNo = ref(1);
|
||||
const configGroup = ref(restoredUiState.configGroup ?? "");
|
||||
const configDataId = ref(restoredUiState.configDataId ?? "");
|
||||
const configAppName = ref(restoredUiState.configAppName ?? "");
|
||||
const configPageNo = ref(restoredUiState.configPageNo ?? 1);
|
||||
const NACOS_CONFIG_PAGE_SIZE_OPTIONS = [20, 50, 100, 200, 500] as const;
|
||||
const NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY = "dbx-nacos-config-page-size";
|
||||
const savedNacosConfigPageSize = Number(safeLocalStorageGet(NACOS_CONFIG_PAGE_SIZE_STORAGE_KEY));
|
||||
const configPageSize = ref<number>(NACOS_CONFIG_PAGE_SIZE_OPTIONS.find((size) => size === savedNacosConfigPageSize) ?? 20);
|
||||
const configPageSize = ref<number>(restoredUiState.configPageSize ?? NACOS_CONFIG_PAGE_SIZE_OPTIONS.find((size) => size === savedNacosConfigPageSize) ?? 20);
|
||||
const configs = ref<NacosConfigItem[]>([]);
|
||||
const configTotal = ref(0);
|
||||
const selectedConfig = ref<NacosConfigItem | null>(null);
|
||||
@@ -139,7 +162,7 @@ const originalConfigType = ref("text");
|
||||
const originalConfigMetadata = ref({ appName: "", desc: "", tags: "" });
|
||||
const savingConfig = ref(false);
|
||||
const deletingConfig = ref(false);
|
||||
const configAdvancedOpen = ref(false);
|
||||
const configAdvancedOpen = ref(restoredUiState.configAdvancedOpen ?? false);
|
||||
const configSaveNotice = ref("");
|
||||
const configValidationOpen = ref(false);
|
||||
const configValidationDiagnostics = ref<NacosConfigDiagnostic[]>([]);
|
||||
@@ -151,12 +174,12 @@ interface NacosBatchDeleteSnapshot {
|
||||
keys: NacosConfigKey[];
|
||||
}
|
||||
const pendingBatchDelete = ref<NacosBatchDeleteSnapshot | null>(null);
|
||||
const historyOpen = ref(false);
|
||||
const historyOpen = ref(restoredUiState.historyOpen ?? false);
|
||||
const historyLoading = ref(false);
|
||||
const historyError = ref("");
|
||||
const historyItems = ref<NacosConfigHistoryItem[]>([]);
|
||||
const historyPageNo = ref(1);
|
||||
const historyPageSize = ref(20);
|
||||
const historyPageNo = ref(restoredUiState.historyPageNo ?? 1);
|
||||
const historyPageSize = ref(restoredUiState.historyPageSize ?? 20);
|
||||
const historyTotal = ref(0);
|
||||
const historyViewingItem = ref<NacosConfigHistoryItem | null>(null);
|
||||
const historyViewingContent = ref("");
|
||||
@@ -230,18 +253,18 @@ let latestConfigSaveRequestId = 0;
|
||||
|
||||
const servicesLoading = ref(false);
|
||||
const servicesError = ref("");
|
||||
const serviceGroup = ref("");
|
||||
const serviceName = ref("");
|
||||
const serviceCluster = ref("");
|
||||
const servicePageNo = ref(1);
|
||||
const servicePageSize = ref(20);
|
||||
const serviceGroup = ref(restoredUiState.serviceGroup ?? "");
|
||||
const serviceName = ref(restoredUiState.serviceName ?? "");
|
||||
const serviceCluster = ref(restoredUiState.serviceCluster ?? "");
|
||||
const servicePageNo = ref(restoredUiState.servicePageNo ?? 1);
|
||||
const servicePageSize = ref(restoredUiState.servicePageSize ?? 20);
|
||||
const services = ref<NacosServiceInfo[]>([]);
|
||||
const serviceTotal = ref(0);
|
||||
const selectedService = ref<NacosServiceInfo | null>(null);
|
||||
const selectedServiceDetail = ref<NacosServiceDetail | null>(null);
|
||||
const serviceDetailLoading = ref(false);
|
||||
const serviceDetailError = ref("");
|
||||
const serviceDetailExpanded = ref(false);
|
||||
const serviceDetailExpanded = ref(restoredUiState.serviceDetailExpanded ?? false);
|
||||
const instances = ref<NacosInstanceInfo[]>([]);
|
||||
const instancesLoading = ref(false);
|
||||
const instancesError = ref("");
|
||||
@@ -275,7 +298,27 @@ let serviceMutationSequence = 0;
|
||||
|
||||
const NACOS_SPLIT_SIZE_KEY = "dbx-nacos-admin-split-size";
|
||||
const savedNacosSplitSize = Number(safeLocalStorageGet(NACOS_SPLIT_SIZE_KEY));
|
||||
const nacosSplitSize = ref(savedNacosSplitSize >= 20 && savedNacosSplitSize <= 80 ? savedNacosSplitSize : 42);
|
||||
const nacosSplitSize = ref(restoredUiState.nacosSplitSize ?? (savedNacosSplitSize >= 20 && savedNacosSplitSize <= 80 ? savedNacosSplitSize : 42));
|
||||
|
||||
trackUiState(() => ({
|
||||
activeTab: activeTab.value,
|
||||
configGroup: configGroup.value,
|
||||
configDataId: configDataId.value,
|
||||
configAppName: configAppName.value,
|
||||
configPageNo: configPageNo.value,
|
||||
configPageSize: configPageSize.value,
|
||||
configAdvancedOpen: configAdvancedOpen.value,
|
||||
historyOpen: historyOpen.value,
|
||||
historyPageNo: historyPageNo.value,
|
||||
historyPageSize: historyPageSize.value,
|
||||
serviceGroup: serviceGroup.value,
|
||||
serviceName: serviceName.value,
|
||||
serviceCluster: serviceCluster.value,
|
||||
servicePageNo: servicePageNo.value,
|
||||
servicePageSize: servicePageSize.value,
|
||||
serviceDetailExpanded: serviceDetailExpanded.value,
|
||||
nacosSplitSize: nacosSplitSize.value,
|
||||
}));
|
||||
const CONNECTION_NOT_FOUND_RETRY_DELAYS_MS = [150, 350, 700];
|
||||
const configListViewport = ref<HTMLElement | null>(null);
|
||||
const configListViewportWidth = ref(0);
|
||||
@@ -2605,7 +2648,7 @@ onMounted(async () => {
|
||||
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
|
||||
}
|
||||
await loadInfo();
|
||||
await Promise.all([loadConfigsWithRetry(1), loadServicesWithRetry(1)]);
|
||||
await Promise.all([loadConfigsWithRetry(configPageNo.value), loadServicesWithRetry(servicePageNo.value)]);
|
||||
if (props.targetDataId) await openTargetConfig(props.targetDataId, props.targetGroup || "DEFAULT_GROUP", props.targetKeyword);
|
||||
else await restoreSelectedConfig();
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type NacosDashboardSample,
|
||||
type NullableMetric,
|
||||
} from "@/lib/nacos/nacosDashboard";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const props = defineProps<{
|
||||
connectionId: string;
|
||||
@@ -33,15 +34,18 @@ const props = defineProps<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const connectionStore = useConnectionStore();
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<{ autoRefreshInterval?: number }>({}, "NacosDashboard");
|
||||
const loading = ref(false);
|
||||
const fetching = ref(false);
|
||||
const error = ref("");
|
||||
const samples = ref<NacosDashboardSample[]>([]);
|
||||
const autoRefreshInterval = ref(10);
|
||||
const autoRefreshInterval = ref([0, 5, 10, 30, 60].includes(restoredUiState.autoRefreshInterval ?? -1) ? restoredUiState.autoRefreshInterval! : 10);
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let activeRequestKey = "";
|
||||
let latestRequestId = 0;
|
||||
|
||||
trackUiState(() => ({ autoRefreshInterval: autoRefreshInterval.value }));
|
||||
|
||||
const latest = computed(() => samples.value[samples.value.length - 1]);
|
||||
const snapshot = computed(() => latest.value?.snapshot);
|
||||
const metrics = computed(() => snapshot.value?.metrics);
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@ describe("NacosAdminConsole config workbench layout", () => {
|
||||
});
|
||||
|
||||
it("keeps verbose service details collapsed until the user expands them", () => {
|
||||
expect(source).toContain("const serviceDetailExpanded = ref(false);");
|
||||
expect(source).toContain("const serviceDetailExpanded = ref(restoredUiState.serviceDetailExpanded ?? false);");
|
||||
});
|
||||
|
||||
it("reserves the cluster-clear action space so entering a filter cannot reflow the toolbar", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useToolbarOverflow } from "@/composables/useToolbarOverflow";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { filterDatabaseNamesForConnection } from "@/lib/database/visibleDatabases";
|
||||
import { formatObjectBrowserBytes, formatObjectBrowserTimestamp } from "@/lib/table/objectBrowserRows";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import type { ConnectionConfig } from "@/types/database";
|
||||
@@ -28,21 +29,7 @@ type DatabaseBrowserColumnKey = "name" | "sizeBytes" | "createdAt" | "updatedAt"
|
||||
type DatabaseBrowserSortKey = DatabaseBrowserColumnKey;
|
||||
type SortDirection = "asc" | "desc";
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const searchInput = ref<InstanceType<typeof Input>>();
|
||||
const search = ref("");
|
||||
const rows = ref<DatabaseRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const sortKey = ref<DatabaseBrowserSortKey>("name");
|
||||
const sortDirection = ref<SortDirection>("asc");
|
||||
const columnWidths = ref<Record<DatabaseBrowserColumnKey, number>>({
|
||||
const DEFAULT_COLUMN_WIDTHS: Record<DatabaseBrowserColumnKey, number> = {
|
||||
name: 260,
|
||||
sizeBytes: 100,
|
||||
createdAt: 150,
|
||||
@@ -50,9 +37,43 @@ const columnWidths = ref<Record<DatabaseBrowserColumnKey, number>>({
|
||||
defaultCharset: 130,
|
||||
defaultCollation: 170,
|
||||
comment: 260,
|
||||
});
|
||||
};
|
||||
const DATABASE_BROWSER_COLUMN_KEYS = Object.keys(DEFAULT_COLUMN_WIDTHS) as DatabaseBrowserColumnKey[];
|
||||
|
||||
interface DatabaseBrowserTabUiState {
|
||||
search?: string;
|
||||
sortKey?: DatabaseBrowserSortKey;
|
||||
sortDirection?: SortDirection;
|
||||
columnWidths?: Partial<Record<DatabaseBrowserColumnKey, number>>;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
connection: ConnectionConfig;
|
||||
}>();
|
||||
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<DatabaseBrowserTabUiState>({}, "DatabaseBrowser");
|
||||
const { t } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const searchInput = ref<InstanceType<typeof Input>>();
|
||||
const search = ref(restoredUiState.search ?? "");
|
||||
const rows = ref<DatabaseRow[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref("");
|
||||
const sortKey = ref<DatabaseBrowserSortKey>(DATABASE_BROWSER_COLUMN_KEYS.includes(restoredUiState.sortKey as DatabaseBrowserColumnKey) ? restoredUiState.sortKey! : "name");
|
||||
const sortDirection = ref<SortDirection>(restoredUiState.sortDirection === "desc" ? "desc" : "asc");
|
||||
const columnWidths = ref<Record<DatabaseBrowserColumnKey, number>>(
|
||||
Object.fromEntries(
|
||||
DATABASE_BROWSER_COLUMN_KEYS.map((key) => {
|
||||
const restored = restoredUiState.columnWidths?.[key];
|
||||
return [key, typeof restored === "number" && Number.isFinite(restored) ? Math.max(key === "name" || key === "comment" ? 120 : 72, restored) : DEFAULT_COLUMN_WIDTHS[key]];
|
||||
}),
|
||||
) as Record<DatabaseBrowserColumnKey, number>,
|
||||
);
|
||||
let stopColumnResize: (() => void) | null = null;
|
||||
|
||||
trackUiState(() => ({ search: search.value, sortKey: sortKey.value, sortDirection: sortDirection.value, columnWidths: columnWidths.value }));
|
||||
|
||||
const hasSize = computed(() => rows.value.some((row) => row.sizeBytes != null));
|
||||
const hasCreatedAt = computed(() => rows.value.some((row) => !!row.createdAt?.trim()));
|
||||
const hasUpdatedAt = computed(() => rows.value.some((row) => !!row.updatedAt?.trim()));
|
||||
@@ -233,10 +254,12 @@ watch(sortKeyOptions, (options) => {
|
||||
}
|
||||
});
|
||||
|
||||
let initialConnectionWatch = true;
|
||||
watch(
|
||||
() => props.connection.id,
|
||||
() => {
|
||||
search.value = "";
|
||||
if (initialConnectionWatch) initialConnectionWatch = false;
|
||||
else search.value = "";
|
||||
void refresh();
|
||||
},
|
||||
{ immediate: true },
|
||||
|
||||
@@ -10,6 +10,7 @@ import * as api from "@/lib/backend/api";
|
||||
import type { RedisNodeEndpoint, RedisCommandResult } from "@/lib/backend/api";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -35,6 +36,15 @@ const { t } = useI18n();
|
||||
const { toast } = useToast();
|
||||
const connectionStore = useConnectionStore();
|
||||
|
||||
interface RedisDashboardTabUiState {
|
||||
selectedNodeAddress?: string;
|
||||
searchQuery?: string;
|
||||
collapsedSections?: string[];
|
||||
autoRefreshInterval?: number;
|
||||
}
|
||||
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<RedisDashboardTabUiState>({}, "RedisDashboard");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -44,9 +54,11 @@ const error = ref<string | null>(null);
|
||||
const rawSections = ref<InfoSection[]>([]);
|
||||
const masterNodes = ref<RedisNodeEndpoint[]>([]);
|
||||
const selectedNodeIndex = ref("0"); // string for Select compatibility
|
||||
const searchQuery = ref("");
|
||||
const collapsedSections = ref<Set<string>>(new Set());
|
||||
const autoRefreshInterval = ref<number>(0); // 0 = off, 5, 10, 30, 60 (seconds)
|
||||
const searchQuery = ref(restoredUiState.searchQuery ?? "");
|
||||
const collapsedSections = ref<Set<string>>(new Set(restoredUiState.collapsedSections ?? []));
|
||||
const autoRefreshInterval = ref<number>([0, 5, 10, 30, 60].includes(restoredUiState.autoRefreshInterval ?? -1) ? restoredUiState.autoRefreshInterval! : 0); // 0 = off
|
||||
let collapsedSectionsInitialized = restoredUiState.collapsedSections !== undefined;
|
||||
let restoredNodeAddress = restoredUiState.selectedNodeAddress;
|
||||
|
||||
const infoSectionI18nKeys: Record<string, string> = {
|
||||
Server: "redis.dashboard.sections.server",
|
||||
@@ -80,6 +92,20 @@ const nodeOptions = computed(() => {
|
||||
return masterNodes.value.map((n) => `${n.host}:${n.port}`);
|
||||
});
|
||||
|
||||
trackUiState(() => ({
|
||||
selectedNodeAddress: nodeOptions.value[Number(selectedNodeIndex.value)],
|
||||
searchQuery: searchQuery.value,
|
||||
collapsedSections: [...collapsedSections.value],
|
||||
autoRefreshInterval: autoRefreshInterval.value,
|
||||
}));
|
||||
|
||||
function restoreSelectedNode() {
|
||||
if (!restoredNodeAddress) return;
|
||||
const index = nodeOptions.value.indexOf(restoredNodeAddress);
|
||||
if (index >= 0) selectedNodeIndex.value = String(index);
|
||||
restoredNodeAddress = undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -123,6 +149,7 @@ async function fetchInfo() {
|
||||
const [host, portStr] = addr.split(":");
|
||||
return { host, port: parseInt(portStr, 10) || 6379 };
|
||||
});
|
||||
restoreSelectedNode();
|
||||
}
|
||||
// Match selected node by address, fall back to first node
|
||||
const selectedAddr = nodeOptions.value[Number(selectedNodeIndex.value)];
|
||||
@@ -138,8 +165,9 @@ async function fetchInfo() {
|
||||
|
||||
rawSections.value = parseInfoText(infoText);
|
||||
// Default all sections to collapsed (only on first load to preserve user interactions)
|
||||
if (collapsedSections.value.size === 0) {
|
||||
if (!collapsedSectionsInitialized) {
|
||||
collapsedSections.value = new Set(rawSections.value.map((s) => s.name));
|
||||
collapsedSectionsInitialized = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || String(e);
|
||||
@@ -154,6 +182,7 @@ async function fetchClusterNodes() {
|
||||
if (!isClusterMode.value) return;
|
||||
try {
|
||||
masterNodes.value = await api.redisClusterMasterNodes(props.connectionId);
|
||||
restoreSelectedNode();
|
||||
} catch (e: any) {
|
||||
// Nodes will be populated from the INFO response instead
|
||||
console.warn("Failed to fetch cluster master nodes:", e?.message || e);
|
||||
|
||||
@@ -8,6 +8,7 @@ import QueryLoadingState from "@/components/common/QueryLoadingState.vue";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import { uuid } from "@/lib/common/utils";
|
||||
import type { DatabaseType, MilvusCollectionSchema, MilvusFieldInfo, QueryResult } from "@/types/database";
|
||||
import { useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const DataGrid = defineAsyncComponent(() => import("@/components/grid/DataGrid.vue"));
|
||||
const { t } = useI18n();
|
||||
@@ -22,20 +23,36 @@ const props = defineProps<{
|
||||
databaseType?: DatabaseType;
|
||||
dimension?: number;
|
||||
tenant?: string;
|
||||
result?: QueryResult;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:result": [result: QueryResult | undefined];
|
||||
}>();
|
||||
|
||||
interface VectorTabUiState {
|
||||
operationMode?: VectorOperationMode;
|
||||
requestText?: string;
|
||||
requestIsDefault?: boolean;
|
||||
searchVector?: string;
|
||||
searchTopK?: number;
|
||||
}
|
||||
const { initialState: restoredUiState, track: trackUiState } = useTabUiState<VectorTabUiState>({}, "VectorBrowser");
|
||||
|
||||
const loading = ref(false);
|
||||
const cancelling = ref(false);
|
||||
const executionId = ref("");
|
||||
const elapsedSeconds = ref("0.0");
|
||||
const error = ref("");
|
||||
const statusMessage = ref("");
|
||||
const result = ref<QueryResult>(emptyResult());
|
||||
const operationMode = ref<VectorOperationMode>("browse");
|
||||
const requestText = ref("");
|
||||
const requestIsDefault = ref(true);
|
||||
const searchVector = ref("");
|
||||
const searchTopK = ref(10);
|
||||
const emptyResultValue = emptyResult();
|
||||
const result = computed(() => props.result ?? emptyResultValue);
|
||||
const operationMode = ref<VectorOperationMode>(restoredUiState.operationMode ?? "browse");
|
||||
const requestText = ref(restoredUiState.requestText ?? "");
|
||||
const requestIsDefault = ref(restoredUiState.requestIsDefault ?? true);
|
||||
const searchVector = ref(restoredUiState.searchVector ?? "");
|
||||
const searchTopK = ref(restoredUiState.searchTopK ?? 10);
|
||||
let initialRequestStateHandled = false;
|
||||
let loadingTimer: ReturnType<typeof setInterval> | undefined;
|
||||
const milvusSchema = ref<MilvusCollectionSchema>();
|
||||
const milvusDetailError = ref("");
|
||||
@@ -90,9 +107,19 @@ const executeDisabled = computed(() => loading.value || !requestText.value.trim(
|
||||
watch(
|
||||
() => [props.connectionId, props.databaseType, props.database, props.collection] as const,
|
||||
() => {
|
||||
if (!initialRequestStateHandled) {
|
||||
initialRequestStateHandled = true;
|
||||
if (restoredUiState.requestText !== undefined) {
|
||||
if (props.databaseType === "milvus") void loadMilvusCollectionDetail();
|
||||
return;
|
||||
}
|
||||
resetRequest();
|
||||
if (props.databaseType === "milvus") void loadMilvusCollectionDetail();
|
||||
return;
|
||||
}
|
||||
invalidateRequest();
|
||||
resetRequest();
|
||||
result.value = emptyResult();
|
||||
emit("update:result", undefined);
|
||||
error.value = "";
|
||||
statusMessage.value = "";
|
||||
resetMilvusCollectionDetail();
|
||||
@@ -101,6 +128,8 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
trackUiState(() => ({ operationMode: operationMode.value, requestText: requestText.value, requestIsDefault: requestIsDefault.value, searchVector: searchVector.value, searchTopK: searchTopK.value }));
|
||||
|
||||
watch(
|
||||
() => [operationMode.value, searchVector.value, searchTopK.value, collectionDimension.value] as const,
|
||||
() => {
|
||||
@@ -391,7 +420,7 @@ function refreshResult() {
|
||||
return withLoading(async (id) => {
|
||||
const browseText = defaultRequestText(props.databaseType, props.database, props.collection, "browse");
|
||||
const nextResult = await executeRequestText(browseText);
|
||||
if (executionId.value === id) result.value = nextResult;
|
||||
if (executionId.value === id) emit("update:result", nextResult);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -402,12 +431,12 @@ function runRequest() {
|
||||
const nextResult = await executeRequestText(requestText.value);
|
||||
if (executionId.value !== id) return;
|
||||
if (operationMode.value === "browse" || operationMode.value === "search") {
|
||||
result.value = nextResult;
|
||||
emit("update:result", nextResult);
|
||||
} else {
|
||||
const browseText = defaultRequestText(props.databaseType, props.database, props.collection, "browse", true);
|
||||
const browseResult = await executeRequestText(browseText);
|
||||
if (executionId.value !== id) return;
|
||||
result.value = browseResult;
|
||||
emit("update:result", browseResult);
|
||||
statusMessage.value = t("vector.operationSuccess");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -21,6 +21,35 @@ function roundTrip(tabs: QueryTab[]) {
|
||||
}
|
||||
|
||||
describe("openTabsPersistence originalSql round-trip", () => {
|
||||
it("preserves per-tab output view state across a round-trip", () => {
|
||||
const [restored] = roundTrip([queryTab({ uiState: { activeOutputView: "chart", resultPaneOpen: false } })]);
|
||||
|
||||
expect(restored.uiState).toEqual({ activeOutputView: "chart", resultPaneOpen: false });
|
||||
});
|
||||
|
||||
it("preserves namespaced special-page state across a round-trip", () => {
|
||||
const uiState = {
|
||||
page: {
|
||||
etcd: {
|
||||
EtcdKeyBrowser: { mode: "search", searchQuery: "orders" },
|
||||
KvKeyBrowser: { selectedKey: "/orders/42", expandedGroupIds: ["group:/orders"] },
|
||||
},
|
||||
},
|
||||
};
|
||||
const [restored] = roundTrip([queryTab({ mode: "etcd", uiState })]);
|
||||
|
||||
expect(restored.uiState).toEqual(uiState);
|
||||
});
|
||||
|
||||
it("drops invalid per-tab output view state while restoring", () => {
|
||||
const [restored] = restoreOpenTabsPayload({
|
||||
tabs: [{ id: "t1", title: "query_1", connectionId: "c1", database: "db", mode: "query", sql: "", uiState: { activeOutputView: "invalid", resultPaneOpen: "false" } }],
|
||||
activeTabId: "t1",
|
||||
}).tabs;
|
||||
|
||||
expect(restored.uiState).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps clean saved SQL tabs eligible for file hydration", () => {
|
||||
const [restored] = roundTrip([queryTab({ savedSqlId: "saved", sql: "SELECT 1", originalSql: "SELECT 1" })]);
|
||||
expect(restored.sql).toBe("");
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/** @vitest-environment happy-dom */
|
||||
|
||||
import { createApp, defineComponent, h, nextTick, ref } from "vue";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { MAX_TAB_PAGE_UI_STATE_BYTES, provideTabUiState, sanitizeTabPageUiState, sanitizeTabUiState, useTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
const mounted: Array<{ unmount: () => void; host: HTMLElement }> = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const { unmount, host } of mounted.splice(0)) {
|
||||
unmount();
|
||||
host.remove();
|
||||
}
|
||||
});
|
||||
|
||||
describe("tabUiState", () => {
|
||||
it("restores a namespace and flushes its last value before unmount", async () => {
|
||||
const updates: unknown[] = [];
|
||||
const trackedValue = ref(0);
|
||||
let initialValue = 0;
|
||||
let captureCount = 0;
|
||||
|
||||
const Child = defineComponent({
|
||||
setup() {
|
||||
const state = useTabUiState<{ value?: number }>({}, "Panel");
|
||||
initialValue = state.initialState.value ?? 0;
|
||||
trackedValue.value = initialValue;
|
||||
state.track(() => ({ value: trackedValue.value }));
|
||||
return () => h("span", String(trackedValue.value));
|
||||
},
|
||||
});
|
||||
const Parent = defineComponent({
|
||||
setup() {
|
||||
provideTabUiState(() => {
|
||||
captureCount += 1;
|
||||
return {
|
||||
snapshot: { Panel: { value: 7 } },
|
||||
update: (patch) => updates.push(patch),
|
||||
};
|
||||
});
|
||||
return () => h(Child);
|
||||
},
|
||||
});
|
||||
|
||||
const host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
const app = createApp(Parent);
|
||||
app.mount(host);
|
||||
mounted.push({ unmount: () => app.unmount(), host });
|
||||
|
||||
expect(initialValue).toBe(7);
|
||||
expect(captureCount).toBe(1);
|
||||
|
||||
trackedValue.value = 8;
|
||||
await nextTick();
|
||||
expect(updates.at(-1)).toEqual({ Panel: { value: 8 } });
|
||||
|
||||
trackedValue.value = 9;
|
||||
app.unmount();
|
||||
mounted.length = 0;
|
||||
host.remove();
|
||||
expect(updates.at(-1)).toEqual({ Panel: { value: 9 } });
|
||||
});
|
||||
|
||||
it("rejects cyclic, oversized, and non-plain snapshots", () => {
|
||||
const cyclic: Record<string, unknown> = {};
|
||||
cyclic.self = cyclic;
|
||||
|
||||
expect(sanitizeTabPageUiState(cyclic)).toBeUndefined();
|
||||
expect(sanitizeTabPageUiState({ value: "x".repeat(MAX_TAB_PAGE_UI_STATE_BYTES + 1) })).toBeUndefined();
|
||||
expect(sanitizeTabPageUiState({ value: new Date() })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps only supported top-level state and JSON-compatible page values", () => {
|
||||
const state = sanitizeTabUiState({
|
||||
activeOutputView: "invalid",
|
||||
resultPaneOpen: false,
|
||||
ignored: true,
|
||||
page: {
|
||||
query: {
|
||||
Panel: {
|
||||
count: 3,
|
||||
invalidNumber: Number.POSITIVE_INFINITY,
|
||||
callback: () => undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
resultPaneOpen: false,
|
||||
page: { query: { Panel: { count: 3 } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import type { QueryTab, TabOutputView } from "@/types/database";
|
||||
import type { SavedOpenTab } from "@/lib/app/openTabsPersistence";
|
||||
|
||||
export type DetachedOutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
|
||||
export type DetachedOutputView = TabOutputView;
|
||||
|
||||
export interface DetachedTabRuntimeState {
|
||||
editorViewport?: QueryTab["editorViewport"];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { QueryTab } from "@/types/database";
|
||||
import type { QueryTab, TabOutputView } from "@/types/database";
|
||||
import { sanitizeTabUiState } from "@/lib/tabs/tabUiState";
|
||||
|
||||
export const OPEN_TABS_STORAGE_KEY = "dbx-open-tabs";
|
||||
export const ACTIVE_TAB_STORAGE_KEY = "dbx-active-tab";
|
||||
@@ -64,6 +65,7 @@ export interface SavedOpenTab {
|
||||
resultRuns?: SavedQueryResultRun[];
|
||||
activeResultRunId?: string;
|
||||
resultAutoSave?: boolean;
|
||||
uiState?: QueryTab["uiState"];
|
||||
}
|
||||
|
||||
export interface RestoredOpenTabs {
|
||||
@@ -129,6 +131,21 @@ function restoredEditorViewport(tab: SavedOpenTab): QueryTab["editorViewport"] {
|
||||
};
|
||||
}
|
||||
|
||||
const TAB_OUTPUT_VIEWS = new Set<TabOutputView>(["result", "summary", "explain", "chart", "messages", "profile"]);
|
||||
|
||||
function restoredTabUiState(tab: SavedOpenTab): QueryTab["uiState"] {
|
||||
const activeOutputView = tab.uiState?.activeOutputView;
|
||||
const resultPaneOpen = tab.uiState?.resultPaneOpen;
|
||||
const restored: NonNullable<QueryTab["uiState"]> = {};
|
||||
if (activeOutputView && TAB_OUTPUT_VIEWS.has(activeOutputView)) restored.activeOutputView = activeOutputView;
|
||||
if (typeof resultPaneOpen === "boolean") restored.resultPaneOpen = resultPaneOpen;
|
||||
if (tab.uiState?.page) {
|
||||
const sanitized = sanitizeTabUiState({ page: tab.uiState.page });
|
||||
if (sanitized?.page) restored.page = sanitized.page;
|
||||
}
|
||||
return Object.keys(restored).length > 0 ? restored : undefined;
|
||||
}
|
||||
|
||||
export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
|
||||
return tabs.map((tab) => ({
|
||||
id: tab.id,
|
||||
@@ -195,6 +212,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
|
||||
: {}),
|
||||
...(tab.mode === "query" && tab.activeResultRunId !== undefined ? { activeResultRunId: tab.activeResultRunId } : {}),
|
||||
...(tab.mode === "query" && typeof tab.resultAutoSave === "boolean" ? { resultAutoSave: tab.resultAutoSave } : {}),
|
||||
...(tab.uiState ? { uiState: sanitizeTabUiState(tab.uiState) } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -247,6 +265,7 @@ function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, op
|
||||
resultRuns,
|
||||
activeResultRunId: resultRuns?.some((run) => run.id === tab.activeResultRunId) ? tab.activeResultRunId : resultRuns?.[0]?.id,
|
||||
resultAutoSave: mode === "query" && typeof tab.resultAutoSave === "boolean" ? tab.resultAutoSave : undefined,
|
||||
uiState: restoredTabUiState(tab),
|
||||
};
|
||||
});
|
||||
const activeTabId = rawActiveTabId || null;
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { inject, onBeforeUnmount, provide, watch, type InjectionKey } from "vue";
|
||||
import type { TabPageUiState, TabUiState } from "@/types/database";
|
||||
|
||||
export const MAX_TAB_PAGE_UI_STATE_BYTES = 64 * 1024;
|
||||
|
||||
interface TabUiStateContext {
|
||||
snapshot: TabPageUiState;
|
||||
update: (patch: TabPageUiState) => void;
|
||||
}
|
||||
|
||||
const TAB_UI_STATE_KEY: InjectionKey<() => TabUiStateContext> = Symbol("dbx-tab-ui-state");
|
||||
|
||||
export function provideTabUiState(capture: () => TabUiStateContext): void {
|
||||
provide(TAB_UI_STATE_KEY, capture);
|
||||
}
|
||||
|
||||
export function useTabUiState<T extends object>(
|
||||
defaults: T,
|
||||
namespace?: string,
|
||||
): {
|
||||
initialState: T;
|
||||
update: (patch: Partial<T>) => void;
|
||||
track: (snapshot: () => Partial<T>) => void;
|
||||
} {
|
||||
const context = inject(TAB_UI_STATE_KEY, undefined)?.();
|
||||
const initialState = { ...defaults, ...sanitizeTabPageUiState(namespace ? context?.snapshot[namespace] : context?.snapshot) } as T;
|
||||
let active = true;
|
||||
const update = (patch: Partial<T>) => {
|
||||
if (active) context?.update(namespace ? { [namespace]: patch } : (patch as TabPageUiState));
|
||||
};
|
||||
const track = (snapshot: () => Partial<T>) => {
|
||||
const stop = watch(snapshot, update, { deep: true, flush: "post" });
|
||||
onBeforeUnmount(() => {
|
||||
update(snapshot());
|
||||
stop();
|
||||
active = false;
|
||||
});
|
||||
};
|
||||
return { initialState, update, track };
|
||||
}
|
||||
|
||||
export function sanitizeTabPageUiState(value: unknown): TabPageUiState | undefined {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
||||
try {
|
||||
const normalized = sanitizeValue(value, 0, new Set(), { remaining: MAX_TAB_PAGE_UI_STATE_BYTES }) as TabPageUiState | undefined;
|
||||
if (!normalized || !Object.keys(normalized).length) return undefined;
|
||||
if (new TextEncoder().encode(JSON.stringify(normalized)).byteLength > MAX_TAB_PAGE_UI_STATE_BYTES) return undefined;
|
||||
return normalized;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeValue(value: unknown, depth: number, ancestors: Set<object>, budget: { remaining: number }): unknown {
|
||||
if (depth > 10) throw new Error("UI state nesting limit");
|
||||
budget.remaining -= typeof value === "string" ? new TextEncoder().encode(value).byteLength : 8;
|
||||
if (budget.remaining < 0) throw new Error("UI state size limit");
|
||||
if (value === null || typeof value === "boolean" || typeof value === "string") return value;
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
||||
if (typeof value !== "object") return undefined;
|
||||
if (ancestors.has(value)) throw new Error("Cyclic UI state");
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) return undefined;
|
||||
ancestors.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > 512) throw new Error("UI state array limit");
|
||||
return value.map((entry) => sanitizeValue(entry, depth + 1, ancestors, budget)).filter((entry) => entry !== undefined);
|
||||
}
|
||||
const result: Record<string, unknown> = {};
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length > 128) throw new Error("UI state key limit");
|
||||
for (const [key, entry] of entries) {
|
||||
if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
|
||||
budget.remaining -= new TextEncoder().encode(key).byteLength;
|
||||
const sanitized = sanitizeValue(entry, depth + 1, ancestors, budget);
|
||||
if (sanitized !== undefined) result[key] = sanitized;
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
ancestors.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeTabUiState(value: unknown): TabUiState | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
const state = value as TabUiState;
|
||||
const normalized: TabUiState = {};
|
||||
if (state.activeOutputView && ["result", "summary", "explain", "chart", "messages", "profile"].includes(state.activeOutputView)) normalized.activeOutputView = state.activeOutputView;
|
||||
if (typeof state.resultPaneOpen === "boolean") normalized.resultPaneOpen = state.resultPaneOpen;
|
||||
const sanitizedPage = sanitizeTabPageUiState(state.page);
|
||||
if (sanitizedPage) {
|
||||
const page: Record<string, TabPageUiState> = {};
|
||||
for (const [mode, snapshot] of Object.entries(sanitizedPage)) {
|
||||
const sanitized = sanitizeTabPageUiState(snapshot);
|
||||
if (sanitized) page[mode] = sanitized;
|
||||
}
|
||||
if (Object.keys(page).length) normalized.page = page;
|
||||
}
|
||||
return Object.keys(normalized).length ? normalized : undefined;
|
||||
}
|
||||
@@ -111,6 +111,61 @@ describe("queryStore editor groups", () => {
|
||||
expect(store.activateTab("missing")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps UI state isolated when switching tabs", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const firstId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
const secondId = store.createTab("pg-1", "app", "Query 2", "query");
|
||||
|
||||
store.updateTabUiState(firstId, { activeOutputView: "chart", resultPaneOpen: false });
|
||||
store.updateTabUiState(secondId, { activeOutputView: "summary", resultPaneOpen: true });
|
||||
|
||||
store.activateTab(firstId);
|
||||
expect(store.tabs.find((tab) => tab.id === firstId)?.uiState).toEqual({ activeOutputView: "chart", resultPaneOpen: false });
|
||||
store.activateTab(secondId);
|
||||
expect(store.tabs.find((tab) => tab.id === secondId)?.uiState).toEqual({ activeOutputView: "summary", resultPaneOpen: true });
|
||||
});
|
||||
|
||||
it("rejects stale or wrong-mode special-page UI updates", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "Keys", "etcd");
|
||||
const owner = store.tabs.find((tab) => tab.id === tabId)!;
|
||||
|
||||
store.updateTabPageUiState(tabId, "etcd", { EtcdKeyBrowser: { mode: "search" } }, owner);
|
||||
store.updateTabPageUiState(tabId, "query", { QueryEditor: { draft: "stale" } }, owner);
|
||||
store.updateTabPageUiState(tabId, "etcd", { EtcdKeyBrowser: { mode: "watch" } }, { ...owner });
|
||||
|
||||
expect(owner.uiState?.page).toEqual({ etcd: { EtcdKeyBrowser: { mode: "search" } } });
|
||||
});
|
||||
|
||||
it("persists tab UI state with the open-tabs snapshot", async () => {
|
||||
const api = await import("@/lib/backend/api");
|
||||
const storeApi = vi.mocked(api.saveOpenTabsState);
|
||||
storeApi.mockClear();
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "Query 1", "query");
|
||||
|
||||
store.updateTabUiState(tabId, { activeOutputView: "messages", resultPaneOpen: false });
|
||||
store.updateTabPageUiState(tabId, "query", { QueryEditor: { inspectorOpen: true } });
|
||||
await store.flushPendingPersist();
|
||||
|
||||
expect(storeApi).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tabs: [
|
||||
expect.objectContaining({
|
||||
uiState: {
|
||||
activeOutputView: "messages",
|
||||
resultPaneOpen: false,
|
||||
page: { query: { QueryEditor: { inspectorOpen: true } } },
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("close all in group only closes the same pinned partition as the trigger tab", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { uuid } from "@/lib/common/utils";
|
||||
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
|
||||
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
|
||||
import { orderPinnedFirst } from "@/lib/app/pinnedItems";
|
||||
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
|
||||
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan";
|
||||
@@ -2318,6 +2319,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
mode: t.mode,
|
||||
autoCommit: t.autoCommit,
|
||||
resultAutoSave: t.resultAutoSave,
|
||||
uiState: t.uiState,
|
||||
structureTableName: t.structureTableName,
|
||||
structureDraft: t.structureDraft,
|
||||
objectBrowser: t.objectBrowser,
|
||||
@@ -4201,6 +4203,36 @@ export const useQueryStore = defineStore("query", () => {
|
||||
queueSavedSqlEditorPositionPersist(tab);
|
||||
}
|
||||
|
||||
function updateTabUiState(id: string, patch: Partial<NonNullable<QueryTab["uiState"]>>) {
|
||||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||||
if (!tab) return;
|
||||
tab.uiState = { ...tab.uiState, ...patch };
|
||||
}
|
||||
|
||||
function updateTabPageUiState(id: string, mode: string, patch: Record<string, unknown>, owner?: QueryTab) {
|
||||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||||
if (!tab || tab.mode !== mode || (owner && owner !== tab)) return;
|
||||
const nextPageState = sanitizeTabPageUiState({ ...(tab.uiState?.page?.[mode] ?? {}), ...patch });
|
||||
if (!nextPageState) return;
|
||||
const page = sanitizeTabPageUiState({ ...(tab.uiState?.page ?? {}), [mode]: nextPageState });
|
||||
if (!page) return;
|
||||
tab.uiState = { ...tab.uiState, page: page as NonNullable<QueryTab["uiState"]>["page"] };
|
||||
}
|
||||
|
||||
function updateTabPageResult(id: string, mode: string, result: QueryResult | undefined, owner?: QueryTab) {
|
||||
const tab = tabs.value.find((candidate) => candidate.id === id);
|
||||
if (!tab || tab.mode !== mode || (owner && owner !== tab)) return;
|
||||
if (!result) {
|
||||
clearResultPayload(tab);
|
||||
return;
|
||||
}
|
||||
tab.results = undefined;
|
||||
tab.activeResultIndex = undefined;
|
||||
assignDisplayedResult(tab, result);
|
||||
touchResult(tab);
|
||||
scheduleResultCacheTrim();
|
||||
}
|
||||
|
||||
function updateEditorSelection(id: string, selection: { anchor: number; head: number }) {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab) return;
|
||||
@@ -8023,6 +8055,9 @@ export const useQueryStore = defineStore("query", () => {
|
||||
updateDataGridLocalColumnFilters,
|
||||
updateDataGridHiddenColumnKeys,
|
||||
updateEditorViewport,
|
||||
updateTabUiState,
|
||||
updateTabPageUiState,
|
||||
updateTabPageResult,
|
||||
updateEditorSelection,
|
||||
flushEditorState,
|
||||
updateObjectBrowserViewport,
|
||||
|
||||
@@ -1152,6 +1152,18 @@ export interface QueryPageJumpProgress {
|
||||
targetPage: number;
|
||||
}
|
||||
|
||||
export type TabOutputView = "result" | "summary" | "explain" | "chart" | "messages" | "profile";
|
||||
|
||||
export type TabPageUiState = Record<string, unknown>;
|
||||
|
||||
/** UI-only state that must survive an inactive tab's component being unmounted. */
|
||||
export interface TabUiState {
|
||||
activeOutputView?: TabOutputView;
|
||||
resultPaneOpen?: boolean;
|
||||
/** Small JSON-compatible snapshots owned by special-page components. */
|
||||
page?: Record<string, TabPageUiState>;
|
||||
}
|
||||
|
||||
export interface QueryTab {
|
||||
id: string;
|
||||
/** Stable creation time used when tabs are displayed in creation order. */
|
||||
@@ -1215,6 +1227,7 @@ export interface QueryTab {
|
||||
activeResultRunId?: string;
|
||||
/** Undefined inherits the default on open; false preserves an explicit per-tab opt-out. */
|
||||
resultAutoSave?: boolean;
|
||||
uiState?: TabUiState;
|
||||
explainPlan?: import("@/lib/diagram/explainPlan").ParsedExplainPlan;
|
||||
/** MySQL's regular EXPLAIN result, kept alongside its JSON visual plan. */
|
||||
explainTableResult?: QueryResult;
|
||||
|
||||
Reference in new issue
Block a user