feat(delivery): integrate PostgreSQL delivery
This commit is contained in:
@@ -5,6 +5,7 @@ export interface DeliveryTarget {
|
||||
id: number
|
||||
name: string
|
||||
target_type: string
|
||||
service_type?: string
|
||||
awx_inventory_id: number
|
||||
awx_template_id: number
|
||||
enabled: boolean
|
||||
@@ -48,18 +49,36 @@ export interface CreateMySQLDeliveryPayload {
|
||||
mysql_root_password?: string
|
||||
}
|
||||
|
||||
export interface CreatePostgreSQLDeliveryPayload {
|
||||
business_line_id: number
|
||||
target_id: number
|
||||
namespace: string
|
||||
cluster_name: string
|
||||
version_major: string
|
||||
topology: 'standalone' | 'primary_replica'
|
||||
replica_count?: number
|
||||
target_hosts?: string[]
|
||||
cpu_milli: number
|
||||
memory_mi: number
|
||||
storage_gi: number
|
||||
data_root?: string
|
||||
max_connections?: number
|
||||
}
|
||||
|
||||
export interface DeliveryTask {
|
||||
id: string
|
||||
business_line_id: number
|
||||
requested_by: number
|
||||
component: string
|
||||
target_type: string
|
||||
service_type?: string
|
||||
target_id: number
|
||||
namespace: string
|
||||
instance_name: string
|
||||
target_host?: string
|
||||
target_host_ip?: string
|
||||
mysql_port?: number
|
||||
postgresql_port?: number
|
||||
status: string
|
||||
error_message?: string
|
||||
created_at: string
|
||||
@@ -109,8 +128,8 @@ export interface MySQLServiceLedgerItem {
|
||||
}
|
||||
|
||||
export const deliveryApi = {
|
||||
async listTargets(): Promise<DeliveryTarget[]> {
|
||||
const data = await authRequest('/auth/api/v1/delivery/targets?component=mysql')
|
||||
async listTargets(component = 'mysql'): Promise<DeliveryTarget[]> {
|
||||
const data = await authRequest(`/auth/api/v1/delivery/targets?component=${encodeURIComponent(component)}`)
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
@@ -156,6 +175,17 @@ export const deliveryApi = {
|
||||
})
|
||||
},
|
||||
|
||||
async createPostgreSQL(payload: CreatePostgreSQLDeliveryPayload): Promise<DeliveryTask> {
|
||||
const data = await authRequest('/auth/api/v1/delivery/postgresql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Idempotency-Key': createIdempotencyKey({ ...payload, service: 'postgresql' }),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
return data.task
|
||||
},
|
||||
|
||||
async getTask(taskId: string): Promise<{ task: DeliveryTask; events: TaskEvent[] }> {
|
||||
const data = await authRequest(`/auth/api/v1/delivery/tasks/${encodeURIComponent(taskId)}`)
|
||||
return {
|
||||
@@ -204,13 +234,13 @@ export const deliveryApi = {
|
||||
},
|
||||
}
|
||||
|
||||
function createIdempotencyKey(payload: CreateMySQLDeliveryPayload) {
|
||||
function createIdempotencyKey(payload: { business_line_id: number; target_id: number; namespace: string; instance_name?: string; cluster_name?: string; service?: string }) {
|
||||
return [
|
||||
'mysql',
|
||||
payload.service || 'mysql',
|
||||
payload.business_line_id,
|
||||
payload.target_id,
|
||||
payload.namespace,
|
||||
payload.instance_name,
|
||||
payload.instance_name || payload.cluster_name,
|
||||
Date.now(),
|
||||
].join(':')
|
||||
}
|
||||
|
||||
@@ -82,32 +82,46 @@
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="form-field" :class="{ 'is-invalid': instanceNameError }">
|
||||
实例名称
|
||||
{{ isPostgreSQL ? '集群名称' : '实例名称' }}
|
||||
<div class="instance-name-editor">
|
||||
<el-input
|
||||
v-model="deliveryForm.instanceName"
|
||||
placeholder="mysql-business-service"
|
||||
:placeholder="isPostgreSQL ? 'postgresql-business-service' : 'mysql-business-service'"
|
||||
maxlength="63"
|
||||
/>
|
||||
<el-button class="random-name-button" text type="primary" :icon="Refresh" aria-label="随机生成实例名称" title="随机生成符合命名规范的实例名称" @click.stop="regenerateInstanceName">随机</el-button>
|
||||
<el-button class="random-name-button" text type="primary" :icon="Refresh" :aria-label="`随机生成${isPostgreSQL ? '集群' : '实例'}名称`" title="随机生成符合命名规范的名称" @click.stop="regenerateInstanceName">随机</el-button>
|
||||
</div>
|
||||
<small v-if="instanceNameError" class="field-error">{{ instanceNameError }}</small>
|
||||
<small v-else class="field-help">3–63 位,以小写字母开头,仅支持小写字母、数字和短横线。</small>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
MySQL 版本
|
||||
{{ isPostgreSQL ? 'PostgreSQL 版本' : 'MySQL 版本' }}
|
||||
<el-select v-model="deliveryForm.version">
|
||||
<el-option v-for="version in activeService.versions" :key="version" :label="version" :value="version" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
部署主机
|
||||
<el-select v-model="deliveryForm.targetHost" placeholder="自动调度" clearable>
|
||||
{{ isPostgreSQL && deliveryForm.mode === 'primary_replica' ? '部署主机(按角色顺序)' : '部署主机' }}
|
||||
<el-select
|
||||
v-if="isPostgreSQL && deliveryForm.mode === 'primary_replica'"
|
||||
v-model="deliveryForm.targetHosts"
|
||||
multiple
|
||||
:multiple-limit="deliveryForm.replicaCount + 1"
|
||||
placeholder="选择主节点和从节点"
|
||||
>
|
||||
<el-option v-for="host in targetHosts" :key="host.name" :label="hostLabel(host)" :value="host.name" />
|
||||
</el-select>
|
||||
<el-select v-else v-model="deliveryForm.targetHost" placeholder="自动调度" clearable>
|
||||
<el-option label="自动调度" value="" />
|
||||
<el-option v-for="host in targetHosts" :key="host.name" :label="hostLabel(host)" :value="host.name" />
|
||||
</el-select>
|
||||
</label>
|
||||
<div class="form-field" :class="{ 'is-invalid': rootPasswordError }">
|
||||
<label v-if="isPostgreSQL && deliveryForm.mode === 'primary_replica'" class="form-field">
|
||||
从节点数量
|
||||
<el-input-number v-model="deliveryForm.replicaCount" :min="1" :max="7" controls-position="right" />
|
||||
<small class="field-help">需要 {{ deliveryForm.replicaCount + 1 }} 台不同主机;当前模板可用 {{ targetHosts.length }} 台。</small>
|
||||
</label>
|
||||
<div v-if="!isPostgreSQL" class="form-field" :class="{ 'is-invalid': rootPasswordError }">
|
||||
<span>数据库管理员密码(root)</span>
|
||||
<div class="password-editor">
|
||||
<el-input
|
||||
@@ -133,7 +147,7 @@
|
||||
type="button"
|
||||
class="choice-btn"
|
||||
:class="{ active: deliveryForm.mode === mode.value }"
|
||||
@click="deliveryForm.mode = mode.value"
|
||||
@click="selectDeliveryMode(mode.value)"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
@@ -175,8 +189,10 @@
|
||||
</div>
|
||||
<div class="form-grid resource-fields">
|
||||
<label class="form-field" :class="{ 'is-invalid': dataDiskError }">
|
||||
数据盘挂载点
|
||||
{{ isPostgreSQL ? '数据根目录' : '数据盘挂载点' }}
|
||||
<el-input v-if="isPostgreSQL" v-model="deliveryForm.dataDisk" disabled placeholder="/data/postgresql" />
|
||||
<el-autocomplete
|
||||
v-else
|
||||
v-model="deliveryForm.dataDisk"
|
||||
:fetch-suggestions="queryMountPathSuggestions"
|
||||
placeholder="/data"
|
||||
@@ -191,22 +207,22 @@
|
||||
</template>
|
||||
</el-autocomplete>
|
||||
<small v-if="dataDiskError" class="field-error">{{ dataDiskError }}</small>
|
||||
<small v-else class="field-help">填写目标主机上的绝对挂载点,例如 /data 或 /data/ax;实例数据会写入其下的标准目录。</small>
|
||||
<small v-else class="field-help">{{ isPostgreSQL ? '首发版本固定使用 /data/postgresql,每个实例写入独立目录。' : '填写目标主机上的绝对挂载点,例如 /data 或 /data/ax;实例数据会写入其下的标准目录。' }}</small>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<label v-if="!isPostgreSQL" class="form-field">
|
||||
MySQL 端口
|
||||
<el-input-number v-model="deliveryForm.port" :min="13306" :max="13999" controls-position="right" placeholder="自动分配" />
|
||||
<small class="field-help">留空由平台从 13306–13999 端口池分配。</small>
|
||||
</label>
|
||||
</div>
|
||||
<div class="path-hint">
|
||||
<div><span>安装目录</span><code>{{ mysqlInstallPath }}</code></div>
|
||||
<div><span>数据目录</span><code>{{ mysqlDataPath }}</code></div>
|
||||
<small>程序安装在系统盘,数据盘只决定实例数据、日志和临时文件的落点。</small>
|
||||
<div><span>安装目录</span><code>{{ installPath }}</code></div>
|
||||
<div><span>数据目录</span><code>{{ dataPath }}</code></div>
|
||||
<small>{{ isPostgreSQL ? '端口由平台从 15432–15999 自动分配。' : '程序安装在系统盘,数据盘只决定实例数据、日志和临时文件的落点。' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="optional-panel">
|
||||
<details v-if="!isPostgreSQL" class="optional-panel">
|
||||
<summary>
|
||||
<span class="parameter-icon"><Connection /></span>
|
||||
<span><strong>数据库配置</strong><small>可选,默认值已按标准基线填充</small></span>
|
||||
@@ -254,39 +270,39 @@
|
||||
<div class="advanced-grid">
|
||||
<label class="form-field">最大连接数
|
||||
<el-select v-model="deliveryForm.maxConnections">
|
||||
<el-option v-for="item in maxConnectionOptions" :key="item" :label="item === 'auto' ? '自动联动' : item" :value="item" />
|
||||
<el-option v-for="item in activeMaxConnectionOptions" :key="item" :label="item === 'auto' ? '自动联动' : item" :value="item" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="form-field">Redo 日志容量
|
||||
<label v-if="!isPostgreSQL" class="form-field">Redo 日志容量
|
||||
<el-select v-model="deliveryForm.redoCapacity">
|
||||
<el-option label="自动联动" value="auto" />
|
||||
<el-option v-for="item in logSizeOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="form-field">事务日志刷盘
|
||||
<label v-if="!isPostgreSQL" class="form-field">事务日志刷盘
|
||||
<el-select v-model="deliveryForm.flushLogAtCommit">
|
||||
<el-option label="每次提交(1)" :value="1" />
|
||||
<el-option label="每秒写入(2)" :value="2" />
|
||||
<el-option label="每秒刷盘(0)" :value="0" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="form-field">Binlog 同步
|
||||
<label v-if="!isPostgreSQL" class="form-field">Binlog 同步
|
||||
<el-select v-model="deliveryForm.syncBinlog">
|
||||
<el-option label="每次提交(1)" :value="1" />
|
||||
<el-option label="由系统调度(0)" :value="0" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="form-field">InnoDB I/O 能力
|
||||
<label v-if="!isPostgreSQL" class="form-field">InnoDB I/O 能力
|
||||
<el-select v-model="deliveryForm.ioCapacity">
|
||||
<el-option v-for="item in ioCapacityOptions" :key="item" :label="String(item)" :value="item" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="form-field">慢查询阈值
|
||||
<label v-if="!isPostgreSQL" class="form-field">慢查询阈值
|
||||
<el-select v-model="deliveryForm.longQueryTime">
|
||||
<el-option v-for="item in longQueryOptions" :key="item" :label="`${item} 秒`" :value="item" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="form-field">Binlog 保留时间
|
||||
<label v-if="!isPostgreSQL" class="form-field">Binlog 保留时间
|
||||
<el-select v-model="deliveryForm.binlogExpireSeconds">
|
||||
<el-option label="1 天" :value="86400" />
|
||||
<el-option label="3 天" :value="259200" />
|
||||
@@ -294,7 +310,7 @@
|
||||
<el-option label="14 天" :value="1209600" />
|
||||
</el-select>
|
||||
</label>
|
||||
<label class="form-field">单个 Binlog 上限
|
||||
<label v-if="!isPostgreSQL" class="form-field">单个 Binlog 上限
|
||||
<el-select v-model="deliveryForm.maxBinlogSize">
|
||||
<el-option v-for="item in logSizeOptions" :key="item" :label="item" :value="item" />
|
||||
</el-select>
|
||||
@@ -314,7 +330,7 @@
|
||||
|
||||
<div class="preview-facts">
|
||||
<div><span>执行方式</span><strong>自动调度</strong></div>
|
||||
<div><span>部署主机</span><strong>{{ deliveryForm.targetHost || '由 AWX Inventory 分配' }}</strong></div>
|
||||
<div><span>部署主机</span><strong>{{ selectedTargetHostText }}</strong></div>
|
||||
<div><span>数据位置</span><strong>{{ deliveryForm.dataDisk }}</strong></div>
|
||||
</div>
|
||||
|
||||
@@ -402,22 +418,22 @@
|
||||
<div class="access-card-head">
|
||||
<div>
|
||||
<span class="eyebrow">PRIMARY ENDPOINT</span>
|
||||
<h4>MySQL 连接入口</h4>
|
||||
<h4>{{ isPostgreSQL ? 'PostgreSQL 连接入口' : 'MySQL 连接入口' }}</h4>
|
||||
</div>
|
||||
<span class="tag" :class="connectionReady ? 'tag-green' : 'tag-amber'">{{ connectionReady ? '可连接' : '等待地址' }}</span>
|
||||
</div>
|
||||
<div class="endpoint-value">{{ resultAddress }}</div>
|
||||
<p class="access-note">使用交付时设置的 root 管理员账号连接。</p>
|
||||
<p class="access-note">{{ isPostgreSQL ? '使用 AWX 托管的 postgres 管理员凭据连接。' : '使用交付时设置的 root 管理员账号连接。' }}</p>
|
||||
<div class="command-block">
|
||||
<code>{{ mysqlCommand }}</code>
|
||||
<el-button text :icon="CopyDocument" aria-label="复制 MySQL 连接命令" title="复制 MySQL 连接命令" @click="copyText(mysqlCommand, '连接命令已复制')" />
|
||||
<code>{{ databaseCommand }}</code>
|
||||
<el-button text :icon="CopyDocument" aria-label="复制数据库连接命令" title="复制数据库连接命令" @click="copyText(databaseCommand, '连接命令已复制')" />
|
||||
</div>
|
||||
<div class="access-actions">
|
||||
<el-button :icon="CopyDocument" :disabled="!connectionReady" @click="copyText(connectionUri, '连接地址已复制')">复制连接地址</el-button>
|
||||
<el-button :icon="DocumentCopy" :disabled="!connectionReady" @click="copyText(mysqlCommand, '命令已复制')">复制连接命令</el-button>
|
||||
<el-button :icon="DocumentCopy" :disabled="!connectionReady" @click="copyText(databaseCommand, '命令已复制')">复制连接命令</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="credentialEligible && !credentialDismissed" class="credential-handoff">
|
||||
<div v-if="!isPostgreSQL && credentialEligible && !credentialDismissed" class="credential-handoff">
|
||||
<div class="credential-head">
|
||||
<div>
|
||||
<span class="eyebrow">ONE-TIME CREDENTIAL</span>
|
||||
@@ -446,7 +462,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="credentialEligible" class="credential-unavailable">
|
||||
<div v-else-if="!isPostgreSQL && credentialEligible" class="credential-unavailable">
|
||||
<strong>一次性凭证已关闭</strong>
|
||||
<span>历史任务仍可查看连接信息,但平台不会再次展示 root 明文密码。</span>
|
||||
</div>
|
||||
@@ -461,11 +477,11 @@
|
||||
<span class="tag tag-green">{{ deliveryForm.version }}</span>
|
||||
</div>
|
||||
<dl class="detail-list">
|
||||
<div><dt>实例名称</dt><dd class="mono">{{ deliveryForm.instanceName }}</dd></div>
|
||||
<div><dt>{{ isPostgreSQL ? '集群名称' : '实例名称' }}</dt><dd class="mono">{{ deliveryForm.instanceName }}</dd></div>
|
||||
<div><dt>部署主机</dt><dd class="mono">{{ deliveredHost || 'AWX 自动分配' }}</dd></div>
|
||||
<div><dt>运行规格</dt><dd>{{ deliveryForm.spec }} · {{ storageLabel }}</dd></div>
|
||||
<div><dt>数据盘</dt><dd class="mono">{{ deliveryForm.dataDisk }}</dd></div>
|
||||
<div><dt>管理账号</dt><dd class="mono">root · 一次性交付</dd></div>
|
||||
<div><dt>管理账号</dt><dd class="mono">{{ isPostgreSQL ? 'postgres · AWX 凭据托管' : 'root · 一次性交付' }}</dd></div>
|
||||
<div><dt>健康状态</dt><dd>{{ deliveryFailed ? resultSubtitle : activeService.healthText }}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -480,9 +496,9 @@
|
||||
<span class="meta">任务 {{ deploymentId || taskNo }}</span>
|
||||
</div>
|
||||
<div class="path-grid">
|
||||
<div class="path-item"><span>本地 Socket</span><code>{{ mysqlSocketPath }}</code><button type="button" @click="copyText(mysqlSocketPath, 'Socket 路径已复制')"><CopyDocument /></button></div>
|
||||
<div class="path-item"><span>配置文件</span><code>{{ mysqlConfigPath }}</code><button type="button" @click="copyText(mysqlConfigPath, '配置路径已复制')"><CopyDocument /></button></div>
|
||||
<div class="path-item"><span>systemd 服务</span><code>{{ mysqlServiceName }}</code><button type="button" @click="copyText(mysqlServiceName, '服务名已复制')"><CopyDocument /></button></div>
|
||||
<div class="path-item"><span>本地 Socket</span><code>{{ databaseSocketPath }}</code><button type="button" @click="copyText(databaseSocketPath, 'Socket 路径已复制')"><CopyDocument /></button></div>
|
||||
<div class="path-item"><span>配置文件</span><code>{{ databaseConfigPath }}</code><button type="button" @click="copyText(databaseConfigPath, '配置路径已复制')"><CopyDocument /></button></div>
|
||||
<div class="path-item"><span>systemd 服务</span><code>{{ databaseServiceName }}</code><button type="button" @click="copyText(databaseServiceName, '服务名已复制')"><CopyDocument /></button></div>
|
||||
<div class="path-item"><span>注册系统</span><code>{{ activeService.registerTo }}</code></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -618,6 +634,8 @@ interface WorkbenchSnapshot {
|
||||
timezone: string
|
||||
dataDisk: string
|
||||
targetHost: string
|
||||
targetHosts: string[]
|
||||
replicaCount: number
|
||||
maxConnections: string
|
||||
redoCapacity: string
|
||||
flushLogAtCommit: number
|
||||
@@ -694,30 +712,30 @@ const basicServices = ref<Service[]>([
|
||||
},
|
||||
{
|
||||
key: 'pgsql',
|
||||
name: 'PgSQL',
|
||||
name: 'PostgreSQL',
|
||||
icon: 'Pg',
|
||||
bgColor: 'var(--logo-ap-bg)',
|
||||
iconColor: 'var(--tag-purple-text)',
|
||||
description: '流复制主从架构,规划中 · 接入 CloudDM 统一审核体系。',
|
||||
playbook: 'roles/pgsql-deploy',
|
||||
version: '规划中',
|
||||
status: '规划中',
|
||||
template: '',
|
||||
runner: '',
|
||||
versions: [],
|
||||
modes: [],
|
||||
specs: [],
|
||||
disks: [],
|
||||
charsets: [],
|
||||
defaultPort: 5432,
|
||||
defaultPaths: { install: '', data: '', log: '' },
|
||||
registerTo: '',
|
||||
configId: '',
|
||||
description: '原生 PostgreSQL 单机或一主多从交付,使用独立数据目录与 systemd 实例。',
|
||||
playbook: 'postgresql-deploy.yml',
|
||||
version: 'v15 / v16',
|
||||
status: '可交付',
|
||||
template: 'XINFRA PostgreSQL Delivery',
|
||||
runner: 'xinfra-e2e-execution',
|
||||
versions: ['PostgreSQL 16', 'PostgreSQL 15'],
|
||||
modes: [
|
||||
{ value: 'standalone', label: '单实例' },
|
||||
{ value: 'primary_replica', label: '一主多从' },
|
||||
],
|
||||
specs: ['0.5C / 2G', '1C / 2G', '2C / 4G', '4C / 8G', '8C / 16G', '16C / 32G'],
|
||||
disks: ['20 GB', '50 GB', '100 GB', '500 GB', '1000 GB'],
|
||||
charsets: ['UTF8'],
|
||||
defaultPort: 0,
|
||||
defaultPaths: { install: '/usr/lib/postgresql', data: '/data/postgresql', log: '/data/postgresql' },
|
||||
registerTo: 'PostgreSQL 专用资源台账',
|
||||
configId: 'PG-NATIVE-V1',
|
||||
assetId: '',
|
||||
healthText: '',
|
||||
statusColor: 'var(--text-dim)',
|
||||
disabled: true,
|
||||
opacity: 0.75,
|
||||
healthText: 'PostgreSQL 就绪 · TCP 探测通过',
|
||||
},
|
||||
{
|
||||
key: 'redis',
|
||||
@@ -817,7 +835,7 @@ const rollbackReleasing = ref(false)
|
||||
const cloudDMRetrying = ref(false)
|
||||
const rollbackActionBusy = computed(() => rollbackRetrying.value || rollbackReleasing.value)
|
||||
const canManageRollback = computed(() => authStore.isAdmin && lastDeliveryStatus.value === 'rollback_failed' && Boolean(deploymentId.value))
|
||||
const canRetryCloudDM = computed(() => deliveryRegisterFailed.value && Boolean(deploymentId.value))
|
||||
const canRetryCloudDM = computed(() => !isPostgreSQL.value && deliveryRegisterFailed.value && Boolean(deploymentId.value))
|
||||
|
||||
const deliveryForm = reactive({
|
||||
instanceName: generateInstanceName(activeServiceKey.value, currentName.value),
|
||||
@@ -833,6 +851,8 @@ const deliveryForm = reactive({
|
||||
timezone: '+08:00',
|
||||
dataDisk: '/data',
|
||||
targetHost: '',
|
||||
targetHosts: [] as string[],
|
||||
replicaCount: 1,
|
||||
maxConnections: 'auto',
|
||||
redoCapacity: 'auto',
|
||||
flushLogAtCommit: 1,
|
||||
@@ -844,6 +864,7 @@ const deliveryForm = reactive({
|
||||
})
|
||||
|
||||
const maxConnectionOptions = ['auto', '200', '500', '1000', '2000', '4000', '8000', '16000']
|
||||
const postgresqlMaxConnectionOptions = ['auto', '200', '500', '1000', '2000', '4000', '8000']
|
||||
const logSizeOptions = ['128M', '256M', '512M', '1G']
|
||||
const ioCapacityOptions = [200, 2000, 5000]
|
||||
const longQueryOptions = [0.5, 1, 2, 5, 10]
|
||||
@@ -864,24 +885,40 @@ const deliveryStageStepIndex: Record<string, number> = {
|
||||
}
|
||||
|
||||
const activeService = computed(() => basicServices.value.find((service) => service.key === activeServiceKey.value && !service.disabled))
|
||||
const isPostgreSQL = computed(() => activeServiceKey.value === 'pgsql')
|
||||
const selectedTarget = computed(() => deliveryTargets.value.find((target) => target.id === selectedTargetId.value))
|
||||
const targetHosts = computed<TargetHostOption[]>(() => parseTargetHosts(selectedTarget.value?.metadata))
|
||||
const selectedHost = computed(() => targetHosts.value.find((host) => host.name === deliveryForm.targetHost))
|
||||
const activeMaxConnectionOptions = computed(() => isPostgreSQL.value ? postgresqlMaxConnectionOptions : maxConnectionOptions)
|
||||
const collationOptions = computed(() => collationMap[deliveryForm.charset] || [])
|
||||
const storageLabel = computed(() => `${deliveryForm.storageGi} GB`)
|
||||
const instanceNameError = computed(() => instanceNameValidationError(deliveryForm.instanceName))
|
||||
const dataDiskError = computed(() => dataDiskValidationError(deliveryForm.dataDisk))
|
||||
const rootPasswordError = computed(() => rootPasswordValidationError(deliveryForm.rootPassword))
|
||||
const mysqlInstallPath = computed(() => instanceNameError.value ? '请先填写有效实例名' : `/opt/mysql-delivery/${deliveryForm.instanceName}`)
|
||||
const mysqlDataPath = computed(() => {
|
||||
const rootPasswordError = computed(() => isPostgreSQL.value ? '' : rootPasswordValidationError(deliveryForm.rootPassword))
|
||||
const installPath = computed(() => {
|
||||
if (instanceNameError.value) return '请先填写有效实例名'
|
||||
return isPostgreSQL.value ? `/usr/lib/postgresql/${postgresqlVersionValue(deliveryForm.version)}` : `/opt/mysql-delivery/${deliveryForm.instanceName}`
|
||||
})
|
||||
const dataPath = computed(() => {
|
||||
if (instanceNameError.value) return '请先填写有效实例名'
|
||||
if (dataDiskError.value) return '请先选择有效挂载点'
|
||||
if (isPostgreSQL.value) return `${deliveryForm.dataDisk}/${deliveryForm.instanceName}/data`
|
||||
return `${deliveryForm.dataDisk}/mysql-delivery/${deliveryForm.instanceName}/data`
|
||||
})
|
||||
const taskNo = computed(() => `CMP-20260721-${activeServiceKey.value === 'mysql' ? '0024' : '0023'}`)
|
||||
const currentModeLabel = computed(() => activeService.value?.modes.find((mode) => mode.value === deliveryForm.mode)?.label || '-')
|
||||
const topologySummary = computed(() => `${currentModeLabel.value} · ${deliveryForm.spec} · ${storageLabel.value}`)
|
||||
const topologyNodes = computed(() => {
|
||||
if (isPostgreSQL.value) {
|
||||
const count = deliveryForm.mode === 'primary_replica' ? deliveryForm.replicaCount + 1 : 1
|
||||
const pinned = deliveryForm.mode === 'primary_replica' ? deliveryForm.targetHosts : deliveryForm.targetHost ? [deliveryForm.targetHost] : []
|
||||
const candidates = (pinned.length ? pinned : targetHosts.value.slice(0, count).map((host) => host.name))
|
||||
.map((name) => targetHosts.value.find((host) => host.name === name) || { name })
|
||||
return Array.from({ length: count }, (_, index) => {
|
||||
const host = candidates[index]
|
||||
return { name: host?.name || '等待调度', ip: host?.ip || '由 AWX Inventory 分配', role: index === 0 ? (count === 1 ? '单实例' : '主节点') : `从节点 ${index}` }
|
||||
})
|
||||
}
|
||||
if (deliveryForm.mode === 'single') {
|
||||
const previewHost = selectedHost.value || targetHosts.value[0]
|
||||
return [{ name: previewHost?.name || '等待调度', ip: previewHost?.ip || '由 AWX Inventory 分配', role: '单实例' }]
|
||||
@@ -912,37 +949,53 @@ const runnerPreview = computed(() => {
|
||||
`component: ${activeService.value?.key || '-'}`,
|
||||
`instance: ${deliveryForm.instanceName}`,
|
||||
`topology: ${currentModeLabel.value}`,
|
||||
`limit: ${deliveryForm.targetHost || 'auto'}`,
|
||||
`data_disk: ${deliveryForm.dataDisk}`,
|
||||
`limit: ${selectedTargetHostText.value}`,
|
||||
`${isPostgreSQL.value ? 'data_root' : 'data_disk'}: ${deliveryForm.dataDisk}`,
|
||||
`resources: ${deliveryForm.spec} / ${storageLabel.value}`,
|
||||
`charset: ${deliveryForm.charset}`,
|
||||
`collation: ${deliveryForm.collation}`,
|
||||
`timezone: ${deliveryForm.timezone}`,
|
||||
...(isPostgreSQL.value ? [`replica_count: ${deliveryForm.mode === 'primary_replica' ? deliveryForm.replicaCount : 0}`] : [
|
||||
`charset: ${deliveryForm.charset}`,
|
||||
`collation: ${deliveryForm.collation}`,
|
||||
`timezone: ${deliveryForm.timezone}`,
|
||||
]),
|
||||
`register_to: ${activeService.value?.registerTo || '-'}`,
|
||||
].join('\n')
|
||||
})
|
||||
const connectionReady = computed(() => Boolean(deliveredHost.value && deliveredPort.value))
|
||||
const credentialEligible = computed(() => {
|
||||
if (!connectionReady.value || !['finished', 'register_failed'].includes(lastDeliveryStatus.value)) return false
|
||||
if (isPostgreSQL.value || !connectionReady.value || !['finished', 'register_failed'].includes(lastDeliveryStatus.value)) return false
|
||||
return credentialAvailable.value !== false
|
||||
})
|
||||
const revealedRootCredential = computed(() => revealedCredentials.value.find((item) => item.username === 'root'))
|
||||
const resultReady = computed(() => !taskStateLoading.value && !running.value && isTerminalDeliveryStatus(lastDeliveryStatus.value))
|
||||
const selectedTargetHostText = computed(() => {
|
||||
if (isPostgreSQL.value && deliveryForm.mode === 'primary_replica') {
|
||||
return deliveryForm.targetHosts.length ? deliveryForm.targetHosts.join(', ') : '由 AWX Inventory 分配'
|
||||
}
|
||||
return deliveryForm.targetHost || '由 AWX Inventory 分配'
|
||||
})
|
||||
const previewAddress = computed(() => `${selectedHost.value?.ip || 'AWX 自动分配'}:${deliveryForm.port || '自动端口'}`)
|
||||
const resultAddress = computed(() => connectionReady.value ? `${deliveredHost.value}:${deliveredPort.value}` : '交付接口未返回地址')
|
||||
const connectionUri = computed(() => connectionReady.value ? `mysql://root@${deliveredHost.value}:${deliveredPort.value}` : '')
|
||||
const mysqlCommand = computed(() => connectionReady.value ? `mysql -h ${deliveredHost.value} -P ${deliveredPort.value} -u root -p` : '等待交付接口返回主机和端口')
|
||||
const mysqlSocketPath = computed(() => `/run/mysql-delivery-${deliveryForm.instanceName}/mysql.sock`)
|
||||
const mysqlConfigPath = computed(() => `/etc/mysql/mysql-delivery/${deliveryForm.instanceName}.cnf`)
|
||||
const mysqlServiceName = computed(() => `mysql-delivery@${deliveryForm.instanceName}.service`)
|
||||
const connectionUri = computed(() => {
|
||||
if (!connectionReady.value) return ''
|
||||
return isPostgreSQL.value ? `postgresql://postgres@${deliveredHost.value}:${deliveredPort.value}/postgres` : `mysql://root@${deliveredHost.value}:${deliveredPort.value}`
|
||||
})
|
||||
const databaseCommand = computed(() => {
|
||||
if (!connectionReady.value) return '等待交付接口返回主机和端口'
|
||||
return isPostgreSQL.value
|
||||
? `psql -h ${deliveredHost.value} -p ${deliveredPort.value} -U postgres -d postgres`
|
||||
: `mysql -h ${deliveredHost.value} -P ${deliveredPort.value} -u root -p`
|
||||
})
|
||||
const databaseSocketPath = computed(() => isPostgreSQL.value ? `/run/postgresql-xinfra/${deliveryForm.instanceName}/.s.PGSQL.${deliveredPort.value || '<port>'}` : `/run/mysql-delivery-${deliveryForm.instanceName}/mysql.sock`)
|
||||
const databaseConfigPath = computed(() => isPostgreSQL.value ? `${deliveryForm.dataDisk}/${deliveryForm.instanceName}/conf/postgresql.conf` : `/etc/mysql/mysql-delivery/${deliveryForm.instanceName}.cnf`)
|
||||
const databaseServiceName = computed(() => isPostgreSQL.value ? `postgresql-xinfra@${deliveryForm.instanceName}.service` : `mysql-delivery@${deliveryForm.instanceName}.service`)
|
||||
const resultTitle = computed(() => {
|
||||
if (deliveryRolledBack.value) return '交付失败,已完成回退'
|
||||
if (deliveryAcknowledged.value) return '交付失败,已确认清理'
|
||||
if (deliveryRegisterFailed.value) return 'MySQL 已交付,CloudDM 注册失败'
|
||||
if (deliveryRegisterFailed.value) return `${activeService.value?.name || '数据库'} 已交付,CloudDM 注册失败`
|
||||
if (lastDeliveryStatus.value === 'rollback_failed') return '交付失败,回退失败'
|
||||
if (lastDeliveryStatus.value === 'canceled') return '交付任务已取消'
|
||||
if (deliveryFailed.value) return '交付失败'
|
||||
return activeServiceKey.value === 'mysql' ? 'MySQL 实例已交付' : 'OpenResty 集群已交付'
|
||||
return isPostgreSQL.value ? 'PostgreSQL 集群已交付' : 'MySQL 实例已交付'
|
||||
})
|
||||
const resultSubtitle = computed(() => {
|
||||
if (deliveryRolledBack.value) return deliveryError.value || '目标机实例文件、配置和资源记录已清理'
|
||||
@@ -982,6 +1035,7 @@ watch(currentName, (name) => {
|
||||
|
||||
watch(selectedTargetId, () => {
|
||||
deliveryForm.targetHost = ''
|
||||
deliveryForm.targetHosts = []
|
||||
precheckPassed.value = false
|
||||
})
|
||||
|
||||
@@ -1001,6 +1055,7 @@ watch(deliveryForm, () => {
|
||||
|
||||
watch(activeServiceKey, () => {
|
||||
hydrateServiceDefaults()
|
||||
loadDeliveryTargets()
|
||||
if (activeServiceKey.value) {
|
||||
workbenchReady = false
|
||||
restoreWorkbench()
|
||||
@@ -1096,6 +1151,8 @@ function persistWorkbench() {
|
||||
timezone: deliveryForm.timezone,
|
||||
dataDisk: deliveryForm.dataDisk,
|
||||
targetHost: deliveryForm.targetHost,
|
||||
targetHosts: deliveryForm.targetHosts,
|
||||
replicaCount: deliveryForm.replicaCount,
|
||||
maxConnections: deliveryForm.maxConnections,
|
||||
redoCapacity: deliveryForm.redoCapacity,
|
||||
flushLogAtCommit: deliveryForm.flushLogAtCommit,
|
||||
@@ -1153,9 +1210,9 @@ async function restoreWorkbench() {
|
||||
try {
|
||||
const data = await deliveryApi.getTask(snapshot.deploymentId)
|
||||
deliveredHost.value = data.task.target_host_ip || deliveredHost.value
|
||||
deliveredPort.value = data.task.mysql_port || deliveredPort.value
|
||||
deliveredPort.value = (isPostgreSQL.value ? data.task.postgresql_port : data.task.mysql_port) || deliveredPort.value
|
||||
lastDeliveryStatus.value = data.task.status
|
||||
credentialAvailable.value = data.task.credential_available
|
||||
credentialAvailable.value = isPostgreSQL.value ? false : data.task.credential_available
|
||||
deliveryError.value = data.task.error_message || deliveryError.value
|
||||
seenEventIds.value = new Set()
|
||||
applyTaskEvents(data.events, false)
|
||||
@@ -1188,7 +1245,7 @@ function hydrateServiceDefaults() {
|
||||
if (!service) return
|
||||
deliveryForm.instanceName = generateInstanceName(service.key, currentName.value)
|
||||
deliveryForm.version = service.versions[0] || ''
|
||||
deliveryForm.mode = service.modes[1]?.value || service.modes[0]?.value || 'single'
|
||||
deliveryForm.mode = service.modes[0]?.value || 'single'
|
||||
deliveryForm.spec = service.key === 'mysql'
|
||||
? (service.specs[0] || '')
|
||||
: (service.specs[1] || service.specs[0] || '')
|
||||
@@ -1202,6 +1259,8 @@ function hydrateServiceDefaults() {
|
||||
deliveryForm.timezone = '+08:00'
|
||||
deliveryForm.dataDisk = service.defaultPaths.data || '/data'
|
||||
deliveryForm.targetHost = ''
|
||||
deliveryForm.targetHosts = []
|
||||
deliveryForm.replicaCount = 1
|
||||
deliveryForm.rootPassword = generateRootPassword()
|
||||
deliveryForm.maxConnections = 'auto'
|
||||
deliveryForm.redoCapacity = 'auto'
|
||||
@@ -1252,6 +1311,16 @@ function resetExecutionState() {
|
||||
}
|
||||
|
||||
function defaultSteps(): DeliveryStep[] {
|
||||
if (isPostgreSQL.value) {
|
||||
return [
|
||||
['资源锁定与主机检查', '配额、主机、端口和实例目录'],
|
||||
['安装 PostgreSQL 与初始化集群', '软件包、数据目录、运行用户'],
|
||||
['应用实例配置', 'postgresql.conf、pg_hba.conf、systemd'],
|
||||
['配置复制拓扑', '复制账号、slot、primary_conninfo'],
|
||||
['数据库健康检查', '端口、角色、版本和数据目录'],
|
||||
['资源入账与交付归档', 'PostgreSQL 集群、实例与资源台账'],
|
||||
].map(([name, desc]) => ({ name, desc, state: 'pending' as StepState }))
|
||||
}
|
||||
const isMysql = activeServiceKey.value === 'mysql'
|
||||
const copy = isMysql
|
||||
? [
|
||||
@@ -1283,10 +1352,21 @@ async function precheck() {
|
||||
ElMessage.warning('当前没有可用的 AWX 部署模板,请联系平台管理员配置')
|
||||
return
|
||||
}
|
||||
if (deliveryForm.port !== undefined && (deliveryForm.port < 13306 || deliveryForm.port > 13999)) {
|
||||
if (!isPostgreSQL.value && deliveryForm.port !== undefined && (deliveryForm.port < 13306 || deliveryForm.port > 13999)) {
|
||||
ElMessage.warning('手动端口必须在 13306–13999 范围内')
|
||||
return
|
||||
}
|
||||
if (isPostgreSQL.value && deliveryForm.mode === 'primary_replica') {
|
||||
const requiredHosts = deliveryForm.replicaCount + 1
|
||||
if (targetHosts.value.length < requiredHosts) {
|
||||
ElMessage.warning(`一主 ${deliveryForm.replicaCount} 从需要 ${requiredHosts} 台不同主机,当前 Inventory 只有 ${targetHosts.value.length} 台`)
|
||||
return
|
||||
}
|
||||
if (deliveryForm.targetHosts.length > 0 && deliveryForm.targetHosts.length !== requiredHosts) {
|
||||
ElMessage.warning(`请按主节点、从节点顺序选择 ${requiredHosts} 台主机,或清空后由平台自动分配`)
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
const conflict = await findInstanceNameConflict(businessLineId, normalizeDNSLabel(deliveryForm.instanceName))
|
||||
if (conflict) {
|
||||
@@ -1325,9 +1405,10 @@ async function findInstanceNameConflict(businessLineId: number, instanceName: st
|
||||
'rolling_back',
|
||||
'rollback_failed',
|
||||
])
|
||||
const component = isPostgreSQL.value ? 'postgresql' : 'mysql'
|
||||
const [tasks, services] = await Promise.all([
|
||||
deliveryApi.listTasks({ businessLineId, component: 'mysql' }),
|
||||
deliveryApi.listMySQLServices(businessLineId),
|
||||
deliveryApi.listTasks({ businessLineId, component }),
|
||||
isPostgreSQL.value ? Promise.resolve([]) : deliveryApi.listMySQLServices(businessLineId),
|
||||
])
|
||||
if (tasks.some((task) => task.instance_name === instanceName && occupiedStatuses.has(task.status))) {
|
||||
return `实例名称 ${instanceName} 已被运行中或尚未释放的交付任务占用`
|
||||
@@ -1350,7 +1431,7 @@ async function createTask() {
|
||||
return
|
||||
}
|
||||
if (!activeService.value) return
|
||||
if (activeService.value.key !== 'mysql') {
|
||||
if (!['mysql', 'pgsql'].includes(activeService.value.key)) {
|
||||
ElMessage.warning(`${activeService.value.name} 的 AWX 交付模板尚未接入`)
|
||||
return
|
||||
}
|
||||
@@ -1364,9 +1445,11 @@ async function createTask() {
|
||||
activeView.value = 'execution'
|
||||
steps.value = defaultSteps().map((step) => ({ ...step, state: 'pending' }))
|
||||
try {
|
||||
const task = await deliveryApi.createMySQL(mysqlDeliveryPayload(businessLineId))
|
||||
const task = isPostgreSQL.value
|
||||
? await deliveryApi.createPostgreSQL(postgresqlDeliveryPayload(businessLineId))
|
||||
: await deliveryApi.createMySQL(mysqlDeliveryPayload(businessLineId))
|
||||
deploymentId.value = task.id
|
||||
credentialAvailable.value = task.credential_available
|
||||
credentialAvailable.value = isPostgreSQL.value ? false : task.credential_available
|
||||
deliveryForm.rootPassword = ''
|
||||
upsertDeliveryHistory(task.id, task.status, task.error_message || '', task.created_at)
|
||||
deliveryLog.value += `\n[task] ${task.id} created by ${currentName.value}`
|
||||
@@ -1430,6 +1513,28 @@ function mysqlDeliveryPayload(businessLineId: number) {
|
||||
return payload
|
||||
}
|
||||
|
||||
function postgresqlDeliveryPayload(businessLineId: number) {
|
||||
const resources = parseSpec(deliveryForm.spec)
|
||||
const selectedHosts = deliveryForm.mode === 'primary_replica'
|
||||
? deliveryForm.targetHosts
|
||||
: deliveryForm.targetHost ? [deliveryForm.targetHost] : []
|
||||
return {
|
||||
business_line_id: businessLineId,
|
||||
target_id: selectedTargetId.value || 0,
|
||||
namespace: normalizeDNSLabel(currentName.value),
|
||||
cluster_name: normalizeDNSLabel(deliveryForm.instanceName),
|
||||
version_major: postgresqlVersionValue(deliveryForm.version),
|
||||
topology: deliveryForm.mode as 'standalone' | 'primary_replica',
|
||||
replica_count: deliveryForm.mode === 'primary_replica' ? deliveryForm.replicaCount : 0,
|
||||
target_hosts: selectedHosts.length ? selectedHosts : undefined,
|
||||
cpu_milli: resources.cpuMilli,
|
||||
memory_mi: resources.memoryMi,
|
||||
storage_gi: deliveryForm.storageGi,
|
||||
data_root: '/data/postgresql',
|
||||
max_connections: deliveryForm.maxConnections === 'auto' ? undefined : Number(deliveryForm.maxConnections),
|
||||
}
|
||||
}
|
||||
|
||||
function parseTargetHosts(metadata?: string): TargetHostOption[] {
|
||||
if (!metadata) return []
|
||||
try {
|
||||
@@ -1460,8 +1565,8 @@ async function pollTask(id: string) {
|
||||
try {
|
||||
const data = await deliveryApi.getTask(id)
|
||||
deliveredHost.value = data.task.target_host_ip || deliveredHost.value
|
||||
deliveredPort.value = data.task.mysql_port || deliveredPort.value
|
||||
credentialAvailable.value = data.task.credential_available
|
||||
deliveredPort.value = (isPostgreSQL.value ? data.task.postgresql_port : data.task.mysql_port) || deliveredPort.value
|
||||
credentialAvailable.value = isPostgreSQL.value ? false : data.task.credential_available
|
||||
upsertDeliveryHistory(id, data.task.status, data.task.error_message || '', data.task.created_at)
|
||||
applyTaskEvents(data.events)
|
||||
applyDeliveryStatus(data.task.status, data.task.error_message || '')
|
||||
@@ -1568,10 +1673,17 @@ function applyDeliveryStatus(status: string, message: string) {
|
||||
}
|
||||
|
||||
async function loadDeliveryTargets() {
|
||||
if (!activeService.value) {
|
||||
deliveryTargets.value = []
|
||||
selectedTargetId.value = undefined
|
||||
return
|
||||
}
|
||||
targetsLoading.value = true
|
||||
try {
|
||||
deliveryTargets.value = await deliveryApi.listTargets()
|
||||
const preferred = deliveryTargets.value.find((target) => target.name === mysqlDeliveryTemplateName)
|
||||
deliveryTargets.value = await deliveryApi.listTargets(isPostgreSQL.value ? 'postgresql' : activeServiceKey.value)
|
||||
const preferred = isPostgreSQL.value
|
||||
? deliveryTargets.value.find((target) => !/callback|rollback/i.test(target.name))
|
||||
: deliveryTargets.value.find((target) => target.name === mysqlDeliveryTemplateName)
|
||||
selectedTargetId.value = preferred?.id || deliveryTargets.value[0]?.id
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '获取部署目标失败')
|
||||
@@ -1664,6 +1776,17 @@ function defaultMountPathOptions(): DeliveryMountPath[] {
|
||||
return ['/data', '/disk1', '/mnt', '/opt/mysql-delivery'].map((path) => ({ path, available_gi: 0 }))
|
||||
}
|
||||
|
||||
function postgresqlVersionValue(version: string) {
|
||||
return version.match(/\d+/)?.[0] || '16'
|
||||
}
|
||||
|
||||
function selectDeliveryMode(mode: string) {
|
||||
deliveryForm.mode = mode
|
||||
deliveryForm.targetHost = ''
|
||||
deliveryForm.targetHosts = []
|
||||
if (isPostgreSQL.value && mode === 'primary_replica') deliveryForm.replicaCount = 1
|
||||
}
|
||||
|
||||
function normalizeDNSLabel(value: string) {
|
||||
const normalized = value
|
||||
.toLowerCase()
|
||||
@@ -1676,7 +1799,7 @@ function normalizeDNSLabel(value: string) {
|
||||
}
|
||||
|
||||
function generateInstanceName(serviceKey: string, businessLineName: string) {
|
||||
const prefix = serviceKey === 'mysql' ? 'mysql' : 'nginx'
|
||||
const prefix = serviceKey === 'pgsql' ? 'postgresql' : serviceKey === 'mysql' ? 'mysql' : 'nginx'
|
||||
// 限长业务线段,确保 63 字符截断不会吃掉随机后缀(prefix 5 + 连字符 2 + 后缀 6 = 13)
|
||||
const businessLine = normalizeDNSLabel(businessLineName).slice(0, 50).replace(/-+$/g, '')
|
||||
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
|
||||
|
||||
@@ -173,6 +173,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>PostgreSQL 交付记录</h3>
|
||||
<span class="meta">共 {{ postgresqlDeliveryRecords.length }} 条 · 当前业务线:{{ currentName }}</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>集群名称</th>
|
||||
<th>任务编号</th>
|
||||
<th>连接地址</th>
|
||||
<th>命名空间</th>
|
||||
<th>交付状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="!deliveryLoading && postgresqlDeliveryRecords.length === 0">
|
||||
<td colspan="7" class="text-dim">当前业务线暂无 PostgreSQL 交付记录</td>
|
||||
</tr>
|
||||
<tr v-for="task in postgresqlDeliveryRecords" :key="task.id" class="tr-hover">
|
||||
<td><strong class="mono">{{ task.instance_name }}</strong></td>
|
||||
<td class="mono">{{ task.id }}</td>
|
||||
<td class="mono">{{ deliveryEndpoint(task) }}</td>
|
||||
<td class="mono">{{ task.namespace }}</td>
|
||||
<td><span class="tag" :class="deliveryStatusClass(task.status)">{{ deliveryStatusText(task.status) }}</span></td>
|
||||
<td>{{ formatDeliveryTime(task.created_at) }}</td>
|
||||
<td><el-button link type="primary" @click="openDeliveryDetail(task)">查看详情</el-button></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="deliveryDetailVisible" width="640px" align-center destroy-on-close>
|
||||
<template #header>
|
||||
<div class="delivery-dialog-head">
|
||||
@@ -227,7 +263,7 @@
|
||||
</template>
|
||||
<el-button v-else type="warning" :loading="credentialRevealing" @click="revealDeliveryCredential(selectedDeliveryTask)">查看一次性密码</el-button>
|
||||
</div>
|
||||
<div v-else-if="['finished', 'register_failed'].includes(selectedDeliveryTask.status)" class="credential-unavailable">
|
||||
<div v-else-if="isMySQLDeliveryTask(selectedDeliveryTask) && ['finished', 'register_failed'].includes(selectedDeliveryTask.status)" class="credential-unavailable">
|
||||
<strong>一次性凭证已关闭</strong>
|
||||
<span>历史任务仍可查看连接信息,但平台不会再次展示 root 明文密码。</span>
|
||||
</div>
|
||||
@@ -269,6 +305,7 @@ const servicePage = ref(1)
|
||||
const servicePageSize = ref(10)
|
||||
|
||||
const deliveryRecords = ref<DeliveryTask[]>([])
|
||||
const postgresqlDeliveryRecords = computed(() => deliveryRecords.value.filter((task) => task.service_type === 'postgresql' || task.component === 'postgresql'))
|
||||
const deliveryLoading = ref(false)
|
||||
const deliveryDetailVisible = ref(false)
|
||||
const selectedDeliveryTask = ref<DeliveryTask>()
|
||||
@@ -348,7 +385,7 @@ const loadDeliveryRecords = async () => {
|
||||
}
|
||||
deliveryLoading.value = true
|
||||
try {
|
||||
const items = await deliveryApi.listTasks({ businessLineId, component: 'mysql' })
|
||||
const items = await deliveryApi.listTasks({ businessLineId })
|
||||
deliveryRecords.value = items.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
|
||||
} catch (error) {
|
||||
deliveryRecords.value = []
|
||||
@@ -382,7 +419,7 @@ function openDeliveryDetail(task: DeliveryTask) {
|
||||
}
|
||||
|
||||
function latestDeliveryTaskForService(serviceName: string) {
|
||||
return deliveryRecords.value.find((task) => task.instance_name === serviceName)
|
||||
return deliveryRecords.value.find((task) => isMySQLDeliveryTask(task) && task.instance_name === serviceName)
|
||||
}
|
||||
|
||||
function latestCredentialTaskForService(serviceName: string) {
|
||||
@@ -391,7 +428,13 @@ function latestCredentialTaskForService(serviceName: string) {
|
||||
}
|
||||
|
||||
function credentialEligible(task: DeliveryTask) {
|
||||
return ['finished', 'register_failed'].includes(task.status) && Boolean(task.credential_available)
|
||||
return isMySQLDeliveryTask(task)
|
||||
&& ['finished', 'register_failed'].includes(task.status)
|
||||
&& Boolean(task.credential_available)
|
||||
}
|
||||
|
||||
function isMySQLDeliveryTask(task: DeliveryTask) {
|
||||
return task.service_type === undefined || task.service_type === '' || task.service_type === 'mysql'
|
||||
}
|
||||
|
||||
function markCredentialUnavailable(taskID: string) {
|
||||
@@ -435,7 +478,8 @@ function deliveryStatusClass(status: string) {
|
||||
function deliveryEndpoint(task: DeliveryTask) {
|
||||
const host = task.target_host_ip || task.target_host
|
||||
if (!host) return '等待主机分配'
|
||||
return task.mysql_port ? `${host}:${task.mysql_port}` : host
|
||||
const port = task.service_type === 'postgresql' ? task.postgresql_port : task.mysql_port
|
||||
return port ? `${host}:${port}` : host
|
||||
}
|
||||
|
||||
function formatDeliveryTime(value: string, withYear = false) {
|
||||
@@ -478,7 +522,7 @@ function downloadDeliveryCredential(task: DeliveryTask) {
|
||||
item.service || 'mysql',
|
||||
item.instance_name || task.instance_name,
|
||||
item.host || task.target_host_ip || task.target_host || '',
|
||||
item.port || task.mysql_port || '',
|
||||
item.port || (task.service_type === 'postgresql' ? task.postgresql_port : task.mysql_port) || '',
|
||||
item.username,
|
||||
item.account_host || '',
|
||||
item.password,
|
||||
|
||||
+42
-35
@@ -1,44 +1,51 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/auth': {
|
||||
target: 'http://localhost:8083',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/swagger': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, '')
|
||||
const backendTarget = env.XINFRA_BACKEND_PROXY_TARGET || 'http://localhost:8083'
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
vue(),
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/auth': {
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/api': {
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/swagger': {
|
||||
target: backendTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user