@@ -0,0 +1,318 @@
< 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 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 > < / 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 >