feat(delivery): refine base service integrations

This commit is contained in:
mac
2026-07-29 16:36:27 +08:00
parent 2ca08f9360
commit 35875218c9
22 changed files with 1042 additions and 377 deletions
+52
View File
@@ -3,7 +3,59 @@
hosts: "{{ target_hosts | default('all') }}"
become: true
gather_facts: true
vars:
delivery_lookup_path: "{{ lookup_path | default('') }}"
tasks:
- name: Show directory completions
ansible.builtin.shell: |
set -euo pipefail
python3 - <<'PY'
import json
import os
import sys
prefix = os.environ.get("LOOKUP_PATH", "").strip()
if not prefix.startswith("/") or "\x00" in prefix:
print("XINFRA_PATH_COMPLETIONS_JSON=[]")
sys.exit(0)
if prefix.endswith("/"):
parent = prefix.rstrip("/") or "/"
needle = ""
else:
parent = os.path.dirname(prefix) or "/"
needle = os.path.basename(prefix)
items = []
try:
children = sorted(os.listdir(parent))
except OSError:
children = []
for name in children:
if needle and not name.startswith(needle):
continue
path = os.path.join(parent, name) if parent != "/" else "/" + name
if not os.path.isdir(path):
continue
available_gi = 0
try:
stat = os.statvfs(path)
available_gi = int(stat.f_bavail * stat.f_frsize / 1073741824)
except OSError:
pass
items.append({"path": path, "available_gi": available_gi})
print("XINFRA_PATH_COMPLETIONS_JSON=" + json.dumps(items, ensure_ascii=False))
PY
args:
executable: /bin/bash
environment:
LOOKUP_PATH: "{{ delivery_lookup_path }}"
changed_when: false
when: delivery_lookup_path | length > 0
- name: Show discovered mounts
ansible.builtin.debug:
var: ansible_mounts
when: delivery_lookup_path | length == 0
+1 -1
View File
@@ -12,6 +12,7 @@ declare module 'vue' {
AppSidebar: typeof import('./src/components/Layout/AppSidebar.vue')['default']
AuditLogTable: typeof import('./src/components/AuditLogTable.vue')['default']
BusinessLineSwitcher: typeof import('./src/components/BusinessLineSwitcher.vue')['default']
ElAutocomplete: typeof import('element-plus/es')['ElAutocomplete']
ElButton: typeof import('element-plus/es')['ElButton']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
@@ -26,7 +27,6 @@ declare module 'vue' {
ElPagination: typeof import('element-plus/es')['ElPagination']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSlider: typeof import('element-plus/es')['ElSlider']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
RouterLink: typeof import('vue-router')['RouterLink']
-27
View File
@@ -9,7 +9,6 @@
"version": "0.1.0",
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"@tanstack/vue-virtual": "^3.13.34",
"axios": "^1.5.0",
"element-plus": "^2.3.12",
"pinia": "^2.1.4",
@@ -994,32 +993,6 @@
"win32"
]
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.6",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.6.tgz",
"integrity": "sha512-h0/Ebo18CkOrChlQIhNtQkM5ySUnh/GumQ/D1st3hG2HWUPEF+ILUc2k29UtivCi/9G7w7G3/f7Xyd5cCFbKBw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/vue-virtual": {
"version": "3.13.34",
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.34.tgz",
"integrity": "sha512-CBqbCcnVsKpl9IJ7frPnbBmAqmc7JttSySg04kgLYJ4yYuC8JsAbtUHP0yLtWGLyByjihN3SwaDCgHQ+Z4iPoA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"vue": "^2.7.0 || ^3.0.0"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
-1
View File
@@ -10,7 +10,6 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.1.0",
"@tanstack/vue-virtual": "^3.13.34",
"axios": "^1.5.0",
"element-plus": "^2.3.12",
"pinia": "^2.1.4",
+27
View File
@@ -14,6 +14,11 @@ export interface WayneNamespace {
kubeNamespace: string
}
export interface SinaOrganization {
id: string
name: string
}
export const businessLineApi = {
async listMine(): Promise<BusinessLine[]> {
const token = getToken()
@@ -87,6 +92,28 @@ export const businessLineApi = {
body: JSON.stringify({ namespaces }),
})
},
async listSinaOrganizations(businessLineId: number, keyword = ''): Promise<SinaOrganization[]> {
const params = new URLSearchParams()
if (keyword.trim()) {
params.set('keyword', keyword.trim())
}
const suffix = params.toString() ? `?${params.toString()}` : ''
const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organizations${suffix}`)
return Array.isArray(data.items) ? data.items : []
},
async listMappedSinaOrganizations(businessLineId: number): Promise<SinaOrganization[]> {
const data = await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`)
return Array.isArray(data.items) ? data.items : []
},
async replaceMappedSinaOrganizations(businessLineId: number, organizations: SinaOrganization[]): Promise<void> {
await request(`/auth/api/v1/business-lines/${businessLineId}/sina-organization-mappings`, {
method: 'PUT',
body: JSON.stringify({ organizations }),
})
},
}
async function request(path: string, init: RequestInit = {}) {
+13 -3
View File
@@ -66,6 +66,7 @@ export interface DeliveryTask {
updated_at: string
started_at?: string
finished_at?: string
credential_available?: boolean
}
export interface TaskEvent {
@@ -85,8 +86,12 @@ export interface DeliveryTaskSnapshot {
}
export interface DeploymentCredential {
username: string
service: string
instance_name: string
host: string
port: number
username: string
account_host: string
password: string
}
@@ -109,8 +114,13 @@ export const deliveryApi = {
return Array.isArray(data.items) ? data.items : []
},
async listTargetMountPaths(targetId: number, host: string): Promise<DeliveryMountPath[]> {
const data = await authRequest(`/auth/api/v1/delivery/targets/${targetId}/hosts/${encodeURIComponent(host)}/mount-paths`)
async listTargetMountPaths(targetId: number, host: string, prefix = ''): Promise<DeliveryMountPath[]> {
const search = new URLSearchParams()
if (prefix) search.set('prefix', prefix)
const query = search.toString()
const data = await authRequest(
`/auth/api/v1/delivery/targets/${targetId}/hosts/${encodeURIComponent(host)}/mount-paths${query ? `?${query}` : ''}`,
)
return Array.isArray(data.items) ? data.items : []
},
+7 -4
View File
@@ -46,13 +46,13 @@ export interface MachineResourceList {
export interface MachineResourceQuery {
page: number
size: number
businessLineId?: number
hostname?: string
assetNumber?: string
type?: string
location?: string
ip?: string
spec?: string
businessLine?: string
source?: string
status?: string
}
@@ -83,21 +83,24 @@ export const emptyMachineOverview: MachineOverview = {
}
export const machineApi = {
async getOverview(): Promise<MachineOverview> {
return authRequest('/auth/api/v1/machines/overview')
async getOverview(businessLineId?: number): Promise<MachineOverview> {
const params = new URLSearchParams()
if (businessLineId) params.set('business_line_id', String(businessLineId))
const suffix = params.toString() ? `?${params.toString()}` : ''
return authRequest(`/auth/api/v1/machines/overview${suffix}`)
},
async listResources(query: MachineResourceQuery): Promise<MachineResourceList> {
const params = new URLSearchParams()
params.set('page', String(query.page))
params.set('size', String(query.size))
if (query.businessLineId) params.set('business_line_id', String(query.businessLineId))
if (query.hostname) params.set('hostname', query.hostname)
if (query.assetNumber) params.set('assetNumber', query.assetNumber)
if (query.type) params.set('type', query.type)
if (query.location) params.set('location', query.location)
if (query.ip) params.set('ip', query.ip)
if (query.spec) params.set('spec', query.spec)
if (query.businessLine) params.set('businessLine', query.businessLine)
if (query.source) params.set('source', query.source)
if (query.status) params.set('status', query.status)
const data = await authRequest(`/auth/api/v1/machines/resources?${params.toString()}`)
+105 -1
View File
@@ -45,6 +45,34 @@
</el-form-item>
<el-button type="primary" :loading="savingWayneNamespaces" @click="saveWayneNamespaceMapping">保存 Wayne namespace 映射</el-button>
</el-form>
<div class="section-divider"></div>
<el-form label-position="top">
<el-form-item label="SINA 映射">
<el-select
v-model="selectedSinaOrganizationIds"
multiple
filterable
remote
reserve-keyword
collapse-tags
collapse-tags-tooltip
:loading="loadingSinaOrganizations"
:remote-method="searchSinaOrganizations"
placeholder="选择 SINA 业务线"
@visible-change="handleSinaSelectVisible"
>
<el-option
v-for="item in sinaOrganizations"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-button type="primary" :loading="savingSinaOrganizations" @click="saveSinaOrganizationMapping">保存 SINA 映射</el-button>
</el-form>
</div>
<div v-else class="empty-state">需要当前业务线管理员权限</div>
</section>
@@ -53,7 +81,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { businessLineApi, type WayneNamespace } from '@/api/businessLine'
import { businessLineApi, type SinaOrganization, type WayneNamespace } from '@/api/businessLine'
import { userApi, type UserOption } from '@/api/user'
import { useBusinessLineStore } from '@/stores/businessLine'
@@ -63,9 +91,13 @@ const currentName = computed(() => businessLineStore.current?.name || '未选择
const users = ref<UserOption[]>([])
const wayneNamespaces = ref<WayneNamespace[]>([])
const selectedWayneNamespaceIds = ref<number[]>([])
const sinaOrganizations = ref<SinaOrganization[]>([])
const selectedSinaOrganizationIds = ref<string[]>([])
const granting = ref(false)
const loadingWayneNamespaces = ref(false)
const savingWayneNamespaces = ref(false)
const loadingSinaOrganizations = ref(false)
const savingSinaOrganizations = ref(false)
const grantForm = reactive<{
target_user_id: number | null
target_business_line_id: number | null
@@ -82,6 +114,7 @@ watch(
}
if (id && isCurrentBusinessLineAdmin.value) {
loadWayneNamespaceMapping(id)
loadSinaOrganizationMapping(id)
}
},
{ immediate: true },
@@ -98,6 +131,7 @@ watch(
const businessLineID = businessLineStore.current?.id
if (businessLineID) {
await loadWayneNamespaceMapping(businessLineID)
await loadSinaOrganizationMapping(businessLineID)
}
}
},
@@ -113,6 +147,7 @@ onMounted(async () => {
const businessLineID = businessLineStore.current?.id
if (businessLineID) {
await loadWayneNamespaceMapping(businessLineID)
await loadSinaOrganizationMapping(businessLineID)
}
}
})
@@ -182,6 +217,75 @@ async function saveWayneNamespaceMapping() {
savingWayneNamespaces.value = false
}
}
async function handleSinaSelectVisible(visible: boolean) {
if (!visible || sinaOrganizations.value.length) {
return
}
await searchSinaOrganizations('')
}
async function searchSinaOrganizations(keyword: string) {
const businessLineID = businessLineStore.current?.id
if (!businessLineID) {
return
}
loadingSinaOrganizations.value = true
try {
const rows = await businessLineApi.listSinaOrganizations(businessLineID, keyword)
sinaOrganizations.value = mergeSinaOrganizations(rows, selectedSinaOrganizations.value)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '查询 SINA 业务线失败')
} finally {
loadingSinaOrganizations.value = false
}
}
const selectedSinaOrganizations = computed(() =>
sinaOrganizations.value.filter((item) => selectedSinaOrganizationIds.value.includes(item.id)),
)
async function loadSinaOrganizationMapping(businessLineID: number) {
loadingSinaOrganizations.value = true
try {
const mapped = await businessLineApi.listMappedSinaOrganizations(businessLineID)
sinaOrganizations.value = mergeSinaOrganizations(sinaOrganizations.value, mapped)
selectedSinaOrganizationIds.value = mapped.map((item) => item.id)
} catch (error) {
selectedSinaOrganizationIds.value = []
ElMessage.error(error instanceof Error ? error.message : '查询 SINA 映射失败')
} finally {
loadingSinaOrganizations.value = false
}
}
async function saveSinaOrganizationMapping() {
const businessLineID = businessLineStore.current?.id
if (!businessLineID) {
ElMessage.warning('请选择当前业务线')
return
}
const selected = sinaOrganizations.value.filter((item) => selectedSinaOrganizationIds.value.includes(item.id))
savingSinaOrganizations.value = true
try {
await businessLineApi.replaceMappedSinaOrganizations(businessLineID, selected)
ElMessage.success('已保存 SINA 映射')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存 SINA 映射失败')
} finally {
savingSinaOrganizations.value = false
}
}
function mergeSinaOrganizations(...groups: SinaOrganization[][]) {
const rows = new Map<string, SinaOrganization>()
for (const group of groups) {
for (const item of group) {
rows.set(item.id, item)
}
}
return Array.from(rows.values())
}
</script>
<style scoped>
+25 -17
View File
@@ -51,7 +51,6 @@
<th>机房 / 区域</th>
<th>内网 IP</th>
<th>规格</th>
<th>业务线</th>
<th>数据来源</th>
<th>状态</th>
</tr>
@@ -112,16 +111,6 @@
@keyup.enter="reloadFirstPage"
/>
</th>
<th>
<el-input
v-model="filters.businessLine"
clearable
size="small"
placeholder="筛选业务线"
@clear="reloadFirstPage"
@keyup.enter="reloadFirstPage"
/>
</th>
<th>
<el-select v-model="filters.source" clearable size="small" placeholder="全部" @change="reloadFirstPage">
<el-option label="SINA CMDB" value="cmdb" />
@@ -149,12 +138,11 @@
<td><span class="tag zone-a">{{ item.location }}</span></td>
<td class="mono">{{ item.ip }}</td>
<td class="mono text-xs">{{ item.spec }}</td>
<td class="mono">{{ item.businessLine }}</td>
<td><span class="tag src-cmdb">{{ item.source }}</span></td>
<td class="status-text ok">● {{ item.status }}</td>
</tr>
<tr v-if="!loading && resources.length === 0">
<td colspan="9" class="empty-cell">暂无机器数据</td>
<td colspan="8" class="empty-cell">当前业务线暂无机器数据</td>
</tr>
</tbody>
</table>
@@ -177,10 +165,12 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { emptyMachineOverview, machineApi, type MachineResource } from '@/api/machine'
import { useBusinessLineStore } from '@/stores/businessLine'
const businessLineStore = useBusinessLineStore()
const overview = ref(emptyMachineOverview)
const resources = ref<MachineResource[]>([])
const total = ref(0)
@@ -195,7 +185,6 @@ const filters = reactive({
location: '',
ip: '',
spec: '',
businessLine: '',
source: '',
status: '',
})
@@ -207,22 +196,33 @@ const syncStatusText = computed(() => {
})
async function loadOverview() {
overview.value = await machineApi.getOverview()
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
overview.value = emptyMachineOverview
return
}
overview.value = await machineApi.getOverview(businessLineId)
}
async function loadResources() {
const businessLineId = businessLineStore.current?.id
if (!businessLineId) {
resources.value = []
total.value = 0
return
}
loading.value = true
try {
const data = await machineApi.listResources({
page: page.value,
size: size.value,
businessLineId,
hostname: filters.hostname.trim(),
assetNumber: filters.assetNumber.trim(),
type: filters.type,
location: filters.location.trim(),
ip: filters.ip.trim(),
spec: filters.spec.trim(),
businessLine: filters.businessLine.trim(),
source: filters.source,
status: filters.status,
})
@@ -252,6 +252,14 @@ async function handleSync() {
onMounted(async () => {
await Promise.all([loadOverview(), loadResources()])
})
watch(
() => businessLineStore.current?.id,
() => {
page.value = 1
Promise.all([loadOverview(), loadResources()])
},
)
</script>
<style scoped>
+152 -23
View File
@@ -176,7 +176,20 @@
<div class="form-grid resource-fields">
<label class="form-field" :class="{ 'is-invalid': dataDiskError }">
数据盘挂载点
<el-input v-model="deliveryForm.dataDisk" placeholder="/data" />
<el-autocomplete
v-model="deliveryForm.dataDisk"
:fetch-suggestions="queryMountPathSuggestions"
placeholder="/data"
:loading="mountPathsLoading"
clearable
>
<template #default="{ item }">
<div class="mount-path-option">
<span>{{ item.path }}</span>
<small>{{ mountPathMeta(item) }}</small>
</div>
</template>
</el-autocomplete>
<small v-if="dataDiskError" class="field-error">{{ dataDiskError }}</small>
<small v-else class="field-help">填写目标主机上的绝对挂载点,例如 /data 或 /data/ax;实例数据会写入其下的标准目录。</small>
</label>
@@ -415,10 +428,20 @@
<p>请立即复制或下载凭证文件;关闭后页面不再显示明文密码。</p>
<el-button v-if="!credentialRevealed" type="warning" :loading="credentialRevealing" @click="revealCredential">查看一次性密码</el-button>
<div v-else class="credential-secret">
<code>{{ revealedRootCredential?.password }}</code>
<div v-for="credential in revealedCredentials" :key="`${credential.username}@${credential.account_host || credential.host}`" class="credential-account">
<div class="credential-field">
<span>用户名</span>
<code>{{ formatCredentialUsername(credential) }}</code>
<el-button text :icon="CopyDocument" aria-label="复制用户名" title="复制用户名" @click="copyText(formatCredentialUsername(credential), '用户名已复制')" />
</div>
<div class="credential-field">
<span>密码</span>
<code>{{ credential.password }}</code>
<el-button text :icon="CopyDocument" aria-label="复制密码" title="复制密码" @click="copyText(credential.password, '密码已复制')" />
</div>
</div>
<div class="credential-actions">
<el-button :icon="CopyDocument" @click="copyText(revealedRootCredential?.password || '', 'root 密码已复制')">复制密码</el-button>
<el-button :icon="Download" @click="downloadCredentialFile">下载凭证文件</el-button>
<el-button :icon="Download" @click="downloadCredentialFile">下载 Excel 凭证</el-button>
<el-button type="success" plain :icon="CircleCheck" @click="dismissCredential">已保存,关闭</el-button>
</div>
</div>
@@ -495,7 +518,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowDown, Back, CircleCheck, Connection, CopyDocument, DocumentCopy, Download, Monitor, Promotion, Refresh, Setting, Tickets } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import { deliveryApi, type DeliveryTarget, type DeploymentCredential, type TaskEvent } from '@/api/delivery'
import { deliveryApi, type DeliveryMountPath, type DeliveryTarget, type DeploymentCredential, type TaskEvent } from '@/api/delivery'
import { useAuthStore } from '@/stores/auth'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
@@ -780,6 +803,8 @@ const targetsLoading = ref(false)
const pollTimer = ref<number | undefined>()
const seenEventIds = ref(new Set<number>())
const deliveryLog = ref('[ready] 等待创建交付任务...')
const mountPathsLoading = ref(false)
let mountPathSuggestSeq = 0
const lastDeliveryStatus = ref('')
const deliveryRolledBack = computed(() => lastDeliveryStatus.value === 'rolled_back')
const deliveryAcknowledged = computed(() => lastDeliveryStatus.value === 'rollback_acknowledged')
@@ -1536,6 +1561,48 @@ function mysqlVersionValue(version: string) {
return matched.split('.').slice(0, 2).join('.')
}
function mountPathMeta(item: DeliveryMountPath) {
const parts = []
if (item.available_gi > 0) parts.push(`可用 ${item.available_gi}GiB`)
if (item.fstype) parts.push(item.fstype)
return parts.join(' · ')
}
function queryMountPathSuggestions(query: string, callback: (items: Array<DeliveryMountPath & { value: string }>) => void) {
const prefix = query.trim()
if (!prefix) {
callback(defaultMountPathOptions().map((item) => ({ ...item, value: item.path })))
return
}
const host = deliveryForm.targetHost || targetHosts.value[0]?.name || ''
if (!selectedTargetId.value || !host || !prefix.startsWith('/')) {
callback([])
return
}
const seq = ++mountPathSuggestSeq
mountPathsLoading.value = true
deliveryApi
.listTargetMountPaths(selectedTargetId.value, host, prefix)
.then((items) => {
if (seq !== mountPathSuggestSeq) return
callback(items.map((item) => ({ ...item, value: item.path })))
})
.catch(() => {
if (seq !== mountPathSuggestSeq) return
callback([])
})
.finally(() => {
if (seq === mountPathSuggestSeq) {
mountPathsLoading.value = false
}
})
}
function defaultMountPathOptions(): DeliveryMountPath[] {
return ['/data', '/disk1', '/mnt', '/opt/mysql-delivery'].map((path) => ({ path, available_gi: 0 }))
}
function normalizeDNSLabel(value: string) {
const normalized = value
.toLowerCase()
@@ -1552,7 +1619,6 @@ function generateRootPassword() {
'ABCDEFGHJKLMNPQRSTUVWXYZ',
'abcdefghijkmnopqrstuvwxyz',
'23456789',
'!@#$%^&*_-+=',
]
const alphabet = groups.join('')
const pick = (characters: string) => {
@@ -1576,8 +1642,9 @@ function generateRootPassword() {
function rootPasswordValidationError(value: string) {
if (!value) return '请输入 root 密码'
if (value.length < 16 || value.length > 64) return '密码长度必须为 16–64 位'
if (!/[A-Z]/.test(value) || !/[a-z]/.test(value) || !/[0-9]/.test(value) || !/[^A-Za-z0-9]/.test(value)) {
return '密码需同时包含大小写字母、数字和特殊字符'
if (!/^[A-Za-z0-9]+$/.test(value)) return '密码只能包含大小写字母和数字'
if (!/[A-Z]/.test(value) || !/[a-z]/.test(value) || !/[0-9]/.test(value)) {
return '密码需同时包含大小写字母和数字'
}
return ''
}
@@ -1606,7 +1673,15 @@ async function revealCredential() {
credentialRevealing.value = true
try {
if (deploymentId.value === 'PREVIEW-CREDENTIAL') {
revealedCredentials.value = [{ username: 'root', host: 'localhost', password: deliveryForm.rootPassword }]
revealedCredentials.value = [{
service: 'mysql',
instance_name: deliveryForm.instanceName,
host: deliveredHost.value || '10.24.18.21',
port: deliveredPort.value || 13306,
username: 'root',
account_host: 'localhost',
password: deliveryForm.rootPassword,
}]
} else {
revealedCredentials.value = await deliveryApi.revealCredentials(deploymentId.value)
}
@@ -1628,24 +1703,55 @@ function dismissCredential() {
revealedCredentials.value = []
}
function formatCredentialUsername(credential: DeploymentCredential) {
return credential.account_host ? `${credential.username}@${credential.account_host}` : credential.username
}
function escapeExcelCell(value: unknown) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function downloadCredentialFile() {
const password = revealedRootCredential.value?.password
if (!credentialRevealed.value || !password) return
const content = [
'# XInfra MySQL delivery credential',
`instance=${deliveryForm.instanceName}`,
`host=${deliveredHost.value}`,
`port=${deliveredPort.value || ''}`,
'username=root',
`password=${password}`,
].join('\n') + '\n'
const url = URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' }))
if (!credentialRevealed.value || !revealedCredentials.value.length) return
const primary = revealedRootCredential.value || revealedCredentials.value[0]
const headers = ['服务', '实例名称', '主机', '端口', '用户名', '账号 Host', '密码']
const rows = revealedCredentials.value.map((credential) => [
credential.service || activeService.value?.key || 'mysql',
credential.instance_name || deliveryForm.instanceName,
credential.host || deliveredHost.value,
credential.port || deliveredPort.value || '',
credential.username,
credential.account_host || '',
credential.password,
])
const tableRows = [headers, ...rows]
.map((row) => `<tr>${row.map((cell) => `<td>${escapeExcelCell(cell)}</td>`).join('')}</tr>`)
.join('')
const content = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
table { border-collapse: collapse; }
td { border: 1px solid #d9e2ef; padding: 8px; mso-number-format: "\\@"; }
</style>
</head>
<body>
<table>${tableRows}</table>
</body>
</html>`
const url = URL.createObjectURL(new Blob([content], { type: 'application/vnd.ms-excel;charset=utf-8' }))
const link = document.createElement('a')
link.href = url
link.download = `${deliveryForm.instanceName}-root-credential.txt`
link.download = `${primary.instance_name || deliveryForm.instanceName}-credential.xls`
link.click()
URL.revokeObjectURL(url)
ElMessage.success('凭证文件已下载,请妥善保管')
ElMessage.success('Excel 凭证已下载,请妥善保管')
}
function loadDeliveryHistory(): DeliveryHistoryItem[] {
@@ -2795,7 +2901,25 @@ h4 {
gap: 10px;
}
.credential-secret > code {
.credential-account {
display: grid;
gap: 8px;
}
.credential-field {
display: grid;
grid-template-columns: 64px minmax(0, 1fr) 32px;
align-items: center;
gap: 8px;
}
.credential-field > span {
color: var(--text-dim);
font-size: 11.5px;
font-weight: 700;
}
.credential-field > code {
overflow-wrap: anywhere;
padding: 9px 10px;
border: 1px solid var(--line-soft);
@@ -2805,6 +2929,11 @@ h4 {
font-size: 12px;
}
.credential-field .el-button {
width: 32px;
min-width: 32px;
}
.credential-actions {
display: flex;
flex-wrap: wrap;
+16 -9
View File
@@ -223,7 +223,7 @@
<p>凭证仅可领取一次,领取后服务端立即销毁明文。</p>
<el-button type="warning" :loading="credentialRevealing" @click="revealDeliveryCredential(selectedDeliveryTask)">领取一次性凭证</el-button>
</template>
<p v-else-if="consumedCredentialTasks.has(selectedDeliveryTask.id)">该任务的一次性凭证已领取或不可用,平台不再提供明文密码。</p>
<p v-else-if="['finished', 'register_failed'].includes(selectedDeliveryTask.status)">该任务的一次性凭证已领取或不可用,平台不再提供明文密码。</p>
<p v-else>仅交付成功的任务提供一次性凭证。</p>
</div>
</template>
@@ -266,7 +266,6 @@ const deliveryDetailVisible = ref(false)
const selectedDeliveryTask = ref<DeliveryTask>()
const revealedCredentials = ref<DeploymentCredential[]>([])
const credentialRevealing = ref(false)
const consumedCredentialTasks = ref<Set<string>>(new Set())
const containerServices = ref<ContainerWorkload[]>([])
const containerSummary = ref<ContainerServiceSummary>({ ...emptyContainerServiceSummary })
@@ -354,32 +353,40 @@ function deliveryTasksForService(serviceName: string) {
}
function credentialEligible(task: DeliveryTask) {
return ['finished', 'register_failed'].includes(task.status) && !consumedCredentialTasks.value.has(task.id)
return ['finished', 'register_failed'].includes(task.status) && Boolean(task.credential_available)
}
function credentialStatusText(task: DeliveryTask) {
if (consumedCredentialTasks.value.has(task.id)) return '已领取'
return credentialEligible(task) ? '可领取' : '不提供'
if (credentialEligible(task)) return '可领取'
if (['finished', 'register_failed'].includes(task.status)) return '已领取'
return '不提供'
}
function credentialStatusClass(task: DeliveryTask) {
if (consumedCredentialTasks.value.has(task.id)) return 'tag-green'
return credentialEligible(task) ? 'tag-amber' : ''
}
function markCredentialUnavailable(taskID: string) {
const task = deliveryRecords.value.find((item) => item.id === taskID)
if (task) task.credential_available = false
if (selectedDeliveryTask.value?.id === taskID) {
selectedDeliveryTask.value.credential_available = false
}
}
async function revealDeliveryCredential(task: DeliveryTask) {
credentialRevealing.value = true
try {
const items = await deliveryApi.revealCredentials(task.id)
if (items.length === 0) {
consumedCredentialTasks.value.add(task.id)
markCredentialUnavailable(task.id)
ElMessage.warning('该任务没有可领取的凭证')
return
}
revealedCredentials.value = items
consumedCredentialTasks.value.add(task.id)
markCredentialUnavailable(task.id)
} catch (error) {
consumedCredentialTasks.value.add(task.id)
markCredentialUnavailable(task.id)
ElMessage.error(error instanceof Error ? error.message : '凭证领取失败')
} finally {
credentialRevealing.value = false
+8 -199
View File
@@ -42,11 +42,7 @@
暂无任务记录
</div>
<div class="pagination">
<span>共 {{ totalTasks }} 条 · 第 {{ currentPage }}/{{ totalPages }} 页 · 当前业务线:{{ currentName }}</span>
<div class="pagination-actions">
<el-button size="small" :disabled="currentPage <= 1" @click="prevPage">上一页</el-button>
<el-button size="small" :disabled="currentPage >= totalPages" @click="nextPage">下一页</el-button>
</div>
<span>共 {{ tasks.length }} 条 · 当前业务线:{{ currentName }}</span>
</div>
</div>
</div>
@@ -56,29 +52,11 @@
<h3>
<span>任务日志</span>
<span class="selected-name">{{ selectedTaskName }}</span>
<span v-if="isStreaming" class="streaming-indicator">● 实时更新中</span>
<span v-if="streamRetryCount > 0" class="retry-indicator">重试中 ({{ streamRetryCount }}/3)</span>
</h3>
<div class="panel-actions">
<el-switch
v-model="autoScroll"
active-text="自动滚动"
class="auto-scroll-switch"
/>
<span class="meta">{{ selectedTaskMeta }}</span>
</div>
<span class="meta">{{ selectedTaskMeta }}</span>
</div>
<div
ref="logContainerRef"
class="panel-body log-stream"
@scroll="handleScroll"
>
<!-- 简单滚动容器 -->
<div
v-for="(log, index) in logs"
:key="index"
:class="['task-log-line', log.class]"
>
<div class="panel-body log-stream">
<div v-for="(log, index) in logs" :key="index" :class="['task-log-line', log.class]">
<span class="t">{{ log.time }}</span>{{ log.message }}
</div>
<div v-if="loadingLogs" class="task-log-line">
@@ -94,8 +72,8 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { taskLogApi, createTaskLogStream, type TaskLogLine, type TaskLogSummary } from '@/api/taskLog'
import { computed, onMounted, ref, watch } from 'vue'
import { taskLogApi, type TaskLogLine, type TaskLogSummary } from '@/api/taskLog'
import { useBusinessLineStore } from '@/stores/businessLine'
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
@@ -109,21 +87,6 @@ const logs = ref<TaskLogLine[]>([])
const loadingTasks = ref(false)
const loadingLogs = ref(false)
const lastLoadedAt = ref<Date | null>(null)
const isStreaming = ref(false)
let eventSource: EventSource | null = null
const autoScroll = ref(true)
const logContainerRef = ref<HTMLDivElement | null>(null)
// 分页相关
const currentPage = ref(1)
const pageSize = ref(20)
const totalTasks = ref(0)
const totalPages = computed(() => Math.ceil(totalTasks.value / pageSize.value))
// SSE 重试相关
const streamRetryCount = ref(0)
const maxRetryCount = 3
let retryTimeout: ReturnType<typeof setTimeout> | null = null
const selectedTask = computed(() => tasks.value.find((task) => task.id === selectedTaskId.value))
const selectedTaskName = computed(() => selectedTask.value?.name || '未选择')
@@ -133,14 +96,11 @@ const lastLoadedText = computed(() => lastLoadedAt.value ? `更新于 ${formatTi
async function loadTasks() {
loadingTasks.value = true
try {
const result = await taskLogApi.list({
const data = await taskLogApi.list({
source: sourceFilter.value,
businessLineId: businessLineStore.current?.id,
page: currentPage.value,
pageSize: pageSize.value,
})
tasks.value = result.items
totalTasks.value = result.total
tasks.value = data.items
lastLoadedAt.value = new Date()
if (!tasks.value.some((task) => task.id === selectedTaskId.value)) {
selectedTaskId.value = tasks.value[0]?.id || ''
@@ -163,9 +123,6 @@ async function loadLogs(taskId: string) {
const data = await taskLogApi.get(taskId)
logs.value = data.lines
lastLoadedAt.value = new Date()
scrollToBottom()
// 启动 SSE 流式更新
startStream(taskId)
} catch (error) {
logs.value = [{ time: formatTime(new Date()), message: error instanceof Error ? error.message : '任务日志加载失败', class: 'err' }]
} finally {
@@ -173,93 +130,10 @@ async function loadLogs(taskId: string) {
}
}
function startStream(taskId: string) {
stopStream() // 关闭之前的连接
streamRetryCount.value = 0
eventSource = createTaskLogStream(taskId, {
onInit: (lines) => {
logs.value = lines
lastLoadedAt.value = new Date()
scrollToBottom()
},
onUpdate: (newLines) => {
logs.value = newLines
lastLoadedAt.value = new Date()
scrollToBottom()
},
onFinished: () => {
isStreaming.value = false
streamRetryCount.value = 0
},
onError: (error) => {
console.error('SSE error:', error)
isStreaming.value = false
// 尝试重连
retryStream(taskId)
},
})
isStreaming.value = true
}
function retryStream(taskId: string) {
if (streamRetryCount.value >= maxRetryCount) {
console.error('Max retry count reached')
return
}
streamRetryCount.value++
// 指数退避:1s, 2s, 4s
const delay = Math.pow(2, streamRetryCount.value - 1) * 1000
retryTimeout = setTimeout(() => {
startStream(taskId)
}, delay)
}
function stopStream() {
if (retryTimeout) {
clearTimeout(retryTimeout)
retryTimeout = null
}
if (eventSource) {
eventSource.close()
eventSource = null
isStreaming.value = false
}
}
function scrollToBottom() {
if (!autoScroll.value) return
nextTick(() => {
if (logContainerRef.value) {
logContainerRef.value.scrollTop = logContainerRef.value.scrollHeight
}
})
}
function handleScroll() {
if (!logContainerRef.value) return
const { scrollTop, scrollHeight, clientHeight } = logContainerRef.value
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
// 距离底部小于 50px 时认为在底部,恢复自动滚动
if (distanceFromBottom < 50) {
autoScroll.value = true
} else if (distanceFromBottom > 100) {
// 用户向上滚动超过 100px 时暂停自动滚动
autoScroll.value = false
}
}
function selectTask(taskId: string) {
if (selectedTaskId.value === taskId) {
return
}
stopStream() // 停止之前的流
selectedTaskId.value = taskId
void loadLogs(taskId)
}
@@ -268,20 +142,6 @@ function refreshCurrent() {
void loadTasks()
}
function prevPage() {
if (currentPage.value > 1) {
currentPage.value--
void loadTasks()
}
}
function nextPage() {
if (currentPage.value < totalPages.value) {
currentPage.value++
void loadTasks()
}
}
function formatTime(date: Date) {
return date.toLocaleTimeString('zh-CN', { hour12: false })
}
@@ -289,7 +149,6 @@ function formatTime(date: Date) {
watch(
() => businessLineStore.current?.id,
() => {
currentPage.value = 1
void loadTasks()
},
)
@@ -297,10 +156,6 @@ watch(
onMounted(() => {
void loadTasks()
})
onUnmounted(() => {
stopStream()
})
</script>
<style scoped>
@@ -394,7 +249,6 @@ onUnmounted(() => {
.task-log-panel .panel-head {
gap: 14px;
flex-wrap: wrap;
}
.task-log-panel .panel-head h3 {
@@ -404,36 +258,11 @@ onUnmounted(() => {
gap: 10px;
}
.task-log-panel .panel-head .panel-actions {
margin-left: auto;
}
.selected-name {
color: var(--text-dim);
font-weight: 500;
}
.streaming-indicator {
font-size: 11px;
color: var(--accent);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.panel-actions {
display: flex;
align-items: center;
gap: 12px;
}
.auto-scroll-switch {
--el-switch-on-color: var(--accent);
}
.log-stream {
max-height: calc(100vh - 210px);
min-height: 520px;
@@ -458,26 +287,6 @@ onUnmounted(() => {
font-size: 13px;
}
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
font-size: 12px;
color: var(--text-dim);
}
.pagination-actions {
display: flex;
gap: 8px;
}
.retry-indicator {
font-size: 11px;
color: var(--warn);
animation: pulse 1s ease-in-out infinite;
}
@media (max-width: 1180px) {
.task-layout {
grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
+1
View File
@@ -18,6 +18,7 @@ func AutoMigrate(db *gorm.DB) error {
&model.BusinessLine{},
&model.BusinessLineUser{},
&model.BusinessLineWayneNamespace{},
&model.BusinessLineSinaOrganization{},
&model.AccessToken{},
&model.WayneToken{},
&model.AuditLog{},
+273 -4
View File
@@ -1,12 +1,19 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/1024XEngineer/xinfra/server/internal/config"
"github.com/1024XEngineer/xinfra/server/internal/model"
"github.com/1024XEngineer/xinfra/server/internal/service"
@@ -15,8 +22,10 @@ import (
)
type BusinessLineHandler struct {
db *gorm.DB
wayne *service.WayneRoleBindingService
cfg config.Config
db *gorm.DB
wayne *service.WayneRoleBindingService
httpClient *http.Client
}
type BusinessLineWithPermission struct {
@@ -47,8 +56,24 @@ type WayneNamespaceBindingItem struct {
KubeNamespace string `json:"kubeNamespace"`
}
func NewBusinessLineHandler(db *gorm.DB, wayne *service.WayneRoleBindingService) *BusinessLineHandler {
return &BusinessLineHandler{db: db, wayne: wayne}
type SinaOrganizationBindingPayload struct {
Organizations []SinaOrganizationBindingItem `json:"organizations"`
}
type SinaOrganizationBindingItem struct {
ID string `json:"id" binding:"required"`
Name string `json:"name"`
}
func NewBusinessLineHandler(cfg config.Config, db *gorm.DB, wayne *service.WayneRoleBindingService) *BusinessLineHandler {
return &BusinessLineHandler{
cfg: cfg,
db: db,
wayne: wayne,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (h *BusinessLineHandler) ListCurrentUserBusinessLines(c *gin.Context) {
@@ -405,6 +430,250 @@ func (h *BusinessLineHandler) ReplaceWayneNamespaces(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *BusinessLineHandler) ListSinaOrganizations(c *gin.Context) {
businessLineID, ok := parseBusinessLineID(c)
if !ok {
return
}
if !h.canManageBusinessLine(c, businessLineID) {
return
}
token, err := h.loginSina(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
items, err := h.fetchSinaOrganizations(c.Request.Context(), token, c.Query("keyword"))
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *BusinessLineHandler) ListSinaOrganizationMappings(c *gin.Context) {
businessLineID, ok := parseBusinessLineID(c)
if !ok {
return
}
if !h.canManageBusinessLine(c, businessLineID) {
return
}
var rows []model.BusinessLineSinaOrganization
if err := h.db.Where("business_line_id = ?", businessLineID).Order("sina_organization_name ASC").Find(&rows).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
items = append(items, gin.H{
"id": row.SinaOrganizationID,
"name": row.SinaOrganizationName,
})
}
c.JSON(http.StatusOK, gin.H{"items": items})
}
func (h *BusinessLineHandler) ReplaceSinaOrganizationMappings(c *gin.Context) {
businessLineID, ok := parseBusinessLineID(c)
if !ok {
return
}
if !h.canManageBusinessLine(c, businessLineID) {
return
}
var req SinaOrganizationBindingPayload
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("business_line_id = ?", businessLineID).Delete(&model.BusinessLineSinaOrganization{}).Error; err != nil {
return err
}
seen := map[string]struct{}{}
for _, item := range req.Organizations {
id := strings.TrimSpace(item.ID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
row := model.BusinessLineSinaOrganization{
BusinessLineID: businessLineID,
SinaOrganizationID: id,
SinaOrganizationName: strings.TrimSpace(item.Name),
}
if err := tx.Create(&row).Error; err != nil {
return err
}
}
return nil
}); err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
type sinaBusinessLoginResp struct {
Success bool `json:"success"`
Result map[string]interface{} `json:"result"`
Data map[string]interface{} `json:"data"`
Token string `json:"token"`
Message string `json:"message"`
}
type sinaOrganizationListResp struct {
Success bool `json:"success"`
Result struct {
Items []map[string]interface{} `json:"items"`
Count int64 `json:"count"`
} `json:"result"`
Message string `json:"message"`
}
func (h *BusinessLineHandler) loginSina(ctx context.Context) (string, error) {
username := strings.TrimSpace(h.cfg.SINAUsername)
password := strings.TrimSpace(h.cfg.SINAPassword)
if username == "" || password == "" {
return "", errors.New("SINA_USERNAME or SINA_PASSWORD is not configured")
}
payload, err := json.Marshal(map[string]string{
"username": username,
"password": password,
})
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.cfg.SINABaseURL+"/sinai/v1/login", bytes.NewReader(payload))
if err != nil {
return "", err
}
req.Header.Set("content-type", "application/json")
resp, err := h.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("sina login failed: status=%d body=%s", resp.StatusCode, string(body))
}
var parsed sinaBusinessLoginResp
if err := json.Unmarshal(body, &parsed); err != nil {
return "", err
}
if !parsed.Success {
return "", fmt.Errorf("sina login failed: %s", parsed.Message)
}
token := firstNonEmptyString(
parsed.Token,
sinaStringValue(parsed.Result["token"]),
sinaStringValue(parsed.Result["access_token"]),
sinaStringValue(parsed.Data["token"]),
sinaStringValue(parsed.Data["access_token"]),
)
if token == "" {
return "", errors.New("sina login response missing token")
}
return token, nil
}
func (h *BusinessLineHandler) fetchSinaOrganizations(ctx context.Context, token string, keyword string) ([]SinaOrganizationBindingItem, error) {
const size = 100
page := 1
items := make([]SinaOrganizationBindingItem, 0)
for {
values := url.Values{}
values.Set("ciClsName", "zion_organization")
values.Set("keyword", keyword)
values.Set("page", strconv.Itoa(page))
values.Set("size", strconv.Itoa(size))
values.Set("isAccurate", "false")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.cfg.SINABaseURL+"/sinai/v1/ci?"+values.Encode(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", token)
req.Header.Set("Accept", "application/json")
resp, err := h.httpClient.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("sina organization list failed: status=%d body=%s", resp.StatusCode, string(body))
}
var parsed sinaOrganizationListResp
if err := json.Unmarshal(body, &parsed); err != nil {
return nil, err
}
if !parsed.Success {
return nil, fmt.Errorf("sina organization list failed: %s", parsed.Message)
}
for _, row := range parsed.Result.Items {
id := firstNonEmptyString(sinaStringValue(row["id"]), sinaStringValue(row["ciId"]))
name := firstNonEmptyString(
sinaStringValue(row["name"]),
sinaStringValue(row["org_name"]),
sinaStringValue(row["title"]),
id,
)
if id == "" {
continue
}
items = append(items, SinaOrganizationBindingItem{ID: id, Name: name})
}
if len(parsed.Result.Items) < size || int64(len(items)) >= parsed.Result.Count {
break
}
page++
}
return items, nil
}
func sinaStringValue(value interface{}) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
case fmt.Stringer:
return strings.TrimSpace(v.String())
case nil:
return ""
default:
return strings.TrimSpace(fmt.Sprint(v))
}
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func (h *BusinessLineHandler) canManageBusinessLine(c *gin.Context, businessLineID uint64) bool {
claims, ok := CurrentClaims(c)
if !ok {
+2 -2
View File
@@ -171,7 +171,7 @@ func (h *DeliveryHandler) RevealCredentials(c *gin.Context) {
}
items, err := h.service.RevealDeploymentCredentials(c.Request.Context(), c.Param("id"), claims.UserID, claims.IsAdmin)
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "credentials not found or already viewed"})
c.JSON(http.StatusNotFound, gin.H{"error": "credentials not found or already claimed"})
return
}
if err != nil {
@@ -357,7 +357,7 @@ func (h *DeliveryHandler) TargetHostMountPaths(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target_id"})
return
}
items, err := h.service.ListHostMountPaths(c.Request.Context(), targetID, c.Param("host"))
items, err := h.service.ListHostMountPaths(c.Request.Context(), targetID, c.Param("host"), c.Query("prefix"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+45 -13
View File
@@ -18,7 +18,17 @@ func NewMachineHandler(machines *service.MachineService) *MachineHandler {
}
func (h *MachineHandler) Overview(c *gin.Context) {
overview, err := h.machines.Overview(c.Request.Context())
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
businessLineID, ok := queryUint(c, "business_line_id")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "business_line_id is required"})
return
}
overview, err := h.machines.Overview(c.Request.Context(), claims.UserID, claims.IsAdmin, businessLineID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -27,18 +37,28 @@ func (h *MachineHandler) Overview(c *gin.Context) {
}
func (h *MachineHandler) List(c *gin.Context) {
resources, err := h.machines.List(c.Request.Context(), service.MachineListQuery{
Page: queryInt(c, "page", 1),
Size: queryInt(c, "size", 20),
Hostname: c.Query("hostname"),
AssetNumber: c.Query("assetNumber"),
Type: c.Query("type"),
Location: c.Query("location"),
IP: c.Query("ip"),
Spec: c.Query("spec"),
BusinessLine: c.Query("businessLine"),
Source: c.Query("source"),
Status: c.Query("status"),
claims, ok := CurrentClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
return
}
businessLineID, ok := queryUint(c, "business_line_id")
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": "business_line_id is required"})
return
}
resources, err := h.machines.List(c.Request.Context(), claims.UserID, claims.IsAdmin, service.MachineListQuery{
BusinessLineID: businessLineID,
Page: queryInt(c, "page", 1),
Size: queryInt(c, "size", 20),
Hostname: c.Query("hostname"),
AssetNumber: c.Query("assetNumber"),
Type: c.Query("type"),
Location: c.Query("location"),
IP: c.Query("ip"),
Spec: c.Query("spec"),
Source: c.Query("source"),
Status: c.Query("status"),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -67,3 +87,15 @@ func queryInt(c *gin.Context, key string, fallback int) int {
}
return n
}
func queryUint(c *gin.Context, key string) (uint64, bool) {
value := c.Query(key)
if value == "" {
return 0, false
}
n, err := strconv.ParseUint(value, 10, 64)
if err != nil || n == 0 {
return 0, false
}
return n, true
}
+21 -20
View File
@@ -34,26 +34,27 @@ type ResourceQuota struct {
}
type DeliveryTask struct {
ID string `gorm:"size:36;primaryKey" json:"id"`
BusinessLineID uint64 `gorm:"not null;index" json:"business_line_id"`
RequestedBy uint64 `gorm:"not null;index" json:"requested_by"`
Component string `gorm:"size:32;not null;default:mysql;index" json:"component"`
TargetType string `gorm:"size:32;not null" json:"target_type"`
TargetID uint64 `gorm:"not null;index" json:"target_id"`
Namespace string `gorm:"size:63;not null;index" json:"namespace"`
InstanceName string `gorm:"size:63;not null" json:"instance_name"`
TargetHost string `gorm:"size:128" json:"target_host,omitempty"`
TargetHostIP string `gorm:"size:64" json:"target_host_ip,omitempty"`
MySQLPort int `gorm:"column:mysql_port;not null;default:3307" json:"mysql_port"`
Status string `gorm:"size:32;not null;index" json:"status"`
ImmutablePayload string `gorm:"type:json;not null" json:"immutable_payload"`
PayloadHash string `gorm:"size:64;not null" json:"payload_hash"`
IdempotencyKey string `gorm:"size:128;not null;uniqueIndex" json:"idempotency_key"`
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
ID string `gorm:"size:36;primaryKey" json:"id"`
BusinessLineID uint64 `gorm:"not null;index" json:"business_line_id"`
RequestedBy uint64 `gorm:"not null;index" json:"requested_by"`
Component string `gorm:"size:32;not null;default:mysql;index" json:"component"`
TargetType string `gorm:"size:32;not null" json:"target_type"`
TargetID uint64 `gorm:"not null;index" json:"target_id"`
Namespace string `gorm:"size:63;not null;index" json:"namespace"`
InstanceName string `gorm:"size:63;not null" json:"instance_name"`
TargetHost string `gorm:"size:128" json:"target_host,omitempty"`
TargetHostIP string `gorm:"size:64" json:"target_host_ip,omitempty"`
MySQLPort int `gorm:"column:mysql_port;not null;default:3307" json:"mysql_port"`
Status string `gorm:"size:32;not null;index" json:"status"`
ImmutablePayload string `gorm:"type:json;not null" json:"immutable_payload"`
PayloadHash string `gorm:"size:64;not null" json:"payload_hash"`
IdempotencyKey string `gorm:"size:128;not null;uniqueIndex" json:"idempotency_key"`
ErrorMessage string `gorm:"type:text" json:"error_message,omitempty"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
CredentialAvailable bool `gorm:"-" json:"credential_available"`
}
type ResourceReservation struct {
+9
View File
@@ -58,6 +58,15 @@ type BusinessLineWayneNamespace struct {
UpdatedAt time.Time `json:"updated_at"`
}
type BusinessLineSinaOrganization struct {
ID uint64 `gorm:"primaryKey" json:"id"`
BusinessLineID uint64 `gorm:"not null;uniqueIndex:idx_business_line_sina_orgs_unique,priority:1;index" json:"business_line_id"`
SinaOrganizationID string `gorm:"size:128;not null;uniqueIndex:idx_business_line_sina_orgs_unique,priority:2" json:"sina_organization_id"`
SinaOrganizationName string `gorm:"size:255;not null;default:''" json:"sina_organization_name"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type AccessToken struct {
ID uint64 `gorm:"primaryKey" json:"id"`
UserID uint64 `gorm:"not null;index" json:"user_id"`
+4 -1
View File
@@ -81,7 +81,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
healthHandler := handler.NewHealthHandler(deps.DB)
authHandler := handler.NewAuthHandler(deps.Config, authService)
userHandler := handler.NewUserHandler(deps.DB)
businessLineHandler := handler.NewBusinessLineHandler(deps.DB, wayneRoleBindingService)
businessLineHandler := handler.NewBusinessLineHandler(deps.Config, deps.DB, wayneRoleBindingService)
wayenHandler := handler.NewWayenHandler(deps.DB, wayenService, auditService)
wayneRoleBindingHandler := handler.NewWayneRoleBindingHandler(wayneRoleBindingService, auditService)
subsystemAuthHandler := handler.NewSubsystemAuthHandler(deps.DB, wayneRoleBindingService, auditService)
@@ -125,6 +125,9 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
protected.POST("/business-lines/authorizations", businessLineHandler.GrantPermission)
protected.GET("/business-lines/:id/wayne-namespaces", businessLineHandler.ListWayneNamespaces)
protected.PUT("/business-lines/:id/wayne-namespaces", businessLineHandler.ReplaceWayneNamespaces)
protected.GET("/business-lines/:id/sina-organizations", businessLineHandler.ListSinaOrganizations)
protected.GET("/business-lines/:id/sina-organization-mappings", businessLineHandler.ListSinaOrganizationMappings)
protected.PUT("/business-lines/:id/sina-organization-mappings", businessLineHandler.ReplaceSinaOrganizationMappings)
protected.GET("/wayen/login", wayenHandler.Login)
protected.GET("/wayen/credential", wayenHandler.GetCredential)
protected.PUT("/wayen/credential", wayenHandler.SaveCredential)
+188 -23
View File
@@ -129,9 +129,13 @@ type MySQLServiceLedgerItem struct {
}
type DeploymentCredentialView struct {
Username string `json:"username"`
Host string `json:"host"`
Password string `json:"password"`
Service string `json:"service"`
InstanceName string `json:"instance_name"`
Host string `json:"host"`
Port int `json:"port"`
Username string `json:"username"`
AccountHost string `json:"account_host"`
Password string `json:"password"`
}
// targetMetadata describes the native VM候选节点池以及部署形态,由 AWX inventory hosts 动态组装。
@@ -397,13 +401,19 @@ func (s *DeliveryService) getTarget(ctx context.Context, templateID uint64) (Del
return s.awxDeliveryTarget(ctx, *template)
}
func (s *DeliveryService) ListHostMountPaths(ctx context.Context, targetID uint64, hostName string) ([]DeliveryMountPath, error) {
func (s *DeliveryService) ListHostMountPaths(ctx context.Context, targetID uint64, hostName string, prefix string) ([]DeliveryMountPath, error) {
if targetID == 0 {
return nil, fmt.Errorf("target_id is required")
}
if hostName == "" || len(hostName) > 253 || !hostNamePattern.MatchString(hostName) {
return nil, fmt.Errorf("host must be a valid inventory host name")
}
prefix = strings.TrimSpace(prefix)
if prefix != "" {
if err := validateDirectoryLookupPrefix(prefix); err != nil {
return nil, err
}
}
template, err := s.awx.GetJobTemplate(ctx, targetID)
if err != nil {
return nil, fmt.Errorf("deployment target is unavailable: %w", err)
@@ -423,9 +433,16 @@ func (s *DeliveryService) ListHostMountPaths(ctx context.Context, targetID uint6
return nil, fmt.Errorf("host %q is not in the deployment target inventory", hostName)
}
if s.cfg.AWXFactsTemplateID != 0 {
if err := s.refreshHostFacts(ctx, hostName); err != nil {
stdout, err := s.refreshHostFacts(ctx, hostName, prefix)
if err != nil {
return nil, err
}
if prefix != "" {
return directoryPathsFromAWXStdout(stdout), nil
}
}
if prefix != "" {
return []DeliveryMountPath{}, nil
}
facts, err := s.awx.GetHostFacts(ctx, matched.ID)
if err != nil {
@@ -438,21 +455,86 @@ func (s *DeliveryService) ListHostMountPaths(ctx context.Context, targetID uint6
return items, nil
}
func (s *DeliveryService) refreshHostFacts(ctx context.Context, hostName string) error {
func validateDirectoryLookupPrefix(prefix string) error {
if prefix == "" {
return nil
}
if len(prefix) > 512 {
return fmt.Errorf("path prefix is too long")
}
if !strings.HasPrefix(prefix, "/") {
return fmt.Errorf("path prefix must be an absolute path")
}
if strings.ContainsRune(prefix, 0) {
return fmt.Errorf("path prefix contains invalid characters")
}
return nil
}
func directoryPathsFromAWXStdout(stdout string) []DeliveryMountPath {
const marker = "XINFRA_PATH_COMPLETIONS_JSON="
for _, line := range strings.Split(stdout, "\n") {
line = strings.TrimSpace(line)
idx := strings.Index(line, marker)
if idx < 0 {
continue
}
if items, ok := parseDirectoryCompletionJSON(line[idx+len(marker):]); ok {
sort.Slice(items, func(i, j int) bool {
return items[i].Path < items[j].Path
})
return items
}
}
return []DeliveryMountPath{}
}
func parseDirectoryCompletionJSON(raw string) ([]DeliveryMountPath, bool) {
raw = strings.TrimSpace(raw)
candidates := []string{raw}
if strings.Contains(raw, `\"`) {
candidates = append(candidates, strings.ReplaceAll(raw, `\"`, `"`))
}
for _, candidate := range candidates {
start := strings.Index(candidate, "[")
end := strings.LastIndex(candidate, "]")
if start < 0 || end < start {
continue
}
var items []DeliveryMountPath
if err := json.Unmarshal([]byte(candidate[start:end+1]), &items); err == nil {
return items, true
}
}
return nil, false
}
func (s *DeliveryService) refreshHostFacts(ctx context.Context, hostName string, lookupPath string) (string, error) {
extraVars := map[string]any{
"target_hosts": hostName,
}
if lookupPath != "" {
extraVars["lookup_path"] = lookupPath
}
job, err := s.awx.Launch(ctx, s.cfg.AWXFactsTemplateID, AWXLaunchRequest{
Limit: hostName,
Limit: hostName,
ExtraVars: extraVars,
})
if err != nil {
return fmt.Errorf("launch AWX facts job: %w", err)
return "", fmt.Errorf("launch AWX facts job: %w", err)
}
done, err := s.awx.WaitJob(ctx, strconv.FormatUint(job.ID, 10), time.Duration(s.cfg.AWXFactsTimeoutSeconds)*time.Second)
if err != nil {
return err
return "", err
}
if done.Status != "successful" || done.Failed {
return fmt.Errorf("AWX facts job %d finished with status %s", done.ID, done.Status)
return "", fmt.Errorf("AWX facts job %d finished with status %s", done.ID, done.Status)
}
return nil
stdout, err := s.awx.JobStdout(ctx, strconv.FormatUint(job.ID, 10))
if err != nil {
return "", err
}
return stdout, nil
}
func (s *DeliveryService) awxDeliveryTarget(ctx context.Context, template AWXJobTemplate) (DeliveryTarget, error) {
@@ -503,6 +585,9 @@ func (s *DeliveryService) CreateTask(ctx context.Context, userID uint64, isAdmin
if len(credentialInput["root@localhost"]) < 16 || len(credentialInput["xinfra_admin@%"]) < 16 {
return nil, false, fmt.Errorf("mysql passwords must be at least 16 characters")
}
if !mysqlPasswordPattern.MatchString(credentialInput["root@localhost"]) || !mysqlPasswordPattern.MatchString(credentialInput["xinfra_admin@%"]) {
return nil, false, fmt.Errorf("mysql passwords may only contain letters and digits")
}
}
input.MySQLRootPassword = ""
input.MySQLAdminPassword = ""
@@ -657,6 +742,8 @@ var (
// timezone 仅接受偏移量(±HH:MM)、SYSTEM 或命名时区(如 Asia/Shanghai)。
var timezonePattern = regexp.MustCompile(`^([+-](0\d|1[0-4]):[0-5]\d|SYSTEM|[A-Za-z]+(?:/[A-Za-z0-9_+-]+)+)$`)
var mysqlPasswordPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`)
func validateDeliveryInput(input MySQLDeliveryInput, _ []string) error {
if len(input.Namespace) > 63 || !dnsLabelPattern.MatchString(input.Namespace) {
return fmt.Errorf("namespace must be a valid Kubernetes DNS label")
@@ -773,7 +860,13 @@ func (s *DeliveryService) ListTasks(ctx context.Context, userID uint64, isAdmin
query = query.Where("business_line_id IN (?)", s.db.Model(&model.BusinessLineUser{}).Select("business_line_id").Where("user_id = ?", userID))
}
var tasks []model.DeliveryTask
return tasks, query.Find(&tasks).Error
if err := query.Find(&tasks).Error; err != nil {
return nil, err
}
if err := s.fillCredentialAvailability(ctx, tasks); err != nil {
return nil, err
}
return tasks, nil
}
func (s *DeliveryService) ListMySQLServiceLedger(ctx context.Context, userID uint64, isAdmin bool, businessLineID uint64) ([]MySQLServiceLedgerItem, error) {
@@ -916,6 +1009,11 @@ func (s *DeliveryService) GetTask(ctx context.Context, taskID string, userID uin
if err := query.First(&task).Error; err != nil {
return nil, nil, err
}
taskItems := []model.DeliveryTask{task}
if err := s.fillCredentialAvailability(ctx, taskItems); err != nil {
return nil, nil, err
}
task = taskItems[0]
var events []model.TaskEvent
if err := s.db.WithContext(ctx).Where("task_id = ?", taskID).Order("id ASC").Find(&events).Error; err != nil {
return nil, nil, err
@@ -923,6 +1021,41 @@ func (s *DeliveryService) GetTask(ctx context.Context, taskID string, userID uin
return &task, events, nil
}
func (s *DeliveryService) fillCredentialAvailability(ctx context.Context, tasks []model.DeliveryTask) error {
if len(tasks) == 0 {
return nil
}
taskIDs := make([]string, 0, len(tasks))
for i := range tasks {
if tasks[i].Status == model.TaskFinished || tasks[i].Status == model.TaskRegisterFailed {
taskIDs = append(taskIDs, tasks[i].ID)
}
}
if len(taskIDs) == 0 {
return nil
}
var rows []struct {
TaskID string
Count int64
}
if err := s.db.WithContext(ctx).
Model(&model.DeploymentCredential{}).
Select("task_id, count(*) as count").
Where("task_id IN ? AND status = ?", taskIDs, "available").
Group("task_id").
Scan(&rows).Error; err != nil {
return err
}
available := make(map[string]bool, len(rows))
for _, row := range rows {
available[row.TaskID] = row.Count > 0
}
for i := range tasks {
tasks[i].CredentialAvailable = available[tasks[i].ID]
}
return nil
}
func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskID string, userID uint64, isAdmin bool) ([]DeploymentCredentialView, error) {
task, _, err := s.GetTask(ctx, taskID, userID, isAdmin)
if err != nil {
@@ -933,6 +1066,10 @@ func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskI
}
var out []DeploymentCredentialView
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var result model.DeploymentResult
if err := tx.Where("task_id = ?", taskID).First(&result).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var credentials []model.DeploymentCredential
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("task_id = ? AND status = ?", taskID, "available").
@@ -949,22 +1086,20 @@ func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskI
return err
}
out = append(out, DeploymentCredentialView{
Username: credential.Username,
Host: credential.AccountHost,
Password: password,
Service: firstNonEmptyDeliveryValue(result.Component, credential.Component, task.Component, "mysql"),
InstanceName: firstNonEmptyDeliveryValue(result.InstanceName, credential.InstanceName, task.InstanceName),
Host: firstNonEmptyDeliveryValue(result.Host, task.TargetHostIP),
Port: firstNonZero(result.Port, task.MySQLPort),
Username: credential.Username,
AccountHost: credential.AccountHost,
Password: password,
})
}
ids := make([]uint64, 0, len(credentials))
for _, credential := range credentials {
ids = append(ids, credential.ID)
}
now := time.Now()
return tx.Model(&model.DeploymentCredential{}).Where("id IN ?", ids).Updates(map[string]any{
"status": "viewed",
"viewed_by": userID,
"viewed_at": now,
"updated_at": now,
}).Error
return tx.Unscoped().Where("id IN ?", ids).Delete(&model.DeploymentCredential{}).Error
})
if err != nil {
return nil, err
@@ -972,6 +1107,24 @@ func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskI
return out, nil
}
func firstNonEmptyDeliveryValue(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func firstNonZero(values ...int) int {
for _, value := range values {
if value != 0 {
return value
}
}
return 0
}
func (s *DeliveryService) SubscribeTask(taskID string) (<-chan DeliveryTaskSnapshot, func()) {
ch := make(chan DeliveryTaskSnapshot, 8)
s.streamMu.Lock()
@@ -1170,7 +1323,7 @@ func (s *DeliveryService) claimAndReserve(ctx context.Context) (*model.DeliveryT
return s.failInTransaction(tx, &task, model.TaskValidationFailed, "deployment target has no candidate hosts")
}
var occupied []string
occupiedExclude := []string{model.TaskExecutionFailed, model.TaskValidationFailed, model.TaskCanceled}
occupiedExclude := allocationReleasedTaskStatuses()
if err := tx.Model(&model.DeliveryTask{}).Where("target_id = ? AND target_host <> ? AND status NOT IN ?", task.TargetID, "", occupiedExclude).Pluck("target_host", &occupied).Error; err != nil {
return err
}
@@ -1641,6 +1794,18 @@ func terminalTaskStatuses() []string {
}
}
func allocationReleasedTaskStatuses() []string {
return []string{
model.TaskFinished,
model.TaskExecutionFailed,
model.TaskValidationFailed,
model.TaskRegisterFailed,
model.TaskCanceled,
model.TaskRolledBack,
model.TaskRollbackAck,
}
}
func awxNotificationMessage(input AWXJobNotificationInput) string {
status := strings.TrimSpace(input.Status)
name := strings.TrimSpace(input.Name)
+29
View File
@@ -143,6 +143,35 @@ func TestRollbackExtraVarsTargetsOnlyTheAllocatedInstance(t *testing.T) {
}
}
func TestDirectoryPathsFromAWXStdout(t *testing.T) {
stdout := `
TASK [Show directory completions] **********************************************
ok: [db-01] => {
"stdout": "XINFRA_PATH_COMPLETIONS_JSON=[{\"path\":\"/a/bc\",\"available_gi\":12},{\"path\":\"/a/bb\",\"available_gi\":8}]"
}
`
items := directoryPathsFromAWXStdout(stdout)
if len(items) != 2 {
t.Fatalf("expected two completion items, got %#v", items)
}
if items[0].Path != "/a/bb" || items[1].Path != "/a/bc" {
t.Fatalf("items should be parsed and sorted by path: %#v", items)
}
}
func TestValidateDirectoryLookupPrefix(t *testing.T) {
for _, path := range []string{"/", "/a", "/a/b", "/lib/data"} {
if err := validateDirectoryLookupPrefix(path); err != nil {
t.Fatalf("valid path prefix %q rejected: %v", path, err)
}
}
for _, path := range []string{"a", "relative/path"} {
if err := validateDirectoryLookupPrefix(path); err == nil {
t.Fatalf("invalid path prefix %q was accepted", path)
}
}
}
func TestRegisterFailedIsProtectedFromRollback(t *testing.T) {
if !rollbackProtectedStatus(model.TaskRegisterFailed) {
t.Fatal("register_failed must preserve the healthy instance and resource usage")
+64 -29
View File
@@ -37,17 +37,17 @@ type MachineService struct {
}
type MachineListQuery struct {
Page int
Size int
Hostname string
AssetNumber string
Type string
Location string
IP string
Spec string
BusinessLine string
Source string
Status string
BusinessLineID uint64
Page int
Size int
Hostname string
AssetNumber string
Type string
Location string
IP string
Spec string
Source string
Status string
}
type MachineResourceItem struct {
@@ -189,25 +189,29 @@ func (s *MachineService) SyncNow(ctx context.Context) (*model.MachineSyncState,
return s.syncState(ctx)
}
func (s *MachineService) Overview(ctx context.Context) (*MachineOverview, error) {
var total, physical int64
if err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Count(&total).Error; err != nil {
func (s *MachineService) Overview(ctx context.Context, userID uint64, isAdmin bool, businessLineID uint64) (*MachineOverview, error) {
baseDB, err := s.scopedMachineDB(ctx, userID, isAdmin, businessLineID)
if err != nil {
return nil, err
}
if err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Where("resource_type = ?", "physical").Count(&physical).Error; err != nil {
var total, physical int64
if err := baseDB.Count(&total).Error; err != nil {
return nil, err
}
if err := baseDB.Session(&gorm.Session{}).Where("resource_type = ?", "physical").Count(&physical).Error; err != nil {
return nil, err
}
sourceCounts, err := s.sourceCounts(ctx)
sourceCounts, err := s.sourceCounts(baseDB.Session(&gorm.Session{}))
if err != nil {
return nil, err
}
cloudSources := []string{"aliyun", "ali", "alicloud", "aws", "qiniu"}
cmdbTotal, err := s.countExcludingSources(ctx, cloudSources)
cmdbTotal, err := s.countExcludingSources(baseDB.Session(&gorm.Session{}), cloudSources)
if err != nil {
return nil, err
}
cmdbPhysical, err := s.countExcludingSourcesAndType(ctx, cloudSources, "physical")
cmdbPhysical, err := s.countExcludingSourcesAndType(baseDB.Session(&gorm.Session{}), cloudSources, "physical")
if err != nil {
return nil, err
}
@@ -239,7 +243,7 @@ func (s *MachineService) Overview(ctx context.Context) (*MachineOverview, error)
}, nil
}
func (s *MachineService) List(ctx context.Context, query MachineListQuery) (*MachineResourceList, error) {
func (s *MachineService) List(ctx context.Context, userID uint64, isAdmin bool, query MachineListQuery) (*MachineResourceList, error) {
if query.Page <= 0 {
query.Page = 1
}
@@ -250,7 +254,10 @@ func (s *MachineService) List(ctx context.Context, query MachineListQuery) (*Mac
query.Size = 200
}
db := s.db.WithContext(ctx).Model(&model.MachineResource{})
db, err := s.scopedMachineDB(ctx, userID, isAdmin, query.BusinessLineID)
if err != nil {
return nil, err
}
db = applyMachineFilters(db, query)
var total int64
@@ -270,6 +277,37 @@ func (s *MachineService) List(ctx context.Context, query MachineListQuery) (*Mac
return &MachineResourceList{Total: total, Items: items}, nil
}
func (s *MachineService) scopedMachineDB(ctx context.Context, userID uint64, isAdmin bool, businessLineID uint64) (*gorm.DB, error) {
if businessLineID == 0 {
return nil, errors.New("business_line_id is required")
}
if !isAdmin {
var binding model.BusinessLineUser
err := s.db.WithContext(ctx).Where("business_line_id = ? AND user_id = ?", businessLineID, userID).First(&binding).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("current user does not have business line permission")
}
if err != nil {
return nil, err
}
}
var mappings []model.BusinessLineSinaOrganization
if err := s.db.WithContext(ctx).Where("business_line_id = ?", businessLineID).Order("sina_organization_name ASC").Find(&mappings).Error; err != nil {
return nil, err
}
names := make([]string, 0, len(mappings))
for _, mapping := range mappings {
if name := strings.TrimSpace(mapping.SinaOrganizationName); name != "" {
names = append(names, name)
}
}
db := s.db.WithContext(ctx).Model(&model.MachineResource{})
if len(names) == 0 {
return db.Where("1 = 0"), nil
}
return db.Where("business_line IN ?", names), nil
}
func (s *MachineService) acquireSync() error {
s.mu.Lock()
defer s.mu.Unlock()
@@ -527,18 +565,15 @@ func applyMachineFilters(db *gorm.DB, query MachineListQuery) *gorm.DB {
if query.Spec != "" {
db = db.Where("spec LIKE ?", "%"+query.Spec+"%")
}
if query.BusinessLine != "" {
db = db.Where("business_line LIKE ?", "%"+query.BusinessLine+"%")
}
return db
}
func (s *MachineService) sourceCounts(ctx context.Context) (map[string]int64, error) {
func (s *MachineService) sourceCounts(db *gorm.DB) (map[string]int64, error) {
var rows []struct {
Source string
Count int64
}
if err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Select("source, count(*) as count").Group("source").Scan(&rows).Error; err != nil {
if err := db.Select("source, count(*) as count").Group("source").Scan(&rows).Error; err != nil {
return nil, err
}
out := map[string]int64{}
@@ -549,15 +584,15 @@ func (s *MachineService) sourceCounts(ctx context.Context) (map[string]int64, er
return out, nil
}
func (s *MachineService) countExcludingSources(ctx context.Context, sources []string) (int64, error) {
func (s *MachineService) countExcludingSources(db *gorm.DB, sources []string) (int64, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Where("source NOT IN ?", sources).Count(&count).Error
err := db.Where("source NOT IN ?", sources).Count(&count).Error
return count, err
}
func (s *MachineService) countExcludingSourcesAndType(ctx context.Context, sources []string, resourceType string) (int64, error) {
func (s *MachineService) countExcludingSourcesAndType(db *gorm.DB, sources []string, resourceType string) (int64, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.MachineResource{}).Where("source NOT IN ? AND resource_type = ?", sources, resourceType).Count(&count).Error
err := db.Where("source NOT IN ? AND resource_type = ?", sources, resourceType).Count(&count).Error
return count, err
}