You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
341 lines
14 KiB
341 lines
14 KiB
<template>
|
|
<div>
|
|
<!-- 列表视图 -->
|
|
<div v-show="view === 'list'">
|
|
<div class="flex items-center justify-between mb-5">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-slate-800">环境节点(部署服务器)</h2>
|
|
<p class="text-sm text-slate-400 mt-1">部署服务器节点(如 上海 / 日本 / 新加坡):远端 IP、SSH 登录账号与私钥、私有镜像仓库、区域共享基础设施。应用管理里「部署到目标环境」从这里选择、两边对应;下一期即可从页面直接更新服务/部署应用。密钥加密存储,不回显明文。</p>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm text-white" @click="showForm()">+ 新增节点</button>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100">
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-zebra text-sm">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th><th>名称</th><th>区域码</th><th>SSH 目标</th><th>镜像仓库</th><th>启用</th><th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-if="loading">
|
|
<td colspan="7" class="text-center py-8"><span class="loading loading-spinner loading-sm"></span></td>
|
|
</tr>
|
|
<tr v-else-if="!rows.length">
|
|
<td colspan="7" class="text-center text-slate-400 py-8">暂无环境</td>
|
|
</tr>
|
|
<tr v-for="e in rows" :key="e.id">
|
|
<td class="font-mono">{{ e.id }}</td>
|
|
<td class="font-medium">{{ e.name || '' }}</td>
|
|
<td>{{ e.code || '' }}</td>
|
|
<td class="font-mono text-xs text-slate-500">{{ sshTarget(e) }}</td>
|
|
<td class="text-xs text-slate-500">{{ imageLabel(e) }}</td>
|
|
<td>
|
|
<span v-if="e.enabled" class="text-green-600">是</span>
|
|
<span v-else class="text-slate-400">否</span>
|
|
</td>
|
|
<td>
|
|
<div class="flex gap-1">
|
|
<button class="btn btn-ghost btn-xs" @click="showForm(e)">编辑</button>
|
|
<button class="btn btn-ghost btn-xs text-red-500" @click="delEnv(e.id)">删除</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 表单视图 -->
|
|
<div v-show="view === 'form'">
|
|
<div class="flex items-center gap-3 mb-5">
|
|
<button class="btn btn-ghost btn-sm" @click="showList()">← 返回</button>
|
|
<h2 class="text-lg font-semibold text-slate-800">{{ isEditing ? '编辑节点' : '新增节点' }}</h2>
|
|
</div>
|
|
<div class="bg-white rounded-xl shadow-sm border border-slate-100 p-6 max-w-3xl">
|
|
<form @submit.prevent="saveEnv">
|
|
<!-- 基本 -->
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">名称 *</span></label>
|
|
<input v-model="form.name" type="text" class="input input-bordered w-full" required placeholder="如 上海生产" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">区域码</span></label>
|
|
<input v-model="form.code" type="text" class="input input-bordered w-full" list="env-code-list" placeholder="shanghai / japan / singapore" />
|
|
<datalist id="env-code-list">
|
|
<option value="shanghai" /><option value="japan" /><option value="singapore" />
|
|
</datalist>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SSH 部署目标 -->
|
|
<div class="divider text-xs text-slate-400 mt-6">SSH 部署目标</div>
|
|
<div class="grid grid-cols-3 gap-4">
|
|
<div class="form-control col-span-2">
|
|
<label class="label"><span class="label-text font-medium">SSH 主机</span></label>
|
|
<input v-model="form.ssh_host" type="text" class="input input-bordered w-full" placeholder="139.224.212.237" @input="resetTest" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">端口</span></label>
|
|
<input v-model.number="form.ssh_port" type="number" class="input input-bordered w-full" placeholder="22" @input="resetTest" />
|
|
</div>
|
|
</div>
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">SSH 用户</span></label>
|
|
<input v-model="form.ssh_user" type="text" class="input input-bordered w-full" placeholder="root" @input="resetTest" />
|
|
</div>
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">SSH 私钥 (PEM)</span></label>
|
|
<textarea v-model="form.ssh_private_key" class="textarea textarea-bordered w-full font-mono text-xs h-28" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" @input="resetTest"></textarea>
|
|
<span class="text-xs text-slate-400 mt-1">加密存储;编辑时显示 [encrypted] 表示不改,如需更换直接粘贴新私钥。</span>
|
|
</div>
|
|
|
|
<!-- 私有镜像仓库 + Docker 网络(节点级共享)-->
|
|
<div class="divider text-xs text-slate-400 mt-6">私有镜像仓库 + Docker 网络</div>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">仓库地址</span></label>
|
|
<input v-model="form.registry" type="text" class="input input-bordered w-full" placeholder="docker-registry.ideapsound.com" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">Docker 网络</span></label>
|
|
<input v-model="form.docker_network" type="text" class="input input-bordered w-full" placeholder="1panel-network" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">仓库账号</span></label>
|
|
<input v-model="form.registry_user" type="text" class="input input-bordered w-full" autocomplete="off" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">仓库密码</span></label>
|
|
<input v-model="form.registry_pass" type="password" class="input input-bordered w-full" autocomplete="new-password" placeholder="[encrypted]" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 节点共享集群服务 -->
|
|
<div class="divider text-xs text-slate-400 mt-6">节点共享集群服务</div>
|
|
<p class="text-xs text-slate-400 -mt-2 mb-2">该服务器上各应用共用的地址。各应用自己的部署目录、业务库 DSN、镜像、redis 库号等,在「应用管理 → 部署到目标环境」里按应用填。</p>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">ETCD</span></label>
|
|
<input v-model="form.etcd_servers" type="text" class="input input-bordered w-full font-mono text-xs" placeholder="etcd:2379" @input="resetTest" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">NATS</span></label>
|
|
<input v-model="form.nats_url" type="text" class="input input-bordered w-full font-mono text-xs" placeholder="nats://nats:4222" @input="resetTest" />
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-4 mt-4">
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">Redis 地址</span></label>
|
|
<input v-model="form.redis_addr" type="text" class="input input-bordered w-full font-mono text-xs" placeholder="redis:6379" @input="resetTest" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label"><span class="label-text font-medium">Redis 密码</span></label>
|
|
<input v-model="form.redis_pwd" type="password" class="input input-bordered w-full" autocomplete="new-password" placeholder="[encrypted]" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 备注 + 启用 -->
|
|
<div class="form-control mt-4">
|
|
<label class="label"><span class="label-text font-medium">备注</span></label>
|
|
<input v-model="form.remark" type="text" class="input input-bordered w-full" />
|
|
</div>
|
|
<div class="flex items-center gap-2 mt-4">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input v-model="form.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" />
|
|
<span class="text-sm">启用</span>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- 测试结果 -->
|
|
<div v-show="testResult.show" class="mt-4 text-sm space-y-0.5">
|
|
<div v-for="it in testItems" :key="it.key" :class="it.ok ? 'text-green-700' : 'text-red-600'">
|
|
<span :class="it.ok ? 'text-green-600' : 'text-red-500'">{{ it.ok ? '✓' : '✗' }}</span>
|
|
{{ it.label }}:{{ it.msg }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 mt-6">
|
|
<button type="button" class="btn btn-ghost" @click="showList()">取消</button>
|
|
<button type="button" class="btn btn-outline" :disabled="testing" @click="testConn">{{ testing ? '测试中...' : '测试连接' }}</button>
|
|
<button type="submit" class="btn btn-primary text-white" :disabled="!testPassed">保存</button>
|
|
</div>
|
|
<p class="text-xs text-slate-400 mt-2">需先「测试连接」(SSH 握手成功即可保存;基础设施逐项仅作提示)。</p>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
|
|
definePageMeta({ title: '环境节点' })
|
|
|
|
interface EnvItem {
|
|
id: number
|
|
name: string
|
|
code: string
|
|
remark: string
|
|
enabled: boolean
|
|
ssh_host: string
|
|
ssh_port: number
|
|
ssh_user: string
|
|
ssh_private_key: string
|
|
registry: string
|
|
registry_user: string
|
|
registry_pass: string
|
|
docker_network: string
|
|
etcd_servers: string
|
|
redis_addr: string
|
|
redis_pwd: string
|
|
nats_url: string
|
|
}
|
|
|
|
interface EnvTestResp {
|
|
ok: boolean
|
|
ssh_ok: boolean; ssh_msg: string
|
|
etcd_ok: boolean; etcd_msg: string
|
|
redis_ok: boolean; redis_msg: string
|
|
nats_ok: boolean; nats_msg: string
|
|
}
|
|
|
|
const { consoleApi } = useApi()
|
|
const { success, error } = useToast()
|
|
|
|
const view = ref<'list' | 'form'>('list')
|
|
const rows = ref<EnvItem[]>([])
|
|
const loading = ref(false)
|
|
const isEditing = ref(false)
|
|
|
|
const testPassed = ref(false)
|
|
const testing = ref(false)
|
|
const testResult = reactive<EnvTestResp & { show: boolean }>({
|
|
show: false,
|
|
ok: false,
|
|
ssh_ok: false, ssh_msg: '',
|
|
etcd_ok: false, etcd_msg: '',
|
|
redis_ok: false, redis_msg: '',
|
|
nats_ok: false, nats_msg: '',
|
|
})
|
|
|
|
const testItems = computed(() => [
|
|
{ key: 'ssh', label: 'SSH 部署机', ok: testResult.ssh_ok, msg: testResult.ssh_msg },
|
|
{ key: 'etcd', label: 'ETCD', ok: testResult.etcd_ok, msg: testResult.etcd_msg },
|
|
{ key: 'redis', label: 'Redis', ok: testResult.redis_ok, msg: testResult.redis_msg },
|
|
{ key: 'nats', label: 'NATS', ok: testResult.nats_ok, msg: testResult.nats_msg },
|
|
])
|
|
|
|
function blankForm(): EnvItem {
|
|
return {
|
|
id: 0, name: '', code: '', remark: '', enabled: true,
|
|
ssh_host: '', ssh_port: 22, ssh_user: 'root', ssh_private_key: '',
|
|
registry: '', registry_user: '', registry_pass: '', docker_network: '1panel-network',
|
|
etcd_servers: '', redis_addr: '', redis_pwd: '', nats_url: '',
|
|
}
|
|
}
|
|
|
|
const form = reactive<EnvItem>(blankForm())
|
|
|
|
function assignForm(src: EnvItem) {
|
|
Object.assign(form, blankForm(), src)
|
|
}
|
|
|
|
function sshTarget(e: EnvItem) {
|
|
if (!e.ssh_host) return '-'
|
|
return `${e.ssh_user || 'root'}@${e.ssh_host}:${e.ssh_port || 22}`
|
|
}
|
|
|
|
function imageLabel(e: EnvItem) {
|
|
return e.registry || '-'
|
|
}
|
|
|
|
async function loadData() {
|
|
loading.value = true
|
|
try {
|
|
const data = await consoleApi<EnvItem[]>('envs/list', {})
|
|
rows.value = data ?? []
|
|
} catch (e: any) {
|
|
error(e?.message ?? '获取环境列表失败')
|
|
rows.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function resetTest() {
|
|
testPassed.value = false
|
|
testResult.show = false
|
|
}
|
|
|
|
function showList() {
|
|
view.value = 'list'
|
|
loadData()
|
|
}
|
|
|
|
function showForm(e?: EnvItem) {
|
|
if (e) {
|
|
isEditing.value = true
|
|
assignForm(e)
|
|
} else {
|
|
isEditing.value = false
|
|
assignForm(blankForm())
|
|
}
|
|
resetTest()
|
|
view.value = 'form'
|
|
}
|
|
|
|
async function testConn() {
|
|
if (!form.ssh_host.trim()) {
|
|
error('请先填 SSH 主机')
|
|
return
|
|
}
|
|
testing.value = true
|
|
try {
|
|
const d = await consoleApi<EnvTestResp>('envs/test', { ...form })
|
|
Object.assign(testResult, d, { show: true })
|
|
testPassed.value = !!d.ok
|
|
if (testPassed.value) success('SSH 握手成功,可以保存')
|
|
else error('SSH 未通过,请检查主机/私钥/用户')
|
|
} catch (e: any) {
|
|
error(e?.message ?? '测试失败')
|
|
} finally {
|
|
testing.value = false
|
|
}
|
|
}
|
|
|
|
async function saveEnv() {
|
|
if (!testPassed.value) {
|
|
error('请先「测试连接」通过再保存')
|
|
return
|
|
}
|
|
const data: Record<string, unknown> = { ...form }
|
|
let path = 'envs/add'
|
|
if (isEditing.value && form.id) {
|
|
data.id = Number(form.id)
|
|
path = 'envs/update'
|
|
} else {
|
|
delete data.id
|
|
}
|
|
try {
|
|
await consoleApi(path, data)
|
|
success('保存成功')
|
|
showList()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '失败')
|
|
}
|
|
}
|
|
|
|
function delEnv(id: number) {
|
|
if (!window.confirm(`删除环境 #${id} ?(该环境下若有应用关联将被拒绝)`)) return
|
|
consoleApi('envs/del', { id })
|
|
.then(() => { success('已删除'); loadData() })
|
|
.catch((e: any) => error(e?.message ?? '失败'))
|
|
}
|
|
|
|
onMounted(loadData)
|
|
</script>
|
|
|