feat(delivery): refine base service integrations
This commit is contained in:
@@ -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
|
||||
|
||||
Vendored
+1
-1
@@ -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']
|
||||
|
||||
Generated
-27
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = {}) {
|
||||
|
||||
@@ -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 : []
|
||||
},
|
||||
|
||||
|
||||
@@ -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()}`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,7 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&model.BusinessLine{},
|
||||
&model.BusinessLineUser{},
|
||||
&model.BusinessLineWayneNamespace{},
|
||||
&model.BusinessLineSinaOrganization{},
|
||||
&model.AccessToken{},
|
||||
&model.WayneToken{},
|
||||
&model.AuditLog{},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user