jcloud/dashboard/src2/pages/devtools/database/DatabaseAnalyzer.vue
2025-04-12 17:39:38 +08:00

779 lines
22 KiB
Vue

<template>
<Header class="sticky top-0 z-10 bg-white">
<div
class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between"
>
<div class="flex flex-row items-center gap-2">
<!-- 标题 -->
<Breadcrumbs
:items="[
{ label: '开发工具', route: '/database-analyzer' },
{ label: '数据库分析器', route: '/database-analyzer' },
]"
/>
</div>
<div class="flex flex-row gap-2">
<LinkControl
class="cursor-pointer"
:options="{ pagetype: 'Site', filters: { status: 'Active' } }"
placeholder="选择一个站点"
v-model="site"
/>
<Button
iconLeft="refresh-ccw"
variant="subtle"
:loading="site && !isRequiredInformationReceived"
:disabled="!site"
@click="
() =>
fetchTableSchemas({
reload: true,
})
"
>
<span class="md:hidden">架构</span>
<span class="hidden md:inline">刷新架构</span>
</Button>
</div>
</div>
</Header>
<div class="m-5">
<!-- 主体 -->
<div class="mt-2 flex flex-col" v-if="isRequiredInformationReceived">
<!-- 数据库大小分析器 -->
<div>
<div class="flex flex-row items-center justify-between">
<p class="text-base font-medium text-gray-800">
数据库大小分解
</p>
<div class="flex flex-row gap-2">
<Button @click="this.showTableSchemaSizeDetailsDialog = true">
查看详情
</Button>
<Button
@click="optimizeTable"
:loading="this.$resources.optimizeTable.loading"
>
优化表
</Button>
</div>
</div>
<!-- 滑块 -->
<div
class="mb-4 mt-4 flex h-7 w-full cursor-pointer items-start justify-start overflow-clip rounded border bg-gray-50 pl-0"
@click="showTableSchemaSizeDetailsDialog = true"
>
<div
class="h-7"
:style="{
backgroundColor: '#E86C13',
width: `${databaseSizeBreakup.data_size_percentage}%`,
}"
></div>
<div
class="h-7"
:style="{
backgroundColor: '#34BAE3',
width: `${databaseSizeBreakup.index_size_percentage}%`,
}"
></div>
</div>
<!-- 表格 -->
<div
class="full flex w-full flex-col items-start justify-start overflow-y-auto rounded px-1.5"
>
<div class="flex w-full items-center justify-start gap-x-2 py-3">
<div
class="h-2 w-2 rounded-full"
style="background-color: #e86c13"
></div>
<span class="text-sm text-gray-800">数据大小</span
><span class="ml-auto text-sm text-gray-800">{{
formatSizeInMB(this.databaseSizeBreakup.data_size)
}}</span>
</div>
<div
class="flex w-full items-center justify-start gap-x-2 border-t py-3"
>
<div
class="h-2 w-2 rounded-full"
style="background-color: #34bae3"
></div>
<span class="text-sm text-gray-800">索引大小</span
><span class="ml-auto text-sm text-gray-800"
>{{ formatSizeInMB(this.databaseSizeBreakup.index_size) }}
</span>
</div>
<div
class="flex w-full items-center justify-start gap-x-2 border-t py-3"
>
<div
class="h-2 w-2 rounded-full"
style="background-color: #e2e2e2"
></div>
<span class="text-sm text-gray-800">空闲空间</span
><span class="ml-auto text-sm text-gray-800"
>{{ formatSizeInMB(this.databaseSizeBreakup.free_size) }}
</span>
</div>
</div>
</div>
<!-- 数据库进程 -->
<ToggleContent
class="mt-3"
label="数据库进程"
subLabel="分析数据库的进程"
>
<template #actions>
<div>
<Button
:loading="this.$resources.databaseProcesses.loading"
loading-text="刷新中"
icon-left="rotate-ccw"
@click.stop="this.$resources.databaseProcesses.submit()"
>刷新</Button
>
</div>
</template>
<template #default>
<div
v-if="this.$resources.databaseProcesses.loading"
class="flex h-60 w-full items-center justify-center gap-2 text-base text-gray-700"
>
<Spinner class="w-4" /> 正在加载数据库进程
</div>
<ResultTable
v-else
class="mt-2"
:columns="databaseProcesses.columns"
:data="databaseProcesses.data"
:alignColumns="alignColumns"
:cellFormatters="cellFormatters"
:fullViewFormatters="fullViewFormatters"
actionHeaderLabel="终止"
:actionComponent="DatabaseProcessKillButton"
:actionComponentProps="{
site: this.site,
}"
:enableCSVExport="false"
:borderLess="true"
/>
</template>
</ToggleContent>
<!-- 查询信息 -->
<ToggleContent
class="mt-3"
label="SQL 查询分析"
subLabel="检查可能影响数据库性能的相关查询"
>
<div class="mt-1">
<FTabs
:tabs="queryTabs"
v-model="queryTabIndex"
v-if="queryTabs.length"
>
<template #tab-panel="{ tab }">
<DatabasePerformanceSchemaDisabledNotice
v-if="
(tab.label === '耗时查询' ||
tab.label === '全表扫描查询') &&
!isPerformanceSchemaEnabled
"
/>
<ResultTable
v-else
:columns="tab.columns"
:data="tab.data"
:alignColumns="alignColumns"
:cellFormatters="cellFormatters"
:fullViewFormatters="fullViewFormatters"
:enableCSVExport="false"
:borderLess="true"
:isTruncateText="true"
/>
</template>
</FTabs>
</div>
</ToggleContent>
<!-- 索引信息 -->
<ToggleContent
class="mt-3"
label="数据库索引分析"
subLabel="分析数据库的索引"
>
<div class="mt-1">
<FTabs
:tabs="databaseIndexesTab"
v-model="dbIndexTabIndex"
v-if="databaseIndexesTab.length"
>
<template #tab-panel="{ tab }">
<DatabasePerformanceSchemaDisabledNotice
v-if="
(tab.label === '未使用索引' ||
tab.label === '建议索引') &&
!isPerformanceSchemaEnabled
"
/>
<div v-else-if="tab.label === '建议索引'">
<div
v-if="
!isIndexSuggestionTriggered ||
this.$resources.suggestDatabaseIndexes.loading ||
this.fetchingDatabaseIndex
"
class="flex h-60 flex-col items-center justify-center gap-4"
>
<Button
variant="outline"
@click="
() => {
this.isIndexSuggestionTriggered = true;
this.$resources.suggestDatabaseIndexes.submit();
}
"
:loading="
this.$resources.suggestDatabaseIndexes.loading ||
this.fetchingDatabaseIndex
"
>建议索引</Button
>
<p class="text-base text-gray-700">
分析可能需要一些时间
</p>
</div>
<ResultTable
v-else
:columns="suggestedDatabaseIndexes.columns"
:data="suggestedDatabaseIndexes.data"
:alignColumns="alignColumns"
:cellFormatters="cellFormatters"
:fullViewFormatters="fullViewFormatters"
:enableCSVExport="false"
:borderLess="true"
actionHeaderLabel="添加索引"
:actionComponent="DatabaseAddIndexButton"
:actionComponentProps="{
site: this.site,
}"
:isTruncateText="true"
/>
</div>
<ResultTable
v-else
:columns="tab.columns"
:data="tab.data"
:alignColumns="alignColumns"
:cellFormatters="cellFormatters"
:fullViewFormatters="fullViewFormatters"
:isTruncateText="true"
:enableCSVExport="false"
:borderLess="true"
/>
</template>
</FTabs>
</div>
</ToggleContent>
<DatabaseTableSchemaSizeDetailsDialog
v-if="this.site"
:site="this.site"
:tableSchemas="tableSchemas"
v-model="showTableSchemaSizeDetailsDialog"
:viewSchemaDetails="viewTableSchemaDetails"
/>
<DatabaseTableSchemaDialog
v-if="this.site"
:site="this.site"
:tableSchemas="tableSchemas"
:pre-selected-schema="preSelectedSchemaForSchemaDialog"
v-model="showTableSchemasDialog"
/>
</div>
<div
v-else-if="!site"
class="flex h-full min-h-[80vh] w-full items-center justify-center gap-2 text-gray-700"
>
选择一个站点以开始
</div>
<div
class="flex h-full min-h-[80vh] w-full items-center justify-center gap-2 text-gray-700"
v-else
>
<Spinner class="w-4" /> 正在加载表结构
</div>
</div>
</template>
<script>
import Header from '../../../components/Header.vue';
import { Tabs, Breadcrumbs } from 'jingrow-ui';
import LinkControl from '../../../components/LinkControl.vue';
import ObjectList from '../../../components/ObjectList.vue';
import { h, markRaw } from 'vue';
import { toast } from 'vue-sonner';
import { formatValue } from '../../../utils/format';
import ToggleContent from '../../../components/ToggleContent.vue';
import ResultTable from '../../../components/devtools/database/ResultTable.vue';
import DatabaseProcessKillButton from '../../../components/devtools/database/DatabaseProcessKillButton.vue';
import DatabaseTableSchemaDialog from '../../../components/devtools/database/DatabaseTableSchemaDialog.vue';
import DatabaseTableSchemaSizeDetailsDialog from '../../../components/devtools/database/DatabaseTableSchemaSizeDetailsDialog.vue';
import DatabaseAddIndexButton from '../../../components/devtools/database/DatabaseAddIndexButton.vue';
import DatabasePerformanceSchemaDisabledNotice from '../../../components/devtools/database/DatabasePerformanceSchemaDisabledNotice.vue';
export default {
name: 'DatabaseAnalyzer',
components: {
Header,
Breadcrumbs,
FTabs: Tabs,
LinkControl,
ObjectList,
ToggleContent,
ResultTable,
DatabaseTableSchemaDialog,
DatabaseTableSchemaSizeDetailsDialog,
DatabaseProcessKillButton,
DatabasePerformanceSchemaDisabledNotice,
},
data() {
return {
site: null,
errorMessage: null,
isIndexSuggestionTriggered: false,
queryTabIndex: 0,
dbIndexTabIndex: 0,
showTableSchemaSizeDetailsDialog: false,
preSelectedSchemaForSchemaDialog: null,
showTableSchemasDialog: false,
fetchingDatabaseIndex: false,
DatabaseProcessKillButton: markRaw(DatabaseProcessKillButton),
DatabaseAddIndexButton: markRaw(DatabaseAddIndexButton),
};
},
mounted() {
const url = new URL(window.location.href);
const site_name = url.searchParams.get('site');
if (site_name) {
this.site = site_name;
}
},
watch: {
site(site_name) {
if (!site_name) return;
// set site to query param ?site=site_name
const url = new URL(window.location.href);
url.searchParams.set('site', site_name);
window.history.pushState({}, '', url);
// reset state
this.data = null;
this.errorMessage = null;
this.fetchTableSchemas({
site_name: site_name,
});
this.$resources.site.submit();
this.$resources.databasePerformanceReport.submit({
dt: 'Site',
dn: site_name,
method: 'get_database_performance_report',
});
this.$resources.databaseProcesses.submit({
dt: 'Site',
dn: site_name,
method: 'fetch_database_processes',
});
},
},
resources: {
site() {
return {
url: 'jcloud.api.client.get',
initialData: {},
makeParams: () => {
return { pagetype: 'Site', name: this.site };
},
auto: false,
};
},
tableSchemas() {
return {
url: 'jcloud.api.client.run_pg_method',
initialData: {},
auto: false,
makeParams: () => {
return {
dt: 'Site',
dn: this.site,
method: 'fetch_database_table_schema',
};
},
onSuccess: (data) => {
if (data?.message?.loading) {
setTimeout(this.fetchTableSchemas, 5000);
}
},
};
},
optimizeTable() {
return {
url: 'jcloud.api.client.run_pg_method',
initialData: {},
auto: false,
onSuccess: (data) => {
if (data?.message) {
if (data?.message?.success) {
toast.success(data?.message?.message);
this.$router.push(
`/sites/${this.site}/insights/jobs/${data?.message?.job_name}`,
);
} else {
toast.error(data?.message?.message);
}
}
},
};
},
databasePerformanceReport() {
return {
url: 'jcloud.api.client.run_pg_method',
initialData: {},
makeParams: () => {
return {
dt: 'Site',
dn: this.site,
method: 'get_database_performance_report',
};
},
auto: false,
};
},
suggestDatabaseIndexes() {
return {
url: 'jcloud.api.client.run_pg_method',
initialData: {},
makeParams: () => {
return {
dt: 'Site',
dn: this.site,
method: 'suggest_database_indexes',
};
},
onSuccess: (data) => {
if (data?.message) {
this.fetchingDatabaseIndex =
this.$resources.suggestDatabaseIndexes?.data?.message?.loading ??
false;
if (this.fetchingDatabaseIndex) {
setTimeout(() => {
this.$resources.suggestDatabaseIndexes.submit();
}, 5000);
}
}
},
auto: false,
};
},
databaseProcesses() {
return {
url: 'jcloud.api.client.run_pg_method',
initialData: {},
makeParams: () => {
return {
dt: 'Site',
dn: this.site,
method: 'fetch_database_processes',
};
},
auto: false,
};
},
},
computed: {
site_info() {
return this.$resources.site.data;
},
isRequiredInformationReceived() {
if (this.$resources.site?.loading ?? true) return false;
if (this.$resources.tableSchemas.loading) return false;
if (this.$resources.tableSchemas?.data?.message?.loading) return false;
if (!this.$resources.tableSchemas?.data?.message?.data) return false;
if (this.$resources.tableSchemas?.data?.message?.data == {}) return false;
if (!this.$resources.databasePerformanceReport?.data?.message)
return false;
return true;
},
tableSchemas() {
if (!this.isRequiredInformationReceived) return [];
let result = this.$resources.tableSchemas?.data?.message?.data ?? [];
return result;
},
tableSizeInfo() {
if (!this.isRequiredInformationReceived) return [];
let data = [];
for (const tableName in this.tableSchemas) {
const table = this.tableSchemas[tableName];
data.push({
table_name: tableName,
data_size_mb: (table.size.data_length / (1024 * 1024)).toFixed(3),
index_size_mb: (table.size.index_length / (1024 * 1024)).toFixed(3),
total_size_mb: (table.size.total_size / (1024 * 1024)).toFixed(3),
no_of_columns: table.columns.length,
});
}
return data;
},
tableAnalysisTableOptions() {
if (!this.isRequiredInformationReceived) return [];
return {
data: () => this.tableSizeInfo,
hideControls: true,
columns: [
{
label: '表名',
fieldname: 'table_name',
width: 0.5,
type: 'Component',
component({ row }) {
return h(
'div',
{
class: 'truncate text-base cursor-copy',
onClick() {
if ('clipboard' in navigator) {
navigator.clipboard.writeText(row.table_name);
toast.success('已复制到剪贴板');
}
},
},
[row.table_name],
);
},
},
{
label: '大小 (MB)',
fieldname: 'total_size_mb',
width: 0.5,
},
{
label: '列数',
fieldname: 'no_of_columns',
width: 0.5,
},
],
};
},
databaseSizeBreakup() {
if (!this.isRequiredInformationReceived) return null;
let data_size = this.tableSizeInfo.reduce(
(a, b) => a + parseFloat(b.data_size_mb),
0,
);
data_size = data_size.toFixed(2);
let index_size = this.tableSizeInfo.reduce(
(a, b) => a + parseFloat(b.index_size_mb),
0,
);
index_size = index_size.toFixed(2);
let database_size_limit =
this.site_info.current_plan.max_database_usage.toFixed(2);
return {
data_size,
index_size,
database_size_limit,
free_size: (database_size_limit - data_size - index_size).toFixed(2),
data_size_percentage: parseInt((data_size / database_size_limit) * 100),
index_size_percentage: parseInt(
(index_size / database_size_limit) * 100,
),
};
},
isPerformanceSchemaEnabled() {
const result = this.$resources.databasePerformanceReport?.data?.message;
if (!result) return false;
return result['is_performance_schema_enabled'];
},
queryTabs() {
if (!this.isRequiredInformationReceived) return [];
const result = this.$resources.databasePerformanceReport?.data?.message;
if (!result) return [];
let prepared_result = [
{
label: '慢查询',
columns: ['检查行数', '发送行数', '调用次数', '持续时间', '查询'],
data: result['slow_queries'].map((e) => {
return [e.rows_examined, e.rows_sent, e.count, e.duration, e.query];
}),
},
{
label: '耗时查询',
columns: ['百分比', '调用次数', '平均时间', '查询'],
data: result['top_10_time_consuming_queries'].map((e) => {
return [
Math.round(e['percent'], 1),
e['calls'],
e['avg_time_ms'],
e['query'],
];
}),
},
{
label: '全表扫描查询',
columns: ['检查行数', '发送行数', '调用次数', '查询'],
data: result['top_10_queries_with_full_table_scan'].map((e) => {
return [e['rows_examined'], e['rows_sent'], e['calls'], e['query']];
}),
},
];
return prepared_result;
},
databaseIndexesTab() {
if (!this.isRequiredInformationReceived) return [];
const result = this.$resources.databasePerformanceReport?.data?.message;
if (!result) return [];
let prepared_result = [
{
label: '建议索引',
columns: ['表', '列', '索引名称', '示例查询'],
data: [],
},
{
label: '冗余索引',
columns: [
'表名',
'主导索引',
'主导索引列',
'冗余索引',
'冗余索引列',
],
data: result['redundant_indexes'].map((e) => {
return [
e['table_name'],
e['dominant_index_name'],
e['dominant_index_columns'],
e['redundant_index_name'],
e['redundant_index_columns'],
];
}),
},
{
label: '未使用索引',
columns: ['表名', '索引名称'],
data: result['unused_indexes'].map((e) => {
return [e['table_name'], e['index_name']];
}),
},
];
return prepared_result;
},
suggestedDatabaseIndexes() {
if (!this.isRequiredInformationReceived) return [];
const result =
this.$resources.suggestDatabaseIndexes?.data?.message?.data ?? [];
let data = [];
for (const record of result) {
for (const index of record.suggested_indexes) {
data.push([index.table, index.column, record.normalized]);
}
}
return {
columns: ['表', '列', '慢查询'],
data: data,
};
},
databaseProcesses() {
if (!this.isRequiredInformationReceived) return null;
const result = this.$resources.databaseProcesses.data?.message ?? [];
return {
columns: ['ID', '状态', '时间', '用户', '主机', '命令', '查询'],
data: result.map((e) => {
return [
e['id'],
e['state'],
e['time'],
e['db_user'],
e['db_user_host'],
e['command'],
e['query'],
];
}),
};
},
cellFormatters() {
return {
'检查行数': (v) => formatValue(v, 'commaSeperatedNumber'),
'发送行数': (v) => formatValue(v, 'commaSeperatedNumber'),
调用次数: (v) => formatValue(v, 'commaSeperatedNumber'),
'平均时间': (v) => formatValue(v, 'durationMilliseconds'),
持续时间: (v) => formatValue(v, 'durationSeconds'),
时间: (v) => formatValue(v, 'durationSeconds'),
};
},
fullViewFormatters() {
return {
查询: (v) => formatValue(v, 'sql'),
};
},
alignColumns() {
return {
百分比: 'right',
'检查行数': 'right',
'发送行数': 'right',
调用次数: 'right',
'平均时间': 'right',
持续时间: 'right',
时间: 'right',
};
},
},
methods: {
fetchTableSchemas({ site_name = null, reload = false } = {}) {
if (!site_name) site_name = this.site;
if (!site_name) return;
this.$resources.tableSchemas.submit({
dt: 'Site',
dn: site_name,
method: 'fetch_database_table_schema',
args: {
reload,
},
});
},
optimizeTable() {
this.$resources.optimizeTable.submit({
dt: 'Site',
dn: this.site,
method: 'optimize_tables',
});
},
viewTableSchemaDetails(tableName) {
this.showTableSchemaSizeDetailsDialog = false;
this.preSelectedSchemaForSchemaDialog = tableName;
this.showTableSchemasDialog = true;
},
formatSizeInMB(mb) {
try {
let floatMB = parseFloat(mb);
if (floatMB < 1) {
let kb = floatMB * 1024; // Convert MB to KB
return `${Math.round(kb)} KB`; // Return KB without decimal
} else if (floatMB < 1024) {
return `${Math.round(floatMB)} MB`; // Return MB without decimal
} else {
let gb = floatMB / 1024; // Convert MB to GB
return `${gb.toFixed(1)} GB`; // Return GB with 1 decimal
}
} catch (error) {
return `${mb} MB`; // Return MB without decimal
}
},
},
};
</script>