Files
postgresqlfpk/frontend/postgres-admin/App.vue
T

334 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
type Section = "overview" | "clients" | "databases" | "roles" | "sessions" | "sql" | "backups";
type JsonObject = Record<string, unknown>;
interface Client {
appId: string;
displayName: string;
databaseName: string;
roleName: string;
extensions: string[];
status: string;
updatedAt: string;
}
interface Database {
name: string;
owner: string;
sizeBytes: number;
activeConnections: number;
isManaged: boolean;
}
interface Role {
name: string;
canLogin: boolean;
connectionLimit: number;
isManaged: boolean;
}
interface Session {
processId: number;
database: string;
username: string;
state: string;
query?: string;
queryStartedAt?: string;
}
interface Backup {
fileName: string;
database: string;
sizeBytes: number;
createdAt: string;
sha256: string;
}
const authenticated = ref<boolean | null>(null);
const loading = ref(false);
const active = ref<Section>("overview");
const dark = ref(localStorage.getItem("postgres-admin-theme") === "dark");
const loginForm = reactive({ username: "admin", password: "" });
const overview = ref<JsonObject>({});
const clients = ref<Client[]>([]);
const databases = ref<Database[]>([]);
const roles = ref<Role[]>([]);
const sessions = ref<Session[]>([]);
const backups = ref<Backup[]>([]);
const sql = ref("SELECT current_database(), current_user, now();");
const sqlDatabase = ref("postgres");
const queryResult = ref<{ columns: string[]; rows: unknown[][]; rowCount: number; truncated: boolean; elapsedMilliseconds: number } | null>(null);
const sections: Array<{ key: Section; label: string }> = [
{ key: "overview", label: "运行概览" },
{ key: "clients", label: "接入应用" },
{ key: "databases", label: "数据库" },
{ key: "roles", label: "角色" },
{ key: "sessions", label: "活动会话" },
{ key: "sql", label: "只读 SQL" },
{ key: "backups", label: "备份恢复" }
];
const activeLabel = computed(() => sections.find(item => item.key === active.value)?.label ?? "管理面板");
function applyTheme() {
document.documentElement.dataset.theme = dark.value ? "dark" : "light";
localStorage.setItem("postgres-admin-theme", dark.value ? "dark" : "light");
}
async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
credentials: "same-origin",
...options,
headers: {
"Content-Type": "application/json",
...(options.headers || {})
}
});
if (response.status === 401) {
authenticated.value = false;
throw new Error("管理会话已过期,请重新登录。");
}
if (!response.ok) {
const body = await response.json().catch(() => ({ error: `请求失败(${response.status}` })) as { error?: string };
throw new Error(body.error || `请求失败(${response.status}`);
}
return response.status === 204 ? undefined as T : await response.json() as T;
}
async function login() {
loading.value = true;
try {
await api("/api/v1/auth/login", { method: "POST", body: JSON.stringify(loginForm) });
authenticated.value = true;
loginForm.password = "";
await loadAll();
} catch (error) {
ElMessage.error((error as Error).message);
} finally {
loading.value = false;
}
}
async function logout() {
await api("/api/v1/auth/logout", { method: "POST" }).catch(() => undefined);
authenticated.value = false;
}
async function loadAll() {
loading.value = true;
try {
const results = await Promise.all([
api<JsonObject>("/api/v1/overview"),
api<Client[]>("/api/v1/clients"),
api<Database[]>("/api/v1/databases"),
api<Role[]>("/api/v1/roles"),
api<Session[]>("/api/v1/sessions"),
api<Backup[]>("/api/v1/backups")
]);
[overview.value, clients.value, databases.value, roles.value, sessions.value, backups.value] = results;
authenticated.value = true;
if (!databases.value.some(item => item.name === sqlDatabase.value)) {
sqlDatabase.value = databases.value[0]?.name || "postgres";
}
} catch (error) {
if (authenticated.value !== false) ElMessage.error((error as Error).message);
} finally {
loading.value = false;
}
}
async function rotateEnrollmentToken() {
const result = await api<{ token: string }>("/api/v1/enrollment-token/rotate", { method: "POST" });
await ElMessageBox.alert(result.token, "新的接入令牌(仅显示一次)", {
confirmButtonText: "我已保存",
customClass: "secret-dialog"
});
}
async function rotateClient(client: Client) {
await ElMessageBox.confirm(`轮换 ${client.displayName} 的数据库密码后,客户端必须立即更新凭据。`, "轮换密码", { type: "warning" });
const result = await api<{ password: string }>(`/api/v1/clients/${encodeURIComponent(client.appId)}/rotate`, { method: "POST" });
await ElMessageBox.alert(result.password, "新密码(仅显示一次)", { confirmButtonText: "我已保存" });
}
async function revokeClient(client: Client) {
await ElMessageBox.confirm(`吊销后 ${client.displayName} 将无法连接数据库,但不会删除数据。`, "吊销客户端", { type: "warning" });
await api(`/api/v1/clients/${encodeURIComponent(client.appId)}/revoke`, { method: "POST" });
await loadAll();
}
async function deleteClient(client: Client) {
await ElMessageBox.confirm(
`删除后 ${client.displayName} 的数据库 ${client.databaseName} 和角色 ${client.roleName} 将被彻底销毁,不可恢复。`,
"删除接入应用",
{ type: "warning", confirmButtonText: "确认删除", confirmButtonClass: "el-button--danger" }
);
const { value } = await ElMessageBox.prompt(
`请输入 ${client.appId} 确认删除。`,
"二次确认",
{ type: "warning" }
);
await api(`/api/v1/clients/${encodeURIComponent(client.appId)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" });
await loadAll();
}
async function createDatabase() {
const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建数据库", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" });
await api("/api/v1/databases", { method: "POST", body: JSON.stringify({ name }) });
await loadAll();
}
async function dropDatabase(database: Database) {
const { value } = await ElMessageBox.prompt(`请输入 ${database.name} 确认删除。`, "删除数据库", { type: "warning" });
await api(`/api/v1/databases/${encodeURIComponent(database.name)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" });
await loadAll();
}
async function createRole() {
const { value: name } = await ElMessageBox.prompt("仅允许小写字母、数字和下划线。", "新建登录角色", { inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "角色名称格式无效" });
const { value: password } = await ElMessageBox.prompt("密码至少 16 个字符。", "设置角色密码", { inputType: "password", inputValidator: value => value.length >= 16 || "密码至少需要 16 个字符" });
await api("/api/v1/roles", { method: "POST", body: JSON.stringify({ name, password }) });
await loadAll();
}
async function dropRole(role: Role) {
const { value } = await ElMessageBox.prompt(`请输入 ${role.name} 确认删除。`, "删除角色", { type: "warning" });
await api(`/api/v1/roles/${encodeURIComponent(role.name)}?confirmation=${encodeURIComponent(value)}`, { method: "DELETE" });
await loadAll();
}
async function terminateSession(session: Session) {
await ElMessageBox.confirm(`确认终止 PID ${session.processId} 的连接?`, "终止会话", { type: "warning" });
await api(`/api/v1/sessions/${session.processId}/terminate`, { method: "POST" });
await loadAll();
}
async function runQuery() {
loading.value = true;
try {
queryResult.value = await api("/api/v1/query", { method: "POST", body: JSON.stringify({ database: sqlDatabase.value, sql: sql.value }) });
} catch (error) {
ElMessage.error((error as Error).message);
} finally {
loading.value = false;
}
}
async function createBackup(database: string) {
await api("/api/v1/backups", { method: "POST", body: JSON.stringify({ database }) });
ElMessage.success("备份已完成");
await loadAll();
}
async function restoreBackup(backup: Backup) {
const { value: targetDatabase } = await ElMessageBox.prompt("建议恢复到新的数据库名称。", "恢复备份", { inputValue: `${backup.database}_restored`, inputPattern: /^[a-z][a-z0-9_]{2,62}$/, inputErrorMessage: "数据库名称格式无效" });
const exists = databases.value.some(item => item.name === targetDatabase);
const { value: confirmation } = await ElMessageBox.prompt(`请输入 ${targetDatabase} 确认恢复${exists ? "并覆盖现有数据库" : ""}。`, "确认恢复", { type: "warning" });
await api("/api/v1/backups/restore", { method: "POST", body: JSON.stringify({ backupFileName: backup.fileName, targetDatabase, overwrite: exists, confirmation }) });
ElMessage.success("备份已恢复");
await loadAll();
}
function formatBytes(value: unknown) {
const bytes = Number(value || 0);
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
}
onMounted(async () => {
applyTheme();
await loadAll();
});
</script>
<template>
<main v-if="authenticated === false" class="login-screen">
<section class="login-card">
<div class="brand-mark">PG</div>
<h1>PostgreSQL 服务</h1>
<p>使用安装时设置的独立管理密码登录</p>
<el-form label-position="top" @submit.prevent="login">
<el-form-item label="用户名"><el-input v-model="loginForm.username" autocomplete="username" /></el-form-item>
<el-form-item label="密码"><el-input v-model="loginForm.password" type="password" show-password autocomplete="current-password" @keyup.enter="login" /></el-form-item>
<el-button type="primary" :loading="loading" class="full" @click="login">登录</el-button>
</el-form>
</section>
</main>
<div v-else class="shell" v-loading="loading && authenticated === null">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">PG</span><div><strong>PostgreSQL</strong><small>共享数据库服务</small></div></div>
<nav>
<button v-for="item in sections" :key="item.key" :class="{ active: active === item.key }" @click="active = item.key">{{ item.label }}</button>
</nav>
<div class="sidebar-foot"><span class="health-dot" />仅回环数据库端口</div>
</aside>
<section class="content">
<header class="topbar">
<div><h1>{{ activeLabel }}</h1><small>127.0.0.1:{{ overview.port || 15432 }}</small></div>
<div class="top-actions">
<el-button size="small" @click="dark = !dark; applyTheme()">{{ dark ? "浅色" : "深色" }}</el-button>
<el-button size="small" @click="loadAll">刷新</el-button>
<el-button size="small" @click="logout">退出</el-button>
</div>
</header>
<div class="mobile-section"><el-select v-model="active"><el-option v-for="item in sections" :key="item.key" :label="item.label" :value="item.key" /></el-select></div>
<section v-if="active === 'overview'" class="page-stack">
<div class="metric-grid">
<article><span>PostgreSQL 版本</span><strong>{{ overview.version || "-" }}</strong></article>
<article><span>活动连接</span><strong>{{ overview.connections || 0 }} / {{ overview.maxConnections || 0 }}</strong></article>
<article><span>数据库</span><strong>{{ overview.databases || 0 }}</strong></article>
<article><span>数据总量</span><strong>{{ formatBytes(overview.sizeBytes) }}</strong></article>
</div>
<section class="panel"><div class="panel-head"><div><h2>安全接入</h2><p>接入令牌只用于本机客户端登记轮换后旧令牌立即失效</p></div><el-button type="warning" plain @click="rotateEnrollmentToken">轮换接入令牌</el-button></div></section>
</section>
<section v-else-if="active === 'clients'" class="panel">
<div class="panel-head"><div><h2>接入应用</h2><p>每个应用使用独立数据库和 SCRAM 角色</p></div></div>
<el-table :data="clients" table-layout="auto">
<el-table-column prop="displayName" label="应用" min-width="160"><template #default="{ row }"><strong>{{ row.displayName }}</strong><small class="block">{{ row.appId }}</small></template></el-table-column>
<el-table-column prop="databaseName" label="数据库" min-width="190" />
<el-table-column prop="roleName" label="角色" min-width="190" />
<el-table-column label="扩展"><template #default="{ row }">{{ row.extensions.join(", ") || "-" }}</template></el-table-column>
<el-table-column label="状态"><template #default="{ row }"><el-tag :type="row.status === 'active' ? 'success' : 'danger'">{{ row.status === "active" ? "正常" : "已吊销" }}</el-tag></template></el-table-column>
<el-table-column label="操作" width="190" fixed="right"><template #default="{ row }"><el-button size="small" @click="rotateClient(row)">轮换密码</el-button><el-button size="small" type="danger" plain @click="revokeClient(row)">吊销</el-button><el-button size="small" type="danger" @click="deleteClient(row)">删除</el-button></template></el-table-column>
</el-table>
</section>
<section v-else-if="active === 'databases'" class="panel">
<div class="panel-head"><div><h2>数据库</h2><p>托管数据库必须先吊销客户端不能直接删除</p></div><el-button type="primary" @click="createDatabase">新建数据库</el-button></div>
<el-table :data="databases"><el-table-column prop="name" label="名称" min-width="180" /><el-table-column prop="owner" label="所有者" min-width="160" /><el-table-column label="容量"><template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template></el-table-column><el-table-column prop="activeConnections" label="连接" /><el-table-column label="类型"><template #default="{ row }"><el-tag v-if="row.isManaged">应用托管</el-tag><span v-else>普通</span></template></el-table-column><el-table-column label="操作" width="170"><template #default="{ row }"><el-button size="small" @click="createBackup(row.name)">备份</el-button><el-button size="small" type="danger" plain :disabled="row.isManaged || ['postgres','template0','template1','postgres_service'].includes(row.name)" @click="dropDatabase(row)">删除</el-button></template></el-table-column></el-table>
</section>
<section v-else-if="active === 'roles'" class="panel">
<div class="panel-head"><div><h2>登录角色</h2><p>新角色默认无超级用户建库和建角色权限</p></div><el-button type="primary" @click="createRole">新建角色</el-button></div>
<el-table :data="roles"><el-table-column prop="name" label="名称" min-width="200" /><el-table-column label="可登录"><template #default="{ row }">{{ row.canLogin ? "是" : "否" }}</template></el-table-column><el-table-column prop="connectionLimit" label="连接限制" /><el-table-column label="类型"><template #default="{ row }"><el-tag v-if="row.isManaged">应用托管</el-tag><span v-else>普通</span></template></el-table-column><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" type="danger" plain :disabled="row.isManaged" @click="dropRole(row)">删除</el-button></template></el-table-column></el-table>
</section>
<section v-else-if="active === 'sessions'" class="panel">
<div class="panel-head"><div><h2>活动会话</h2><p>终止连接可能中断应用事务请谨慎操作</p></div></div>
<el-table :data="sessions" table-layout="auto"><el-table-column prop="processId" label="PID" width="90" /><el-table-column prop="database" label="数据库" min-width="140" /><el-table-column prop="username" label="用户" min-width="150" /><el-table-column prop="state" label="状态" width="100" /><el-table-column prop="query" label="当前语句" min-width="300" show-overflow-tooltip /><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" type="danger" plain @click="terminateSession(row)">终止</el-button></template></el-table-column></el-table>
</section>
<section v-else-if="active === 'sql'" class="page-stack">
<section class="panel"><div class="panel-head"><div><h2>只读 SQL 工作台</h2><p>强制只读事务30 秒超时单语句和最多 1000 行结果</p></div><el-select v-model="sqlDatabase" class="database-select"><el-option v-for="item in databases" :key="item.name" :label="item.name" :value="item.name" /></el-select></div><el-input v-model="sql" type="textarea" :rows="8" class="sql-editor" spellcheck="false" /><div class="query-actions"><el-button type="primary" :loading="loading" @click="runQuery">执行查询</el-button></div></section>
<section v-if="queryResult" class="panel"><div class="panel-head"><div><h2>查询结果</h2><p>{{ queryResult.rowCount }} 行 · {{ queryResult.elapsedMilliseconds }} ms<span v-if="queryResult.truncated"> · 已截断</span></p></div></div><div class="table-scroll"><el-table :data="queryResult.rows"><el-table-column v-for="(column, index) in queryResult.columns" :key="column + index" :label="column" min-width="140"><template #default="{ row }">{{ row[index] }}</template></el-table-column></el-table></div></section>
</section>
<section v-else class="panel">
<div class="panel-head"><div><h2>手动备份与恢复</h2><p>默认不执行定时备份恢复到已有数据库需要二次确认并会中断连接</p></div></div>
<el-table :data="backups"><el-table-column prop="database" label="来源数据库" min-width="160" /><el-table-column prop="fileName" label="文件" min-width="260" /><el-table-column label="大小"><template #default="{ row }">{{ formatBytes(row.sizeBytes) }}</template></el-table-column><el-table-column label="创建时间" min-width="170"><template #default="{ row }">{{ new Date(row.createdAt).toLocaleString() }}</template></el-table-column><el-table-column prop="sha256" label="SHA-256" min-width="220" show-overflow-tooltip /><el-table-column label="操作" width="100"><template #default="{ row }"><el-button size="small" @click="restoreBackup(row)">恢复</el-button></template></el-table-column></el-table>
</section>
</section>
</div>
</template>