feat(delivery): refine base service integrations

This commit is contained in:
mac
2026-07-29 16:47:41 +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
+7 -198
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>
</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{},
+271 -2
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 {
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
+35 -3
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,7 +37,18 @@ func (h *MachineHandler) Overview(c *gin.Context) {
}
func (h *MachineHandler) List(c *gin.Context) {
resources, err := h.machines.List(c.Request.Context(), service.MachineListQuery{
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"),
@@ -36,7 +57,6 @@ func (h *MachineHandler) List(c *gin.Context) {
Location: c.Query("location"),
IP: c.Query("ip"),
Spec: c.Query("spec"),
BusinessLine: c.Query("businessLine"),
Source: c.Query("source"),
Status: c.Query("status"),
})
@@ -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
}
+1
View File
@@ -54,6 +54,7 @@ type DeliveryTask struct {
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)
+183 -18
View File
@@ -129,8 +129,12 @@ type MySQLServiceLedgerItem struct {
}
type DeploymentCredentialView struct {
Username string `json:"username"`
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"`
}
@@ -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,
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,8 +1086,12 @@ func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskI
return err
}
out = append(out, DeploymentCredentialView{
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,
Host: credential.AccountHost,
AccountHost: credential.AccountHost,
Password: password,
})
}
@@ -958,13 +1099,7 @@ func (s *DeliveryService) RevealDeploymentCredentials(ctx context.Context, taskI
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")
+54 -19
View File
@@ -37,6 +37,7 @@ type MachineService struct {
}
type MachineListQuery struct {
BusinessLineID uint64
Page int
Size int
Hostname string
@@ -45,7 +46,6 @@ type MachineListQuery struct {
Location string
IP string
Spec string
BusinessLine string
Source string
Status string
}
@@ -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
}