feat(dashboard): show the connected database on the dashboard

Surface the connected database product as a subtle chip in the Dashboard
header. SystemHealthService now reports a database label on /system/health
(reused by the front-end — no extra request), derived from a new
DatabaseBootstrapRunner.getDatabaseLabel() that reads the JDBC product name
once and normalizes it to a canonical label (MySQL / MariaDB / PostgreSQL /
H2, and 人大金仓 for the KingbaseES family), collapsing driver version noise.
This commit is contained in:
matevip 2026-06-15 08:47:39 +08:00
parent 710b756281
commit 4cbd2b50f3
6 changed files with 148 additions and 3 deletions

View File

@ -47,6 +47,9 @@ public class DatabaseBootstrapRunner implements ApplicationRunner {
/** Cached flag: true when running on PostgreSQL. */
private volatile Boolean isPostgres;
/** Cached human-readable label of the connected database, e.g. "MySQL" / "H2" / "PostgreSQL". */
private volatile String databaseLabel;
/**
* When true, wait for Desktop splash screen to call /setup/init with chosen language.
* When false (default), auto-initialize immediately on startup.
@ -150,6 +153,57 @@ public class DatabaseBootstrapRunner implements ApplicationRunner {
}
}
/**
* Friendly product name of the currently connected database, e.g. {@code "MySQL"},
* {@code "PostgreSQL"}, {@code "H2"} or {@code "KingbaseES"}. Read once from JDBC
* metadata and cached the connected database never changes at runtime.
*
* @return the product name, or {@code "Unknown"} if metadata is unavailable.
*/
public String getDatabaseLabel() {
if (databaseLabel == null) {
try (Connection connection = dataSource.getConnection()) {
databaseLabel = normalizeDatabaseLabel(connection.getMetaData().getDatabaseProductName());
} catch (Exception e) {
log.debug("Failed to read database product name: {}", e.getMessage());
databaseLabel = "Unknown";
}
}
return databaseLabel;
}
/**
* Maps a raw JDBC product name to a clean, canonical label. Some drivers append
* version noise to the product name (e.g. KingbaseES reports "KingbaseES V008R006");
* collapsing on a keyword keeps the displayed label stable across driver versions
* and consistent with the dialect this runner detects for DDL.
*
* @return a canonical label, or {@code "Unknown"} when the product name is absent.
*/
static String normalizeDatabaseLabel(String product) {
if (product == null || product.isBlank()) {
return "Unknown";
}
String lower = product.toLowerCase();
if (lower.contains("kingbase")) {
// KingbaseES is the product name; show the vendor's Chinese brand name.
return "人大金仓";
}
if (lower.contains("mariadb")) {
return "MariaDB";
}
if (lower.contains("mysql")) {
return "MySQL";
}
if (lower.contains("postgresql")) {
return "PostgreSQL";
}
if (lower.contains("h2")) {
return "H2";
}
return product.trim();
}
private boolean tableExists(String tableName) throws Exception {
try (Connection connection = dataSource.getConnection()) {
DatabaseMetaData metaData = connection.getMetaData();

View File

@ -67,7 +67,7 @@ public class SystemHealthService {
}
}
return new HealthResponse(overall, checks);
return new HealthResponse(overall, checks, bootstrapRunner.getDatabaseLabel());
}
private HealthCheck checkDefaultModel() {
@ -190,7 +190,7 @@ public class SystemHealthService {
// ==================== Response Records ====================
public record HealthResponse(String overall, List<HealthCheck> checks) {}
public record HealthResponse(String overall, List<HealthCheck> checks, String database) {}
public record HealthCheck(String name, String status, String message, HealthAction action) {}

View File

@ -0,0 +1,44 @@
package vip.mate.config;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* Unit tests for {@link DatabaseBootstrapRunner#normalizeDatabaseLabel(String)}.
* Verifies that raw JDBC product names including version-suffixed ones from the
* KingbaseES / PostgreSQL family collapse to a clean, canonical label.
*/
class DatabaseBootstrapRunnerLabelTest {
@Test
@DisplayName("KingbaseES product name (with version noise) → '人大金仓'")
void kingbaseNormalizes() {
assertEquals("人大金仓", DatabaseBootstrapRunner.normalizeDatabaseLabel("KingbaseES"));
assertEquals("人大金仓", DatabaseBootstrapRunner.normalizeDatabaseLabel("KingbaseES V008R006"));
assertEquals("人大金仓", DatabaseBootstrapRunner.normalizeDatabaseLabel("kingbasees"));
}
@Test
@DisplayName("MySQL / MariaDB → canonical labels")
void mysqlFamilyNormalizes() {
assertEquals("MySQL", DatabaseBootstrapRunner.normalizeDatabaseLabel("MySQL"));
assertEquals("MariaDB", DatabaseBootstrapRunner.normalizeDatabaseLabel("MariaDB"));
}
@Test
@DisplayName("PostgreSQL and H2 → canonical labels")
void postgresAndH2Normalize() {
assertEquals("PostgreSQL", DatabaseBootstrapRunner.normalizeDatabaseLabel("PostgreSQL"));
assertEquals("H2", DatabaseBootstrapRunner.normalizeDatabaseLabel("H2"));
}
@Test
@DisplayName("Unknown / blank product name → 'Unknown'; unrecognized name passes through trimmed")
void fallbacks() {
assertEquals("Unknown", DatabaseBootstrapRunner.normalizeDatabaseLabel(null));
assertEquals("Unknown", DatabaseBootstrapRunner.normalizeDatabaseLabel(" "));
assertEquals("Oracle", DatabaseBootstrapRunner.normalizeDatabaseLabel(" Oracle "));
}
}

View File

@ -573,6 +573,7 @@ export default {
doctor: {
title: 'System Diagnostics',
subtitle: 'Local instance health snapshot',
database: 'Connected database',
checking: 'Checking...',
allGood: 'All systems healthy',
hasWarnings: '{count} warning(s)',

View File

@ -2054,6 +2054,7 @@ export default {
doctor: {
title: '系统诊断',
subtitle: '本地实例健康概览',
database: '当前数据库',
checking: '检查中...',
allGood: '所有系统正常',
hasWarnings: '{count} 个警告',

View File

@ -7,6 +7,11 @@
<div class="mc-page-kicker">{{ t('dashboard.kicker') }}</div>
<h1 class="mc-page-title">{{ t('dashboard.title') }}</h1>
<p class="mc-page-desc">{{ t('dashboard.desc') }}</p>
<div v-if="dbLabel" class="db-chip" :title="t('doctor.database')">
<svg class="db-chip__icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/></svg>
<span class="db-chip__label">{{ t('doctor.database') }}</span>
<span class="db-chip__value">{{ dbLabel }}</span>
</div>
</div>
<div class="hero-note mc-surface-card">
<div class="hero-note__label">{{ t('dashboard.periods.today') }}</div>
@ -190,7 +195,7 @@ import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { ArrowRight, ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue'
import { dashboardApi, modelApi } from '@/api'
import { dashboardApi, modelApi, http } from '@/api'
import { getProviderIcon, onProviderIconError } from '@/utils/providerIcons'
import * as echarts from 'echarts/core'
import { LineChart } from 'echarts/charts'
@ -219,6 +224,9 @@ const todayStats = reactive({
// Model configuration card
const modelProviders = ref<any[]>([])
const activeModel = ref<{ providerId: string; model: string } | null>(null)
// Connected database product name (e.g. "MySQL" / "H2" / "PostgreSQL"), surfaced
// as a subtle line in the page header. Empty string hides it when unavailable.
const dbLabel = ref('')
const readyProviderCount = computed(
() => modelProviders.value.filter((p) => providerChipStatus(p) === 'ready').length,
@ -272,6 +280,15 @@ onMounted(async () => {
// Dashboard data is non-critical
}
// Connected database label independent and non-critical. Reuses the
// existing system health endpoint, which already reports the product name.
try {
const healthRes: any = await http.get('/system/health')
dbLabel.value = (healthRes?.data || healthRes)?.database || ''
} catch {
dbLabel.value = ''
}
// Model configuration card loaded independently so a failure here never
// blanks the analytics above, and vice versa.
try {
@ -415,6 +432,34 @@ function calcDuration(run: any): string {
padding-right: 4px;
}
.db-chip {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 14px;
padding: 4px 10px;
border: 1px solid var(--mc-border);
border-radius: 999px;
background: var(--mc-bg-sunken);
font-size: 12px;
line-height: 1;
color: var(--mc-text-secondary);
}
.db-chip__icon {
color: var(--mc-text-tertiary);
flex-shrink: 0;
}
.db-chip__label {
color: var(--mc-text-tertiary);
}
.db-chip__value {
font-weight: 600;
color: var(--mc-text-primary);
}
.hero-note {
min-width: 220px;
padding: 16px 18px;