Vendored
-2
@@ -22,8 +22,6 @@ declare module 'vue' {
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SubsystemCard: typeof import('./src/components/SubsystemCard.vue')['default']
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { getToken } from '@/utils/auth'
|
||||
|
||||
export interface SubsystemAuthSystem {
|
||||
key: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface WayneRole {
|
||||
id: number
|
||||
name: string
|
||||
comment?: string
|
||||
type: number
|
||||
}
|
||||
|
||||
export interface WaynePermission {
|
||||
create: boolean
|
||||
update: boolean
|
||||
delete: boolean
|
||||
}
|
||||
|
||||
export interface WayneBusinessLineNamespace {
|
||||
id: number
|
||||
name: string
|
||||
kubeNamespace: string
|
||||
permissions?: WaynePermission
|
||||
can_bind?: boolean
|
||||
can_unbind?: boolean
|
||||
permission_error?: string
|
||||
}
|
||||
|
||||
export interface WayneRoleBindingPayload {
|
||||
groupIds?: number[]
|
||||
replace?: boolean
|
||||
requestId?: string
|
||||
reason?: string
|
||||
dryRun?: boolean
|
||||
}
|
||||
|
||||
export interface WayneUserRoles {
|
||||
userId?: number
|
||||
userName?: string
|
||||
namespaces?: Array<{
|
||||
namespace?: {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
groups?: Array<{
|
||||
id: number
|
||||
name: string
|
||||
}>
|
||||
}>
|
||||
apps?: unknown[]
|
||||
}
|
||||
|
||||
export const subsystemAuthApi = {
|
||||
async listSystems(): Promise<SubsystemAuthSystem[]> {
|
||||
const data = await authRequest('/auth/api/v1/subsystem-auth/systems')
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
async listWayneRoles(): Promise<WayneRole[]> {
|
||||
const data = await authRequest('/auth/api/v1/subsystem-auth/wayne/roles')
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
async listWayneNamespaces(businessLineId: number): Promise<WayneBusinessLineNamespace[]> {
|
||||
const data = await authRequest(`/auth/api/v1/subsystem-auth/wayne/business-lines/${businessLineId}/namespaces`)
|
||||
return Array.isArray(data.items) ? data.items : []
|
||||
},
|
||||
|
||||
async getWayneUserRoles(username: string): Promise<WayneUserRoles> {
|
||||
const data = await authRequest(`/auth/api/v1/subsystem-auth/wayne/users/${encodeURIComponent(username)}/roles`)
|
||||
return data.data || data
|
||||
},
|
||||
|
||||
async bindWayneNamespaceRoles(
|
||||
businessLineId: number,
|
||||
namespaceId: number,
|
||||
username: string,
|
||||
payload: WayneRoleBindingPayload,
|
||||
): Promise<unknown> {
|
||||
return authRequest(
|
||||
`/auth/api/v1/subsystem-auth/wayne/business-lines/${businessLineId}/namespaces/${namespaceId}/users/${encodeURIComponent(username)}/roles`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
async unbindWayneNamespaceRoles(
|
||||
businessLineId: number,
|
||||
namespaceId: number,
|
||||
username: string,
|
||||
payload: WayneRoleBindingPayload = {},
|
||||
): Promise<unknown> {
|
||||
return authRequest(
|
||||
`/auth/api/v1/subsystem-auth/wayne/business-lines/${businessLineId}/namespaces/${namespaceId}/users/${encodeURIComponent(username)}/roles`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
async initWayneBusinessLineUser(businessLineId: number, userId: number): Promise<unknown> {
|
||||
return authRequest(`/auth/api/v1/subsystem-auth/wayne/business-lines/${businessLineId}/users/${userId}/init`, {
|
||||
method: 'POST',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
async function authRequest(path: string, init: RequestInit = {}) {
|
||||
const token = getToken()
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
})
|
||||
const text = await response.text()
|
||||
const data = parseResponseBody(text)
|
||||
if (!response.ok) {
|
||||
const message = data?.error || data?.message || text || `HTTP ${response.status}`
|
||||
throw new Error(message)
|
||||
}
|
||||
return data || {}
|
||||
}
|
||||
|
||||
function parseResponseBody(text: string) {
|
||||
if (!text.trim()) {
|
||||
return {}
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch {
|
||||
return { error: text }
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const businessLineStore = useBusinessLineStore()
|
||||
const portalExpanded = ref(true)
|
||||
const isPlatformAdmin = computed(() => authStore.user?.is_admin === true)
|
||||
const isPlatformAdmin = computed(() => authStore.isAdmin)
|
||||
const isBusinessLineAdmin = computed(() => businessLineStore.isCurrentAdmin)
|
||||
|
||||
const subsystems = [
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { getToken, setToken, removeToken, getUser, setUser, removeUser } from '@/utils/auth'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(getToken())
|
||||
const user = ref<any>(getUser())
|
||||
const isAdmin = computed(() => user.value?.is_admin === true || decodeAdminClaim(token.value))
|
||||
|
||||
function setAuth(newToken: string, newUser: any) {
|
||||
token.value = newToken
|
||||
@@ -45,6 +46,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAdmin,
|
||||
setAuth,
|
||||
setSessionToken,
|
||||
refreshUser,
|
||||
@@ -52,3 +54,19 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isLoggedIn,
|
||||
}
|
||||
})
|
||||
|
||||
function decodeAdminClaim(token: string | null): boolean {
|
||||
if (!token) return false
|
||||
try {
|
||||
const payload = JSON.parse(decodeBase64Url(token.split('.')[1] || ''))
|
||||
return payload.admin === true || payload.is_admin === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): string {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
|
||||
return atob(padded)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { computed } from 'vue'
|
||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||
|
||||
export const BUSINESS_LINE_NAMES = ['kodo', 'linxi', 'xinfra', 'las'] as const
|
||||
export type BusinessLineName = (typeof BUSINESS_LINE_NAMES)[number]
|
||||
|
||||
interface BusinessLineMockProfile {
|
||||
name: BusinessLineName
|
||||
clusters: number
|
||||
nodes: number
|
||||
physicalMachines: number
|
||||
virtualMachines: number
|
||||
cpuAllocated: number
|
||||
components: number
|
||||
mysql: number
|
||||
redis: number
|
||||
alertsP0: number
|
||||
alertsP1: number
|
||||
tasksRunning: number
|
||||
primaryZone: string
|
||||
secondaryZone: string
|
||||
servicePrefix: string
|
||||
}
|
||||
|
||||
const profiles: Record<BusinessLineName, BusinessLineMockProfile> = {
|
||||
kodo: {
|
||||
name: 'kodo',
|
||||
clusters: 3,
|
||||
nodes: 128,
|
||||
physicalMachines: 186,
|
||||
virtualMachines: 512,
|
||||
cpuAllocated: 61,
|
||||
components: 214,
|
||||
mysql: 38,
|
||||
redis: 92,
|
||||
alertsP0: 1,
|
||||
alertsP1: 5,
|
||||
tasksRunning: 2,
|
||||
primaryZone: 'IDC-华北机房',
|
||||
secondaryZone: 'IDC-华东机房',
|
||||
servicePrefix: 'kodo',
|
||||
},
|
||||
linxi: {
|
||||
name: 'linxi',
|
||||
clusters: 2,
|
||||
nodes: 74,
|
||||
physicalMachines: 96,
|
||||
virtualMachines: 238,
|
||||
cpuAllocated: 47,
|
||||
components: 128,
|
||||
mysql: 21,
|
||||
redis: 46,
|
||||
alertsP0: 0,
|
||||
alertsP1: 2,
|
||||
tasksRunning: 1,
|
||||
primaryZone: 'IDC-华东机房',
|
||||
secondaryZone: '阿里云-华南',
|
||||
servicePrefix: 'linxi',
|
||||
},
|
||||
xinfra: {
|
||||
name: 'xinfra',
|
||||
clusters: 2,
|
||||
nodes: 52,
|
||||
physicalMachines: 68,
|
||||
virtualMachines: 156,
|
||||
cpuAllocated: 39,
|
||||
components: 84,
|
||||
mysql: 12,
|
||||
redis: 31,
|
||||
alertsP0: 0,
|
||||
alertsP1: 1,
|
||||
tasksRunning: 1,
|
||||
primaryZone: 'IDC-华北机房',
|
||||
secondaryZone: '香港 IDC',
|
||||
servicePrefix: 'xinfra',
|
||||
},
|
||||
las: {
|
||||
name: 'las',
|
||||
clusters: 1,
|
||||
nodes: 34,
|
||||
physicalMachines: 42,
|
||||
virtualMachines: 118,
|
||||
cpuAllocated: 31,
|
||||
components: 66,
|
||||
mysql: 8,
|
||||
redis: 18,
|
||||
alertsP0: 0,
|
||||
alertsP1: 3,
|
||||
tasksRunning: 0,
|
||||
primaryZone: '七牛-新加坡',
|
||||
secondaryZone: 'AWS-美国',
|
||||
servicePrefix: 'las',
|
||||
},
|
||||
}
|
||||
|
||||
export function normalizeBusinessLineName(name?: string | null): BusinessLineName {
|
||||
const normalized = String(name || '').trim().toLowerCase()
|
||||
if (BUSINESS_LINE_NAMES.includes(normalized as BusinessLineName)) {
|
||||
return normalized as BusinessLineName
|
||||
}
|
||||
return 'kodo'
|
||||
}
|
||||
|
||||
export function useBusinessLineMockProfile() {
|
||||
const businessLineStore = useBusinessLineStore()
|
||||
const currentName = computed(() => normalizeBusinessLineName(businessLineStore.current?.name))
|
||||
const profile = computed(() => profiles[currentName.value])
|
||||
|
||||
return {
|
||||
currentName,
|
||||
profile,
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="cluster in clusters" :key="cluster.name" class="tr-hover">
|
||||
<tr v-for="cluster in businessLineClusters" :key="cluster.name" class="tr-hover">
|
||||
<td class="strong">{{ cluster.name }}</td>
|
||||
<td><span class="tag" :class="cluster.zoneClass">{{ cluster.zone }}</span></td>
|
||||
<td :class="['status-text', cluster.statusClass]">● {{ cluster.status }}</td>
|
||||
@@ -43,7 +43,7 @@
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>rke2-bj-prod-01 · 节点列表(节选)</h3>
|
||||
<h3>{{ businessLineClusters[0]?.name }} · 节点列表(节选)</h3>
|
||||
<span class="meta">node-label 多租户隔离</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
@@ -60,7 +60,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="node in nodes" :key="node.name" class="tr-hover">
|
||||
<tr v-for="node in businessLineNodes" :key="node.name" class="tr-hover">
|
||||
<td class="strong mono">{{ node.name }}</td>
|
||||
<td class="mono">{{ node.ip }}</td>
|
||||
<td><span class="tag biz" :style="{ color: node.labelColor, borderColor: node.labelBorder }">{{ node.label }}</span></td>
|
||||
@@ -77,7 +77,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const { currentName, profile } = useBusinessLineMockProfile()
|
||||
|
||||
const clusters = ref([
|
||||
{ name: 'rke2-bj-prod-01', zone: '华北 · IDC', zoneClass: 'zone-a', status: '健康', statusClass: 'ok', nodes: 46, cpu: 64, cpuClass: '', version: 'v1.28.9+rke2r1', calico: 'BGP AS64512' },
|
||||
@@ -87,10 +90,43 @@ const clusters = ref([
|
||||
|
||||
const nodes = ref([
|
||||
{ name: 'bj-node-014', ip: '10.21.4.14', label: 'business-line=kodo', labelColor: 'var(--tag-blue-text)', labelBorder: 'var(--tag-blue-border)', taint: 'kodo:NoSchedule', spec: '64C/256G', cpu: 58, cpuClass: '', status: 'Ready', statusClass: 'ok' },
|
||||
{ name: 'bj-node-015', ip: '10.21.4.15', label: 'business-line=kodo', labelColor: 'var(--tag-blue-text)', labelBorder: 'var(--tag-blue-border)', taint: 'kodo:NoSchedule', spec: '64C/256G', cpu: 62, cpuClass: '', status: 'Ready', statusClass: 'ok' },
|
||||
{ name: 'bj-node-031', ip: '10.21.4.31', label: 'business-line=las', labelColor: 'var(--tag-purple-text)', labelBorder: 'var(--tag-purple-border)', taint: 'las:NoSchedule', spec: '32C/128G', cpu: 88, cpuClass: 'warn', status: '资源告警', statusClass: 'warn' },
|
||||
{ name: 'bj-node-048', ip: '10.21.4.48', label: '未分配', labelColor: 'var(--text-dim)', labelBorder: 'var(--line)', taint: '—', spec: '32C/128G', cpu: 4, cpuClass: '', status: 'Ready · 空闲', statusClass: 'ok' },
|
||||
{ name: 'bj-node-015', ip: '10.21.4.15', label: 'business-line=linxi', labelColor: 'var(--tag-blue-text)', labelBorder: 'var(--tag-blue-border)', taint: 'linxi:NoSchedule', spec: '64C/256G', cpu: 62, cpuClass: '', status: 'Ready', statusClass: 'ok' },
|
||||
{ name: 'bj-node-031', ip: '10.21.4.31', label: 'business-line=xinfra', labelColor: 'var(--tag-purple-text)', labelBorder: 'var(--tag-purple-border)', taint: 'xinfra:NoSchedule', spec: '32C/128G', cpu: 88, cpuClass: 'warn', status: '资源告警', statusClass: 'warn' },
|
||||
{ name: 'bj-node-048', ip: '10.21.4.48', label: 'business-line=las', labelColor: 'var(--text-dim)', labelBorder: 'var(--line)', taint: 'las:NoSchedule', spec: '32C/128G', cpu: 4, cpuClass: '', status: 'Ready · 空闲', statusClass: 'ok' },
|
||||
])
|
||||
|
||||
const businessLineClusters = computed(() => {
|
||||
const cpu = profile.value.cpuAllocated
|
||||
return clusters.value.map((cluster, index) => ({
|
||||
...cluster,
|
||||
name: `${cluster.name}-${currentName.value}`,
|
||||
nodes: Math.max(1, Math.round(profile.value.nodes * ([0.42, 0.36, 0.22][index] || 0.2))),
|
||||
cpu: Math.min(96, Math.max(8, cpu + (index - 1) * 7)),
|
||||
cpuClass: cpu > 75 ? 'warn' : '',
|
||||
status: profile.value.alertsP1 && index === 2 ? `${profile.value.alertsP1} 节点告警` : '健康',
|
||||
statusClass: profile.value.alertsP1 && index === 2 ? 'warn' : 'ok',
|
||||
}))
|
||||
})
|
||||
|
||||
const businessLineNodes = computed(() => {
|
||||
const selected = nodes.value.filter((node) => node.label === `business-line=${currentName.value}`)
|
||||
if (selected.length) return selected
|
||||
return [
|
||||
{
|
||||
name: `${currentName.value}-node-001`,
|
||||
ip: '10.21.4.14',
|
||||
label: `business-line=${currentName.value}`,
|
||||
labelColor: 'var(--tag-blue-text)',
|
||||
labelBorder: 'var(--tag-blue-border)',
|
||||
taint: `${currentName.value}:NoSchedule`,
|
||||
spec: '64C/256G',
|
||||
cpu: profile.value.cpuAllocated,
|
||||
cpuClass: profile.value.cpuAllocated > 75 ? 'warn' : '',
|
||||
status: profile.value.alertsP1 ? '资源告警' : 'Ready',
|
||||
statusClass: profile.value.alertsP1 ? 'warn' : 'ok',
|
||||
},
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">配置项总数</div>
|
||||
<div class="value">1,260</div>
|
||||
<div class="delta">Namespace 38 个</div>
|
||||
<div class="value">{{ configItemCount }}</div>
|
||||
<div class="delta">Namespace {{ Math.max(1, Math.round(configItemCount / 32)) }} 个</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">接入业务线</div>
|
||||
<div class="value">2 / 5</div>
|
||||
<div class="delta">kodo · las 已接入</div>
|
||||
<div class="value">1 / 1</div>
|
||||
<div class="delta">{{ currentName }} 已接入</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">同步状态</div>
|
||||
@@ -78,7 +78,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="cluster in clusters" :key="cluster.name" class="tr-hover">
|
||||
<tr v-for="cluster in currentClusters" :key="cluster.name" class="tr-hover">
|
||||
<td><span class="tag" :class="cluster.zoneClass">{{ cluster.name }}</span></td>
|
||||
<td class="mono">cluster={{ cluster.cluster }}</td>
|
||||
<td class="mono text-xs">{{ cluster.address }}</td>
|
||||
@@ -95,17 +95,23 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const { currentName } = useBusinessLineMockProfile()
|
||||
|
||||
const clusters = ref([
|
||||
{ name: 'YZH 机房', zoneClass: 'zone-a', cluster: 'yzh', address: 'config-yzh.xinfra.internal:8080', type: '容器化 · K8s Service', biz: 'kodo', items: 486, status: '运行中', statusClass: 'ok' },
|
||||
{ name: 'XS 机房', zoneClass: 'zone-b', cluster: 'xs', address: 'config-xs.xinfra.internal:8080', type: '容器化 · K8s Service', biz: 'las', items: 398, status: '运行中', statusClass: 'ok' },
|
||||
{ name: 'JF 机房', zoneClass: 'zone-c', cluster: 'jf', address: 'config-jf.xinfra.internal:8080', type: '容器化 · K8s Service', biz: '灵矽', items: 376, status: '灰度接入中', statusClass: 'warn' },
|
||||
{ name: '达拉斯 IDC', zoneClass: '', cluster: 'dallas', address: '—', type: '容器化 · K8s Service', biz: '—', items: 0, status: '建设中', statusClass: 'idle' },
|
||||
{ name: 'XS 机房', zoneClass: 'zone-b', cluster: 'xs', address: 'config-xs.xinfra.internal:8080', type: '容器化 · K8s Service', biz: 'linxi', items: 398, status: '运行中', statusClass: 'ok' },
|
||||
{ name: 'JF 机房', zoneClass: 'zone-c', cluster: 'jf', address: 'config-jf.xinfra.internal:8080', type: '容器化 · K8s Service', biz: 'xinfra', items: 376, status: '灰度接入中', statusClass: 'warn' },
|
||||
{ name: '达拉斯 IDC', zoneClass: '', cluster: 'dallas', address: 'config-dallas.xinfra.internal:8080', type: '容器化 · K8s Service', biz: 'las', items: 128, status: '建设中', statusClass: 'idle' },
|
||||
{ name: '新加坡 IDC', zoneClass: '', cluster: 'singapore', address: '—', type: '容器化 · K8s Service', biz: '—', items: 0, status: '建设中', statusClass: 'idle' },
|
||||
{ name: '香港 IDC', zoneClass: '', cluster: 'hk', address: '—', type: '容器化 · K8s Service', biz: '—', items: 0, status: '待启动', statusClass: 'idle' },
|
||||
{ name: '东南亚 IDC', zoneClass: '', cluster: 'sea', address: '—', type: '容器化 · K8s Service', biz: '—', items: 0, status: '待启动', statusClass: 'idle' },
|
||||
])
|
||||
|
||||
const currentClusters = computed(() => clusters.value.filter((cluster) => cluster.biz === currentName.value))
|
||||
const configItemCount = computed(() => currentClusters.value.reduce((sum, cluster) => sum + cluster.items, 0))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,28 +11,28 @@
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">RKE2 集群</div>
|
||||
<div class="value">3</div>
|
||||
<div class="delta">3 机房在线</div>
|
||||
<div class="value">{{ profile.clusters }}</div>
|
||||
<div class="delta">{{ profile.clusters }} 机房在线</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">节点总数</div>
|
||||
<div class="value">128</div>
|
||||
<div class="delta up">↑ 6 本周新增</div>
|
||||
<div class="value">{{ profile.nodes }}</div>
|
||||
<div class="delta up">↑ {{ Math.max(1, Math.round(profile.nodes / 24)) }} 本周新增</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">CPU 已分配</div>
|
||||
<div class="value">61%</div>
|
||||
<div class="delta">2,048 / 3,360 核</div>
|
||||
<div class="value">{{ profile.cpuAllocated }}%</div>
|
||||
<div class="delta">{{ Math.round(profile.nodes * 16 * profile.cpuAllocated / 100) }} / {{ profile.nodes * 16 }} 核</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">组件实例</div>
|
||||
<div class="value">214</div>
|
||||
<div class="delta">MySQL 38 · Redis 92 · 其他 84</div>
|
||||
<div class="value">{{ profile.components }}</div>
|
||||
<div class="delta">MySQL {{ profile.mysql }} · Redis {{ profile.redis }} · 其他 {{ profile.components - profile.mysql - profile.redis }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">进行中任务</div>
|
||||
<div class="value warn">2</div>
|
||||
<div class="delta warn">1 个待关注</div>
|
||||
<div class="value warn">{{ profile.tasksRunning }}</div>
|
||||
<div class="delta warn">{{ Math.min(profile.tasksRunning, 1) }} 个待关注</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
<div class="topo">
|
||||
<div class="topo-cluster">
|
||||
<div class="tname">
|
||||
<span class="tag zone-a">IDC-华北机房</span>
|
||||
<span class="mono text-dim">46节点</span>
|
||||
<span class="tag zone-a">{{ profile.primaryZone }}</span>
|
||||
<span class="mono text-dim">{{ Math.round(profile.nodes * 0.42) }}节点</span>
|
||||
</div>
|
||||
<div class="node-grid">
|
||||
<div v-for="i in 24" :key="i" :class="['node-cell', i <= 18 ? 'a' : '']"></div>
|
||||
@@ -54,8 +54,8 @@
|
||||
</div>
|
||||
<div class="topo-cluster">
|
||||
<div class="tname">
|
||||
<span class="tag zone-b">IDC-华东机房</span>
|
||||
<span class="mono text-dim">52节点</span>
|
||||
<span class="tag zone-b">{{ profile.secondaryZone }}</span>
|
||||
<span class="mono text-dim">{{ Math.round(profile.nodes * 0.36) }}节点</span>
|
||||
</div>
|
||||
<div class="node-grid">
|
||||
<div v-for="i in 24" :key="i" :class="['node-cell', i <= 20 ? 'b' : '']"></div>
|
||||
@@ -63,8 +63,8 @@
|
||||
</div>
|
||||
<div class="topo-cluster">
|
||||
<div class="tname">
|
||||
<span class="tag zone-c">阿里云-华南</span>
|
||||
<span class="mono text-dim">30节点</span>
|
||||
<span class="tag zone-c">弹性云资源池</span>
|
||||
<span class="mono text-dim">{{ Math.max(1, profile.nodes - Math.round(profile.nodes * 0.42) - Math.round(profile.nodes * 0.36)) }}节点</span>
|
||||
</div>
|
||||
<div class="node-grid">
|
||||
<div v-for="i in 18" :key="i" :class="['node-cell', i <= 12 ? 'c' : '']"></div>
|
||||
@@ -72,9 +72,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span><span class="node-cell a inline"></span>Kodo业务线</span>
|
||||
<span><span class="node-cell b inline"></span>LAS业务线</span>
|
||||
<span><span class="node-cell c inline"></span>灵矽业务线</span>
|
||||
<span><span class="node-cell a inline"></span>{{ currentName }}主资源池</span>
|
||||
<span><span class="node-cell b inline"></span>{{ currentName }}扩展池</span>
|
||||
<span><span class="node-cell c inline"></span>{{ currentName }}弹性池</span>
|
||||
<span><span class="node-cell empty inline"></span>空闲</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,22 +89,22 @@
|
||||
<tbody>
|
||||
<tr class="tr-hover">
|
||||
<td class="status-text ok">● 成功</td>
|
||||
<td class="strong">MySQL 主从部署</td>
|
||||
<td class="strong">{{ profile.servicePrefix }} MySQL 主从部署</td>
|
||||
<td class="mono">2m14s</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="status-text warn">● 执行中</td>
|
||||
<td class="strong">RKE2 节点加入</td>
|
||||
<td class="strong">{{ profile.servicePrefix }} RKE2 节点加入</td>
|
||||
<td class="mono">38s</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="status-text ok">● 成功</td>
|
||||
<td class="strong">Redis Cluster 部署</td>
|
||||
<td class="strong">{{ profile.servicePrefix }} Redis Cluster 部署</td>
|
||||
<td class="mono">3m02s</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="status-text err">● 失败</td>
|
||||
<td class="strong">openresty 网关部署</td>
|
||||
<td class="strong">{{ profile.servicePrefix }} openresty 网关部署</td>
|
||||
<td class="mono">41s</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
@@ -121,6 +121,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const { currentName, profile } = useBusinessLineMockProfile()
|
||||
|
||||
const refresh = () => {
|
||||
// 刷新数据
|
||||
}
|
||||
|
||||
@@ -10,17 +10,17 @@
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">P0 / Disaster 告警</div>
|
||||
<div class="value" style="color: var(--err)">1</div>
|
||||
<div class="value" style="color: var(--err)">{{ profile.alertsP0 }}</div>
|
||||
<div class="delta" style="color: var(--err)">来自 VictoriaMetrics</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">P1 / High 告警</div>
|
||||
<div class="value" style="color: var(--warn)">5</div>
|
||||
<div class="delta warn">来自 Zabbix · 4 条 / VM · 1 条</div>
|
||||
<div class="value" style="color: var(--warn)">{{ profile.alertsP1 }}</div>
|
||||
<div class="delta warn">来自 Zabbix · {{ Math.max(0, profile.alertsP1 - 1) }} 条 / VM · {{ profile.alertsP1 ? 1 : 0 }} 条</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">今日已推送</div>
|
||||
<div class="value">5</div>
|
||||
<div class="value">{{ profile.alertsP0 + profile.alertsP1 }}</div>
|
||||
<div class="delta">qpass 统一告警通道</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -69,53 +69,13 @@
|
||||
<th>告警内容</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td><span class="tag sev-p0">P0</span></td>
|
||||
<td class="mono">VictoriaMetrics</td>
|
||||
<td class="mono">disaster</td>
|
||||
<td class="strong mono">sg-las-overseas-007</td>
|
||||
<td>磁盘只读 / IO 错误,节点不可写</td>
|
||||
<td class="mono">07:38:55</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td><span class="tag sev-p1">P1</span></td>
|
||||
<td class="mono">Zabbix</td>
|
||||
<td class="mono">high</td>
|
||||
<td class="strong mono">yzh-las-014</td>
|
||||
<td>风扇转速低于阈值(FAN_6: 1920 RPM)</td>
|
||||
<td class="mono">07:35:12</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td><span class="tag sev-p1">P1</span></td>
|
||||
<td class="mono">Zabbix</td>
|
||||
<td class="mono">high</td>
|
||||
<td class="strong mono">xs-bm-291</td>
|
||||
<td>电源冗余丢失(PSU2 离线)</td>
|
||||
<td class="mono">07:21:40</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td><span class="tag sev-p1">P1</span></td>
|
||||
<td class="mono">VictoriaMetrics</td>
|
||||
<td class="mono">high</td>
|
||||
<td class="strong mono">redis-ads-feature-cluster-02</td>
|
||||
<td>慢查询比例超阈值(>5%,持续5分钟)</td>
|
||||
<td class="mono">07:18:03</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td><span class="tag sev-avg">average</span></td>
|
||||
<td class="mono">Zabbix</td>
|
||||
<td class="mono">average</td>
|
||||
<td class="strong mono">overseas-dallas-idc</td>
|
||||
<td>专线延迟抖动超基线(WireGuard ↔ AWS US)</td>
|
||||
<td class="mono">07:10:22</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td><span class="tag sev-avg">average</span></td>
|
||||
<td class="mono">Zabbix</td>
|
||||
<td class="mono">average</td>
|
||||
<td class="strong mono">jf-bm-118</td>
|
||||
<td>CPU 温度接近阈值(76℃ / 80℃)</td>
|
||||
<td class="mono">06:58:47</td>
|
||||
<tr v-for="alert in alertRows" :key="alert.object" class="tr-hover">
|
||||
<td><span :class="['tag', alert.sevClass]">{{ alert.level }}</span></td>
|
||||
<td class="mono">{{ alert.source }}</td>
|
||||
<td class="mono">{{ alert.rawLevel }}</td>
|
||||
<td class="strong mono">{{ alert.object }}</td>
|
||||
<td>{{ alert.message }}</td>
|
||||
<td class="mono">{{ alert.time }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -127,7 +87,7 @@
|
||||
<span class="meta">近 24h · qpass</span>
|
||||
</div>
|
||||
<div class="panel-body log-stream">
|
||||
<div v-for="(alert, index) in alerts" :key="index" :class="['task-log-line', alert.class]">
|
||||
<div v-for="(alert, index) in alerts" :key="index" :class="['task-log-line', alert.class]">
|
||||
<span class="t">{{ alert.time }}</span>{{ alert.message }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,18 +96,30 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const alerts = ref([
|
||||
{ time: '07:38:55', message: '[qpass] P0 告警推送:sg-las-overseas-007 磁盘只读 / IO 错误', class: 'err' },
|
||||
{ time: '07:35:12', message: '[qpass] P1 告警推送:yzh-las-014 风扇转速低于阈值(FAN_6: 1920 RPM)', class: '' },
|
||||
{ time: '07:21:40', message: '[qpass] P1 告警推送:xs-bm-291 电源冗余丢失(PSU2 离线)', class: '' },
|
||||
{ time: '07:18:03', message: '[qpass] P1 告警推送:redis-ads-feature-cluster-02 慢查询比例超阈值', class: '' },
|
||||
{ time: '07:10:22', message: '[qpass] average 告警推送:overseas-dallas-idc 专线延迟抖动超基线', class: '' },
|
||||
{ time: '06:58:47', message: '[qpass] average 告警推送:jf-bm-118 CPU 温度接近阈值(76℃ / 80℃)', class: '' },
|
||||
{ time: '06:15:22', message: '[Nightingale] sg-las-007 CPU 使用率恢复(当前 42%)', class: 'ok' },
|
||||
{ time: '05:48:10', message: '[Nightingale] sg-las-007 CPU 使用率超过 90% 持续 5 分钟', class: '' },
|
||||
])
|
||||
const { currentName, profile } = useBusinessLineMockProfile()
|
||||
|
||||
const alertRows = computed(() => {
|
||||
const rows = []
|
||||
if (profile.value.alertsP0) {
|
||||
rows.push({ level: 'P0', sevClass: 'sev-p0', source: 'VictoriaMetrics', rawLevel: 'disaster', object: `${currentName.value}-node-critical-007`, message: '磁盘只读 / IO 错误,节点不可写', time: '07:38:55' })
|
||||
}
|
||||
for (let i = 0; i < profile.value.alertsP1; i += 1) {
|
||||
rows.push({ level: 'P1', sevClass: 'sev-p1', source: i % 2 ? 'VictoriaMetrics' : 'Zabbix', rawLevel: 'high', object: `${currentName.value}-svc-${String(i + 1).padStart(2, '0')}`, message: i % 2 ? '慢查询比例超阈值(>5%,持续5分钟)' : '节点硬件健康指标异常', time: `07:${35 - i * 4}:12` })
|
||||
}
|
||||
if (!rows.length) {
|
||||
rows.push({ level: 'average', sevClass: 'sev-avg', source: 'Zabbix', rawLevel: 'average', object: `${currentName.value}-baseline`, message: '当前无 P0/P1,仅保留基线观察项', time: '07:10:22' })
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
const alerts = computed(() => alertRows.value.map((alert) => ({
|
||||
time: alert.time,
|
||||
message: `[qpass] ${alert.level} 告警推送:${alert.object} ${alert.message}`,
|
||||
class: alert.level === 'P0' ? 'err' : '',
|
||||
})))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,18 +11,18 @@
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">物理机总数</div>
|
||||
<div class="value">186</div>
|
||||
<div class="delta">186 / 186 监控覆盖</div>
|
||||
<div class="value">{{ profile.physicalMachines }}</div>
|
||||
<div class="delta">{{ profile.physicalMachines }} / {{ profile.physicalMachines }} 监控覆盖</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">虚机总数(LAS)</div>
|
||||
<div class="value">512</div>
|
||||
<div class="delta up">↑ 14 本周新增</div>
|
||||
<div class="label">虚机总数</div>
|
||||
<div class="value">{{ profile.virtualMachines }}</div>
|
||||
<div class="delta up">↑ {{ Math.max(1, Math.round(profile.virtualMachines / 36)) }} 本周新增</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">基础组件实例</div>
|
||||
<div class="value">214</div>
|
||||
<div class="delta">MySQL 38 · Redis 92 · 其他 84</div>
|
||||
<div class="value">{{ profile.components }}</div>
|
||||
<div class="delta">MySQL {{ profile.mysql }} · Redis {{ profile.redis }} · 其他 {{ profile.components - profile.mysql - profile.redis }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>虚机状态 · LAS 资源池</h3>
|
||||
<h3>虚机状态 · 按业务线</h3>
|
||||
<span class="meta">数据源:VictoriaMetrics</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
@@ -99,40 +99,13 @@
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong mono">Kodo</td>
|
||||
<td class="mono">218</td>
|
||||
<td class="strong mono">{{ currentName }}</td>
|
||||
<td class="mono">{{ profile.virtualMachines }}</td>
|
||||
<td>
|
||||
<span class="bar-wrap"><span class="bar-fill" style="width: 64%"></span></span>
|
||||
64%
|
||||
<span class="bar-wrap"><span class="bar-fill" :class="{ warn: profile.cpuAllocated > 75 }" :style="{ width: `${profile.cpuAllocated}%` }"></span></span>
|
||||
{{ profile.cpuAllocated }}%
|
||||
</td>
|
||||
<td class="status-text ok">● 正常</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong mono">LAS</td>
|
||||
<td class="mono">164</td>
|
||||
<td>
|
||||
<span class="bar-wrap"><span class="bar-fill warn" style="width: 82%"></span></span>
|
||||
82%
|
||||
</td>
|
||||
<td class="status-text warn">● 1 条告警</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong mono">灵矽</td>
|
||||
<td class="mono">96</td>
|
||||
<td>
|
||||
<span class="bar-wrap"><span class="bar-fill" style="width: 57%"></span></span>
|
||||
57%
|
||||
</td>
|
||||
<td class="status-text ok">● 正常</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong mono">共享池 / 未分配</td>
|
||||
<td class="mono">34</td>
|
||||
<td>
|
||||
<span class="bar-wrap"><span class="bar-fill" style="width: 31%"></span></span>
|
||||
31%
|
||||
</td>
|
||||
<td class="status-text ok">● 正常</td>
|
||||
<td :class="['status-text', profile.alertsP1 ? 'warn' : 'ok']">● {{ profile.alertsP1 ? `${profile.alertsP1} 条告警` : '正常' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
@@ -155,17 +128,17 @@
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong">MySQL</td>
|
||||
<td class="mono">38</td>
|
||||
<td class="mono">{{ profile.mysql }}</td>
|
||||
<td class="mono">0</td>
|
||||
<td class="mono">100%</td>
|
||||
<td class="status-text ok">● 正常</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong">Redis(CacheCloud)</td>
|
||||
<td class="mono">92</td>
|
||||
<td class="mono">1</td>
|
||||
<td class="mono">98.9%</td>
|
||||
<td class="status-text warn">● 1 条告警</td>
|
||||
<td class="mono">{{ profile.redis }}</td>
|
||||
<td class="mono">{{ profile.alertsP1 ? 1 : 0 }}</td>
|
||||
<td class="mono">{{ profile.alertsP1 ? '98.9%' : '100%' }}</td>
|
||||
<td :class="['status-text', profile.alertsP1 ? 'warn' : 'ok']">● {{ profile.alertsP1 ? '1 条告警' : '正常' }}</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong">PostgreSQL</td>
|
||||
@@ -183,7 +156,7 @@
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong">其他中间件</td>
|
||||
<td class="mono">46</td>
|
||||
<td class="mono">{{ profile.components - profile.mysql - profile.redis - 12 - 26 }}</td>
|
||||
<td class="mono">0</td>
|
||||
<td class="mono">100%</td>
|
||||
<td class="status-text ok">● 正常</td>
|
||||
@@ -196,6 +169,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const { currentName, profile } = useBusinessLineMockProfile()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">资源总数</div>
|
||||
<div class="value">658</div>
|
||||
<div class="delta">物理机 146 · 虚机 512</div>
|
||||
<div class="value">{{ filteredResources.length }}</div>
|
||||
<div class="delta">物理机 {{ physicalCount }} · 虚机 {{ vmCount }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">SINA CMDB</div>
|
||||
@@ -25,9 +25,9 @@
|
||||
<div class="delta">ECS · 增量同步</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">AWS / 七牛 LAS 同步</div>
|
||||
<div class="label">AWS / 七牛同步</div>
|
||||
<div class="value">136</div>
|
||||
<div class="delta">AWS 58 · 七牛 LAS 78</div>
|
||||
<div class="delta">AWS 58 · 七牛 78</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">最近一次同步</div>
|
||||
@@ -47,16 +47,14 @@
|
||||
<el-option label="SINA CMDB" value="cmdb" />
|
||||
<el-option label="阿里云同步" value="aliyun" />
|
||||
<el-option label="AWS 同步" value="aws" />
|
||||
<el-option label="七牛 LAS 同步" value="qiniu" />
|
||||
<el-option label="七牛同步" value="qiniu" />
|
||||
</el-select>
|
||||
<el-select placeholder="全部业务线">
|
||||
<el-option label="全部业务线" value="" />
|
||||
<el-option label="kodo" value="kodo" />
|
||||
<el-option label="linxi" value="linxi" />
|
||||
<el-option label="xinfra" value="xinfra" />
|
||||
<el-option label="las" value="las" />
|
||||
<el-option label="lingxi" value="lingxi" />
|
||||
<el-option label="ltoken" value="ltoken" />
|
||||
<el-option label="maas" value="maas" />
|
||||
<el-option label="未分配" value="unassigned" />
|
||||
</el-select>
|
||||
<el-input placeholder="搜索 hostname / asset_number / IP" class="search-input" />
|
||||
</div>
|
||||
@@ -82,7 +80,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in resources" :key="item.hostname" class="tr-hover">
|
||||
<tr v-for="item in filteredResources" :key="item.hostname" class="tr-hover">
|
||||
<td class="strong mono">{{ item.hostname }}</td>
|
||||
<td class="mono text-xs">{{ item.asset_number }}</td>
|
||||
<td><span class="tag" :class="item.type === '物理机' ? '' : 'vm'">{{ item.type }}</span></td>
|
||||
@@ -96,7 +94,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination">
|
||||
<span>共 658 条 · 每页 7 条</span>
|
||||
<span>共 {{ filteredResources.length }} 条 · 当前业务线:{{ currentName }}</span>
|
||||
<div class="pg-btns">
|
||||
<span class="pg-btn disabled">‹</span>
|
||||
<span class="pg-btn active">1</span>
|
||||
@@ -113,17 +111,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const { currentName } = useBusinessLineMockProfile()
|
||||
|
||||
const resources = ref([
|
||||
{ hostname: 'xs291', asset_number: 'SERV00003502', type: '物理机', location: '华东·杭州下沙', ip: '10.34.37.52', spec: '20C/192G/13.52T', business_line: 'kodo', source: 'SINA CMDB', status: 'production' },
|
||||
{ hostname: 'yzh-las-014', asset_number: 'SERV00004187', type: '虚机', location: '华北·YZH', ip: '10.21.4.214', spec: '32C/128G/4.0T', business_line: 'las', source: 'SINA CMDB', status: 'production' },
|
||||
{ hostname: 'jf-bm-118', asset_number: 'SERV00004290', type: '物理机', location: '华南·JF', ip: '10.45.2.18', spec: '16C/64G/2.0T', business_line: 'lingxi', source: 'SINA CMDB', status: 'production' },
|
||||
{ hostname: 'ali-ecs-sz-0231', asset_number: '—', type: '虚机', location: '阿里云·华南', ip: '172.18.4.31', spec: '16C/64G/500G', business_line: 'ltoken', source: '阿里云同步', status: 'production' },
|
||||
{ hostname: 'aws-us-i-0a13fe2', asset_number: '—', type: '虚机', location: 'AWS · 美国', ip: '10.66.2.12', spec: '8C/32G/200G', business_line: 'maas', source: 'AWS 同步', status: 'production' },
|
||||
{ hostname: 'sg-las-007', asset_number: 'SERV00004511', type: '虚机', location: '七牛 LAS · 新加坡', ip: '10.88.1.07', spec: '16C/64G/2.0T', business_line: 'las', source: '七牛 LAS 同步', status: 'idle' },
|
||||
{ hostname: 'hk-bm-001', asset_number: 'SERV00004602', type: '物理机', location: '香港 IDC', ip: '10.90.0.11', spec: '8C/32G/1.0T', business_line: '未分配', source: 'SINA CMDB', status: 'production' },
|
||||
{ hostname: 'jf-bm-118', asset_number: 'SERV00004290', type: '物理机', location: '华南·JF', ip: '10.45.2.18', spec: '16C/64G/2.0T', business_line: 'linxi', source: 'SINA CMDB', status: 'production' },
|
||||
{ hostname: 'ali-ecs-sz-0231', asset_number: '—', type: '虚机', location: '阿里云·华南', ip: '172.18.4.31', spec: '16C/64G/500G', business_line: 'xinfra', source: '阿里云同步', status: 'production' },
|
||||
{ hostname: 'aws-us-i-0a13fe2', asset_number: '—', type: '虚机', location: 'AWS · 美国', ip: '10.66.2.12', spec: '8C/32G/200G', business_line: 'las', source: 'AWS 同步', status: 'production' },
|
||||
{ hostname: 'sg-las-007', asset_number: 'SERV00004511', type: '虚机', location: '七牛 · 新加坡', ip: '10.88.1.07', spec: '16C/64G/2.0T', business_line: 'las', source: '七牛同步', status: 'idle' },
|
||||
{ hostname: 'hk-bm-001', asset_number: 'SERV00004602', type: '物理机', location: '香港 IDC', ip: '10.90.0.11', spec: '8C/32G/1.0T', business_line: 'xinfra', source: 'SINA CMDB', status: 'production' },
|
||||
])
|
||||
|
||||
const filteredResources = computed(() => resources.value.filter((item) => item.business_line === currentName.value))
|
||||
const physicalCount = computed(() => filteredResources.value.filter((item) => item.type === '物理机').length)
|
||||
const vmCount = computed(() => filteredResources.value.filter((item) => item.type === '虚机').length)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -11,28 +11,28 @@
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">物理机总数</div>
|
||||
<div class="value">186</div>
|
||||
<div class="delta">186 / 186 监控覆盖</div>
|
||||
<div class="value">{{ profile.physicalMachines }}</div>
|
||||
<div class="delta">{{ profile.physicalMachines }} / {{ profile.physicalMachines }} 监控覆盖</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">虚机总数(LAS)</div>
|
||||
<div class="value">512</div>
|
||||
<div class="delta up">↑ 14 本周新增</div>
|
||||
<div class="label">虚机总数({{ currentName }})</div>
|
||||
<div class="value">{{ profile.virtualMachines }}</div>
|
||||
<div class="delta up">↑ {{ Math.max(1, Math.round(profile.virtualMachines / 36)) }} 本周新增</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">基础组件实例</div>
|
||||
<div class="value">214</div>
|
||||
<div class="delta">MySQL 38 · Redis 92 · 其他 84</div>
|
||||
<div class="value">{{ profile.components }}</div>
|
||||
<div class="delta">MySQL {{ profile.mysql }} · Redis {{ profile.redis }} · 其他 {{ profile.components - profile.mysql - profile.redis }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">P0 / Disaster 告警</div>
|
||||
<div class="value err">1</div>
|
||||
<div class="value err">{{ profile.alertsP0 }}</div>
|
||||
<div class="delta err">来自 VictoriaMetrics</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">P1 / High 告警</div>
|
||||
<div class="value warn">5</div>
|
||||
<div class="delta warn">来自 Zabbix · 4 条 / VM · 1 条</div>
|
||||
<div class="value warn">{{ profile.alertsP1 }}</div>
|
||||
<div class="delta warn">来自 Zabbix · {{ Math.max(0, profile.alertsP1 - 1) }} 条 / VM · {{ profile.alertsP1 ? 1 : 0 }} 条</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>虚机状态 · LAS 资源池</h3>
|
||||
<h3>虚机状态 · 业务线资源池</h3>
|
||||
<span class="meta">数据源:VictoriaMetrics</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
@@ -93,28 +93,10 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong mono">Kodo</td>
|
||||
<td class="mono">218</td>
|
||||
<td><span class="bar-wrap"><span class="bar-fill" style="width: 64%"></span></span>64%</td>
|
||||
<td class="status-text ok">● 正常</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong mono">LAS</td>
|
||||
<td class="mono">164</td>
|
||||
<td><span class="bar-wrap"><span class="bar-fill warn" style="width: 82%"></span></span>82%</td>
|
||||
<td class="status-text warn">● 1 条告警</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong mono">灵矽</td>
|
||||
<td class="mono">96</td>
|
||||
<td><span class="bar-wrap"><span class="bar-fill" style="width: 57%"></span></span>57%</td>
|
||||
<td class="status-text ok">● 正常</td>
|
||||
</tr>
|
||||
<tr class="tr-hover">
|
||||
<td class="strong mono">共享池 / 未分配</td>
|
||||
<td class="mono">34</td>
|
||||
<td><span class="bar-wrap"><span class="bar-fill" style="width: 31%"></span></span>31%</td>
|
||||
<td class="status-text ok">● 正常</td>
|
||||
<td class="strong mono">{{ currentName }}</td>
|
||||
<td class="mono">{{ profile.virtualMachines }}</td>
|
||||
<td><span class="bar-wrap"><span class="bar-fill" :class="{ warn: profile.cpuAllocated > 75 }" :style="{ width: `${profile.cpuAllocated}%` }"></span></span>{{ profile.cpuAllocated }}%</td>
|
||||
<td :class="['status-text', profile.alertsP1 ? 'warn' : 'ok']">● {{ profile.alertsP1 ? `${profile.alertsP1} 条告警` : '正常' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -125,6 +107,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const { currentName, profile } = useBusinessLineMockProfile()
|
||||
|
||||
const refresh = () => {
|
||||
// 刷新数据
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="tenant in tenants" :key="tenant.key" class="tr-hover">
|
||||
<tr v-for="tenant in currentTenants" :key="tenant.key" class="tr-hover">
|
||||
<td class="strong">{{ tenant.name }}</td>
|
||||
<td class="mono">{{ tenant.key }}</td>
|
||||
<td class="mono">{{ tenant.namespace }}</td>
|
||||
@@ -63,23 +63,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const { currentName, profile } = useBusinessLineMockProfile()
|
||||
|
||||
const tenants = ref([
|
||||
{
|
||||
name: 'LAS',
|
||||
key: 'las',
|
||||
namespace: 'ns-las-prod',
|
||||
nodes: 12,
|
||||
cpuPct: 64,
|
||||
cpuClass: '',
|
||||
memPct: 71,
|
||||
memClass: '',
|
||||
status: '正常',
|
||||
statusClass: 'ok',
|
||||
},
|
||||
{
|
||||
name: 'KODO',
|
||||
name: 'kodo',
|
||||
key: 'kodo',
|
||||
namespace: 'ns-kodo-prod',
|
||||
nodes: 8,
|
||||
@@ -91,9 +82,9 @@ const tenants = ref([
|
||||
statusClass: 'ok',
|
||||
},
|
||||
{
|
||||
name: 'PILI',
|
||||
key: 'pili',
|
||||
namespace: 'ns-pili-prod',
|
||||
name: 'linxi',
|
||||
key: 'linxi',
|
||||
namespace: 'ns-linxi-prod',
|
||||
nodes: 6,
|
||||
cpuPct: 82,
|
||||
cpuClass: 'warn',
|
||||
@@ -103,9 +94,9 @@ const tenants = ref([
|
||||
statusClass: 'warn',
|
||||
},
|
||||
{
|
||||
name: 'QVM',
|
||||
key: 'qvm',
|
||||
namespace: 'ns-qvm-prod',
|
||||
name: 'xinfra',
|
||||
key: 'xinfra',
|
||||
namespace: 'ns-xinfra-prod',
|
||||
nodes: 4,
|
||||
cpuPct: 23,
|
||||
cpuClass: '',
|
||||
@@ -114,7 +105,34 @@ const tenants = ref([
|
||||
status: '正常',
|
||||
statusClass: 'ok',
|
||||
},
|
||||
{
|
||||
name: 'las',
|
||||
key: 'las',
|
||||
namespace: 'ns-las-prod',
|
||||
nodes: 12,
|
||||
cpuPct: 64,
|
||||
cpuClass: '',
|
||||
memPct: 71,
|
||||
memClass: '',
|
||||
status: '正常',
|
||||
statusClass: 'ok',
|
||||
},
|
||||
])
|
||||
|
||||
const currentTenants = computed(() => {
|
||||
const tenant = tenants.value.find((item) => item.key === currentName.value)
|
||||
if (!tenant) return []
|
||||
return [{
|
||||
...tenant,
|
||||
nodes: Math.max(1, Math.round(profile.value.nodes / 12)),
|
||||
cpuPct: profile.value.cpuAllocated,
|
||||
cpuClass: profile.value.cpuAllocated > 75 ? 'warn' : '',
|
||||
memPct: Math.min(96, profile.value.cpuAllocated + 8),
|
||||
memClass: profile.value.cpuAllocated > 75 ? 'warn' : '',
|
||||
status: profile.value.alertsP1 ? '资源告警' : '正常',
|
||||
statusClass: profile.value.alertsP1 ? 'warn' : 'ok',
|
||||
}]
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -77,8 +77,10 @@
|
||||
<div class="field">
|
||||
<label>业务线</label>
|
||||
<select v-model="mysqlForm.bl">
|
||||
<option value="las">las(当前)</option>
|
||||
<option value="kodo">kodo</option>
|
||||
<option value="linxi">linxi</option>
|
||||
<option value="xinfra">xinfra</option>
|
||||
<option value="las">las</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
@@ -128,12 +130,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { subsystemApi } from '@/api/subsystem'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const router = useRouter()
|
||||
const { currentName } = useBusinessLineMockProfile()
|
||||
|
||||
interface Service {
|
||||
name: string
|
||||
@@ -210,11 +214,16 @@ const basicServices = ref<Service[]>([
|
||||
const showMysqlModal = ref(false)
|
||||
|
||||
const mysqlForm = reactive({
|
||||
bl: 'las',
|
||||
bl: currentName.value,
|
||||
topology: '1m2r',
|
||||
spec: '8C32G',
|
||||
version: '8.0.36',
|
||||
instanceName: 'mysql-las-billing-02',
|
||||
instanceName: `mysql-${currentName.value}-billing-02`,
|
||||
})
|
||||
|
||||
watch(currentName, (name) => {
|
||||
mysqlForm.bl = name
|
||||
mysqlForm.instanceName = `mysql-${name}-billing-02`
|
||||
})
|
||||
|
||||
const handleCardClick = (service: Service) => {
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">服务总数</div>
|
||||
<div class="value">186</div>
|
||||
<div class="value">{{ filteredServices.length }}</div>
|
||||
<div class="delta">去重后唯一服务名</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">服务实例总数</div>
|
||||
<div class="value">842</div>
|
||||
<div class="value">{{ serviceInstances }}</div>
|
||||
<div class="delta">所有机房注册 IP 汇总</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
@@ -50,10 +50,9 @@
|
||||
<el-select placeholder="全部业务标签">
|
||||
<el-option label="全部业务标签" value="" />
|
||||
<el-option label="kodo" value="kodo" />
|
||||
<el-option label="linxi" value="linxi" />
|
||||
<el-option label="xinfra" value="xinfra" />
|
||||
<el-option label="las" value="las" />
|
||||
<el-option label="lingxi" value="lingxi" />
|
||||
<el-option label="ltoken" value="ltoken" />
|
||||
<el-option label="maas" value="maas" />
|
||||
</el-select>
|
||||
<el-select placeholder="全部状态">
|
||||
<el-option label="全部状态" value="" />
|
||||
@@ -83,7 +82,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="service in services" :key="service.name" class="tr-hover">
|
||||
<tr v-for="service in filteredServices" :key="service.name" class="tr-hover">
|
||||
<td class="strong mono">{{ service.name }}</td>
|
||||
<td><span class="tag" :class="service.dcClass">{{ service.dc }}</span></td>
|
||||
<td class="mono">{{ service.biz }}</td>
|
||||
@@ -95,7 +94,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination">
|
||||
<span>共 186 条 · 每页 7 条</span>
|
||||
<span>共 {{ filteredServices.length }} 条 · 当前业务线:{{ currentName }}</span>
|
||||
<div class="pg-btns">
|
||||
<span class="pg-btn disabled">‹</span>
|
||||
<span class="pg-btn active">1</span>
|
||||
@@ -112,17 +111,22 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const { currentName } = useBusinessLineMockProfile()
|
||||
|
||||
const services = ref([
|
||||
{ name: 'kodo-gateway-svc', dc: 'YZH', dcClass: 'zone-a', biz: 'kodo', instances: 12, healthy: '12 / 12', ip: '10.21.4.51', status: '健康', statusClass: 'ok' },
|
||||
{ name: 'kodo-upload-svc', dc: 'XS', dcClass: 'zone-b', biz: 'kodo', instances: 10, healthy: '10 / 10', ip: '10.34.37.66', status: '健康', statusClass: 'ok' },
|
||||
{ name: 'linxi-render-svc', dc: 'JF', dcClass: 'zone-c', biz: 'linxi', instances: 6, healthy: '6 / 6', ip: '10.45.2.30', status: '健康', statusClass: 'ok' },
|
||||
{ name: 'xinfra-portal-svc', dc: 'YZH', dcClass: 'zone-a', biz: 'xinfra', instances: 8, healthy: '8 / 8', ip: '10.21.4.88', status: '健康', statusClass: 'ok' },
|
||||
{ name: 'las-search-api', dc: 'XS', dcClass: 'zone-b', biz: 'las', instances: 18, healthy: '17 / 18', ip: '10.34.37.20', status: '部分异常', statusClass: 'warn' },
|
||||
{ name: 'las-order-svc', dc: '达拉斯 IDC', dcClass: '', biz: 'las', instances: 5, healthy: '5 / 5', ip: '10.66.2.20', status: '健康', statusClass: 'ok' },
|
||||
{ name: 'lingxi-render-svc', dc: 'JF', dcClass: 'zone-c', biz: 'lingxi', instances: 6, healthy: '6 / 6', ip: '10.45.2.30', status: '健康', statusClass: 'ok' },
|
||||
{ name: 'ltoken-wallet-svc', dc: 'YZH', dcClass: 'zone-a', biz: 'ltoken', instances: 8, healthy: '8 / 8', ip: '10.21.4.88', status: '健康', statusClass: 'ok' },
|
||||
{ name: 'maas-infer-svc', dc: '新加坡 IDC', dcClass: '', biz: 'maas', instances: 4, healthy: '3 / 4', ip: '10.88.1.40', status: '部分异常', statusClass: 'warn' },
|
||||
])
|
||||
|
||||
const filteredServices = computed(() => services.value.filter((service) => service.biz === currentName.value))
|
||||
const serviceInstances = computed(() => filteredServices.value.reduce((sum, service) => sum + service.instances, 0))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,118 +3,178 @@
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>子系统赋权</h1>
|
||||
<p>Wayne / CloudDM 入口权限、默认角色与授权状态</p>
|
||||
<p>{{ currentBusinessLineName }} · Wayne namespace 角色授权</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button :loading="loading" @click="reloadAll">刷新</el-button>
|
||||
<el-button v-if="hasWayneRoleBindingPermission" type="primary" :disabled="!canOperate || !selectedUserId || !selectedNamespaceId" :loading="saving" @click="saveRoles">
|
||||
保存授权
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button type="primary">新增授权</el-button>
|
||||
</div>
|
||||
|
||||
<div class="stat-row">
|
||||
<div class="stat-card">
|
||||
<div class="label">接入子系统</div>
|
||||
<div class="value">2</div>
|
||||
<div class="delta">Wayne · CloudDM</div>
|
||||
<div class="value">{{ enabledSystemCount }}</div>
|
||||
<div class="delta">{{ systemSummary }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">授权主体</div>
|
||||
<div class="value">6</div>
|
||||
<div class="delta">用户 3 · 用户组 3</div>
|
||||
<div class="label">Wayne Namespace</div>
|
||||
<div class="value">{{ wayneNamespaces.length }}</div>
|
||||
<div class="delta">当前业务线映射</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">待审批</div>
|
||||
<div class="value" style="color: var(--warn)">2</div>
|
||||
<div class="delta">最近提交 10:18</div>
|
||||
<div class="label">可选角色</div>
|
||||
<div class="value">{{ wayneRoles.length }}</div>
|
||||
<div class="delta">{{ roleSummary }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">默认授权</div>
|
||||
<div class="value" style="font-size: 16px; color: var(--accent)">● 生效</div>
|
||||
<div class="delta">新用户默认只读</div>
|
||||
<div class="label">当前操作权限</div>
|
||||
<div class="value state-value" :class="{ ok: canOperate, warn: !canOperate }">● {{ operatorStateText }}</div>
|
||||
<div class="delta">{{ operatorStateDetail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-select v-model="filters.system" placeholder="全部子系统" style="width: 150px">
|
||||
<el-option label="全部子系统" value="" />
|
||||
<el-option label="Wayne" value="Wayne" />
|
||||
<el-option label="CloudDM" value="CloudDM" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.type" placeholder="全部主体" style="width: 150px">
|
||||
<el-option label="全部主体" value="" />
|
||||
<el-option label="用户" value="user" />
|
||||
<el-option label="用户组" value="group" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" placeholder="全部状态" style="width: 150px">
|
||||
<el-option label="全部状态" value="" />
|
||||
<el-option label="已生效" value="active" />
|
||||
<el-option label="待审批" value="pending" />
|
||||
<el-option label="已停用" value="disabled" />
|
||||
</el-select>
|
||||
<el-input v-model="filters.keyword" placeholder="搜索账号 / 用户组 / 角色" style="flex: 1" />
|
||||
</div>
|
||||
|
||||
<div class="matrix">
|
||||
<div v-for="system in systems" :key="system.name" class="system-card">
|
||||
<div class="system-card">
|
||||
<div class="system-top">
|
||||
<div class="logo" :class="system.className">{{ system.icon }}</div>
|
||||
<div class="logo wayne">W</div>
|
||||
<div>
|
||||
<h3>{{ system.name }}</h3>
|
||||
<p>{{ system.defaultPolicy }}</p>
|
||||
<h3>Wayne</h3>
|
||||
<p>业务线 namespace 角色绑定,默认新用户初始化为访客</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-grid">
|
||||
<div v-for="role in system.roles" :key="role.name" class="role-cell">
|
||||
<div v-for="role in wayneRoles" :key="role.id" class="role-cell">
|
||||
<span>{{ role.name }}</span>
|
||||
<strong>{{ role.count }}</strong>
|
||||
<strong>#{{ role.id }}</strong>
|
||||
</div>
|
||||
<div v-if="!wayneRoles.length" class="role-cell empty-cell">暂无角色</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="system-card muted-card">
|
||||
<div class="system-top">
|
||||
<div class="logo clouddm">DM</div>
|
||||
<div>
|
||||
<h3>CloudDM</h3>
|
||||
<p>接口预留,当前不开放授权操作</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-grid">
|
||||
<div class="role-cell">
|
||||
<span>状态</span>
|
||||
<strong>未启用</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasWayneRoleBindingPermission" class="panel auth-panel">
|
||||
<div class="panel-head">
|
||||
<h3>Wayne 授权操作</h3>
|
||||
<span class="meta">数据源:AuthServer · Wayne internal API</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="用户">
|
||||
<el-select v-model="selectedUserId" filterable placeholder="选择用户" :loading="loadingUsers" @change="loadSelectedUserRoles">
|
||||
<el-option v-for="user in users" :key="user.uid" :label="user.username" :value="user.uid" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Wayne Namespace">
|
||||
<el-select v-model="selectedNamespaceId" filterable placeholder="选择 namespace" :loading="loadingNamespaces" @change="syncSelectedRoleIds">
|
||||
<el-option
|
||||
v-for="namespace in wayneNamespaces"
|
||||
:key="namespace.id"
|
||||
:disabled="!namespace.can_bind && !namespace.can_unbind"
|
||||
:label="namespaceLabel(namespace)"
|
||||
:value="namespace.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="Wayne 角色">
|
||||
<el-select v-model="selectedRoleIds" multiple filterable collapse-tags collapse-tags-tooltip placeholder="选择角色">
|
||||
<el-option v-for="role in wayneRoles" :key="role.id" :label="role.name" :value="role.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="button-row">
|
||||
<el-button type="primary" :disabled="!canOperate || !selectedUserId || !selectedNamespaceId || !selectedRoleIds.length" :loading="saving" @click="saveRoles">
|
||||
保存角色
|
||||
</el-button>
|
||||
<el-button :disabled="!canUnbindSelected || !selectedUserId || !selectedNamespaceId" :loading="clearing" @click="clearRoles">
|
||||
清空角色
|
||||
</el-button>
|
||||
<el-button :disabled="!canOperate || !selectedUserId" :loading="initializing" @click="initVisitor">
|
||||
初始化访客
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<div class="hint-panel">
|
||||
<div class="hint-title">授权规则</div>
|
||||
<p>当前账号必须是平台管理员或当前业务线管理员。</p>
|
||||
<p>保存前会再次校验 Wayne namespace 的授权能力。</p>
|
||||
<p>用户加入业务线时后端会自动初始化 Wayne 访客角色。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="panel readonly-panel">
|
||||
<div class="panel-head">
|
||||
<h3>当前权限</h3>
|
||||
<span class="meta">只读模式</span>
|
||||
</div>
|
||||
<div class="readonly-body">
|
||||
当前账号没有 Wayne namespace 角色绑定权限,只展示现有权限。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>授权列表</h3>
|
||||
<span class="meta">数据源:AuthServer · 子系统授权</span>
|
||||
<h3>当前用户 Wayne 角色</h3>
|
||||
<span class="meta">{{ selectedUsername || '未选择用户' }}</span>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>授权主体</th>
|
||||
<th>类型</th>
|
||||
<th>子系统</th>
|
||||
<th>角色 / 范围</th>
|
||||
<th>来源</th>
|
||||
<th>状态</th>
|
||||
<th>最近变更</th>
|
||||
<th>操作</th>
|
||||
<th>Namespace</th>
|
||||
<th>Kube Namespace</th>
|
||||
<th>当前角色</th>
|
||||
<th>授权能力</th>
|
||||
<th v-if="hasWayneRoleBindingPermission">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in filteredGrants" :key="item.id" class="tr-hover">
|
||||
<tr v-for="namespace in wayneNamespaces" :key="namespace.id" class="tr-hover">
|
||||
<td>
|
||||
<div class="principal">
|
||||
<span class="avatar">{{ item.initial }}</span>
|
||||
<div>
|
||||
<div class="strong">{{ item.principal }}</div>
|
||||
<div class="sub mono">{{ item.detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="strong">{{ namespace.name || '-' }}</div>
|
||||
<div class="sub mono">id={{ namespace.id }}</div>
|
||||
</td>
|
||||
<td><span class="tag">{{ item.type === 'user' ? '用户' : '用户组' }}</span></td>
|
||||
<td class="mono">{{ item.system }}</td>
|
||||
<td class="mono">{{ namespace.kubeNamespace || '-' }}</td>
|
||||
<td>
|
||||
<span class="role">{{ item.role }}</span>
|
||||
<span class="scope mono">{{ item.scope }}</span>
|
||||
<span v-if="roleNamesForNamespace(namespace.id).length" class="role-list">
|
||||
<span v-for="role in roleNamesForNamespace(namespace.id)" :key="role" class="role">{{ role }}</span>
|
||||
</span>
|
||||
<span v-else class="scope">未绑定</span>
|
||||
</td>
|
||||
<td class="mono">{{ item.source }}</td>
|
||||
<td :class="['status-text', item.statusClass]">● {{ item.statusText }}</td>
|
||||
<td class="mono">{{ item.updatedAt }}</td>
|
||||
<td>
|
||||
<span v-if="namespace.permission_error" class="status-text warn">● {{ namespace.permission_error }}</span>
|
||||
<span v-else-if="namespace.can_bind" class="status-text ok">● 可授权</span>
|
||||
<span v-else class="status-text idle">● 无授权权限</span>
|
||||
</td>
|
||||
<td v-if="hasWayneRoleBindingPermission">
|
||||
<div class="actions">
|
||||
<button type="button">编辑</button>
|
||||
<button type="button" class="danger">停用</button>
|
||||
<button type="button" @click="chooseNamespace(namespace.id)">选择</button>
|
||||
<button type="button" class="danger" :disabled="!namespace.can_unbind || !selectedUserId" @click="clearNamespaceRoles(namespace.id)">清空</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!wayneNamespaces.length">
|
||||
<td :colspan="hasWayneRoleBindingPermission ? 5 : 4" class="empty-row">当前业务线没有绑定 Wayne namespace</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -123,62 +183,272 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { subsystemAuthApi, type SubsystemAuthSystem, type WayneBusinessLineNamespace, type WayneRole, type WayneUserRoles } from '@/api/subsystemAuth'
|
||||
import { userApi, type UserOption } from '@/api/user'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||
|
||||
const filters = reactive({
|
||||
system: '',
|
||||
type: '',
|
||||
status: '',
|
||||
keyword: '',
|
||||
const authStore = useAuthStore()
|
||||
const businessLineStore = useBusinessLineStore()
|
||||
|
||||
const systems = ref<SubsystemAuthSystem[]>([])
|
||||
const users = ref<UserOption[]>([])
|
||||
const wayneRoles = ref<WayneRole[]>([])
|
||||
const wayneNamespaces = ref<WayneBusinessLineNamespace[]>([])
|
||||
const userRoles = ref<WayneUserRoles | null>(null)
|
||||
const selectedUserId = ref<number | null>(null)
|
||||
const selectedNamespaceId = ref<number | null>(null)
|
||||
const selectedRoleIds = ref<number[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingUsers = ref(false)
|
||||
const loadingNamespaces = ref(false)
|
||||
const saving = ref(false)
|
||||
const clearing = ref(false)
|
||||
const initializing = ref(false)
|
||||
|
||||
const isPlatformAdmin = computed(() => authStore.isAdmin)
|
||||
const canManageBusinessLine = computed(() => isPlatformAdmin.value || businessLineStore.isCurrentAdmin)
|
||||
const currentBusinessLineId = computed(() => businessLineStore.current?.id || null)
|
||||
const currentBusinessLineName = computed(() => businessLineStore.current?.name || '未选择业务线')
|
||||
const selectedUser = computed(() => users.value.find((user) => user.uid === selectedUserId.value))
|
||||
const selectedUsername = computed(() => selectedUser.value?.username || '')
|
||||
const selectedNamespace = computed(() => wayneNamespaces.value.find((item) => item.id === selectedNamespaceId.value))
|
||||
const canOperate = computed(() => canManageBusinessLine.value && Boolean(selectedNamespace.value?.can_bind))
|
||||
const canUnbindSelected = computed(() => canManageBusinessLine.value && Boolean(selectedNamespace.value?.can_unbind))
|
||||
const hasWayneRoleBindingPermission = computed(() =>
|
||||
canManageBusinessLine.value && wayneNamespaces.value.some((item) => item.can_bind || item.can_unbind),
|
||||
)
|
||||
const enabledSystemCount = computed(() => systems.value.filter((item) => item.enabled).length)
|
||||
const systemSummary = computed(() => systems.value.map((item) => `${item.name}${item.enabled ? '' : '(未启用)'}`).join(' · ') || 'Wayne')
|
||||
const roleSummary = computed(() => wayneRoles.value.map((item) => item.name).join(' · ') || '暂无')
|
||||
const operatorStateText = computed(() => {
|
||||
if (!canManageBusinessLine.value) return '无业务线权限'
|
||||
if (!wayneNamespaces.value.length) return '未绑定 namespace'
|
||||
if (hasWayneRoleBindingPermission.value) return '可授权'
|
||||
return '只读'
|
||||
})
|
||||
const operatorStateDetail = computed(() => {
|
||||
if (!canManageBusinessLine.value) return '需要平台管理员或当前业务线管理员'
|
||||
if (!wayneNamespaces.value.length) return '先在业务线分配中绑定 Wayne namespace'
|
||||
if (!hasWayneRoleBindingPermission.value) return '没有 Wayne 角色绑定权限'
|
||||
return `${wayneNamespaces.value.filter((item) => item.can_bind).length} 个 namespace 可授权`
|
||||
})
|
||||
|
||||
const systems = [
|
||||
{
|
||||
name: 'Wayne',
|
||||
icon: 'W',
|
||||
className: 'wayne',
|
||||
defaultPolicy: '默认 namespace 只读 · DemoGroupId=23',
|
||||
roles: [
|
||||
{ name: '只读', count: 18 },
|
||||
{ name: '发布', count: 6 },
|
||||
{ name: '管理员', count: 2 },
|
||||
],
|
||||
watch(
|
||||
() => businessLineStore.current?.id,
|
||||
async () => {
|
||||
selectedNamespaceId.value = null
|
||||
selectedRoleIds.value = []
|
||||
userRoles.value = null
|
||||
await loadBusinessLineData()
|
||||
},
|
||||
{
|
||||
name: 'CloudDM',
|
||||
icon: 'DM',
|
||||
className: 'clouddm',
|
||||
defaultPolicy: 'OIDC 登录 · SQL 审核角色映射',
|
||||
roles: [
|
||||
{ name: '查询', count: 21 },
|
||||
{ name: '审核', count: 5 },
|
||||
{ name: '管理员', count: 1 },
|
||||
],
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
const grants = [
|
||||
{ id: 1, principal: 'eastsales@qiniu.com', initial: 'E', detail: 'eastsales', type: 'user', system: 'Wayne', role: '默认只读', scope: 'namespace=demo', source: 'SSO 自动初始化', status: 'active', statusText: '已生效', statusClass: 'ok', updatedAt: '2026-07-15 10:12' },
|
||||
{ id: 2, principal: 'platform-admin', initial: 'P', detail: 'LDAP group', type: 'group', system: 'Wayne', role: '管理员', scope: 'all namespaces', source: '手动授权', status: 'active', statusText: '已生效', statusClass: 'ok', updatedAt: '2026-07-14 18:40' },
|
||||
{ id: 3, principal: 'dba-reviewers', initial: 'D', detail: 'LDAP group', type: 'group', system: 'CloudDM', role: 'SQL 审核', scope: 'prod / staging', source: '手动授权', status: 'active', statusText: '已生效', statusClass: 'ok', updatedAt: '2026-07-14 16:05' },
|
||||
{ id: 4, principal: 'las-dev', initial: 'L', detail: 'LDAP group', type: 'group', system: 'CloudDM', role: '查询', scope: 'las schemas', source: '审批流', status: 'pending', statusText: '待审批', statusClass: 'warn', updatedAt: '2026-07-15 10:18' },
|
||||
{ id: 5, principal: 'ops-user1@qiniu.com', initial: 'O', detail: 'ops-user1', type: 'user', system: 'Wayne', role: '发布', scope: 'namespace=demo', source: '审批流', status: 'pending', statusText: '待审批', statusClass: 'warn', updatedAt: '2026-07-15 09:55' },
|
||||
{ id: 6, principal: 'temp-sql@qiniu.com', initial: 'T', detail: 'temp-sql', type: 'user', system: 'CloudDM', role: '查询', scope: 'expired', source: '临时授权', status: 'disabled', statusText: '已停用', statusClass: 'idle', updatedAt: '2026-07-13 20:30' },
|
||||
]
|
||||
watch(selectedNamespaceId, () => {
|
||||
syncSelectedRoleIds()
|
||||
})
|
||||
|
||||
const filteredGrants = computed(() => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
return grants.filter((item) => {
|
||||
if (filters.system && item.system !== filters.system) return false
|
||||
if (filters.type && item.type !== filters.type) return false
|
||||
if (filters.status && item.status !== filters.status) return false
|
||||
if (!keyword) return true
|
||||
return [item.principal, item.detail, item.system, item.role, item.scope]
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.includes(keyword)
|
||||
onMounted(async () => {
|
||||
await reloadAll()
|
||||
})
|
||||
|
||||
async function reloadAll() {
|
||||
loading.value = true
|
||||
try {
|
||||
await ensureBusinessLinesLoaded()
|
||||
await Promise.all([loadSystems(), loadUsers(), loadWayneRoles(), loadBusinessLineData()])
|
||||
if (selectedUserId.value) {
|
||||
await loadSelectedUserRoles()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBusinessLinesLoaded() {
|
||||
if (businessLineStore.current?.id && businessLineStore.businessLines.length) {
|
||||
return
|
||||
}
|
||||
await businessLineStore.loadMine().catch(() => {})
|
||||
}
|
||||
|
||||
async function loadSystems() {
|
||||
try {
|
||||
systems.value = await subsystemAuthApi.listSystems()
|
||||
} catch {
|
||||
systems.value = [
|
||||
{ key: 'wayne', name: 'Wayne', enabled: true },
|
||||
{ key: 'clouddm', name: 'CloudDM', enabled: false },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loadingUsers.value = true
|
||||
try {
|
||||
users.value = await userApi.list()
|
||||
if (!selectedUserId.value && users.value.length) {
|
||||
selectedUserId.value = users.value[0].uid
|
||||
await loadSelectedUserRoles()
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '查询用户失败')
|
||||
} finally {
|
||||
loadingUsers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWayneRoles() {
|
||||
try {
|
||||
wayneRoles.value = await subsystemAuthApi.listWayneRoles()
|
||||
} catch (error) {
|
||||
wayneRoles.value = []
|
||||
ElMessage.error(error instanceof Error ? error.message : '查询 Wayne 角色失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBusinessLineData() {
|
||||
const businessLineId = currentBusinessLineId.value
|
||||
if (!businessLineId || !canManageBusinessLine.value) {
|
||||
wayneNamespaces.value = []
|
||||
return
|
||||
}
|
||||
loadingNamespaces.value = true
|
||||
try {
|
||||
wayneNamespaces.value = await subsystemAuthApi.listWayneNamespaces(businessLineId)
|
||||
if (!selectedNamespaceId.value && wayneNamespaces.value.length) {
|
||||
selectedNamespaceId.value = wayneNamespaces.value[0].id
|
||||
}
|
||||
} catch (error) {
|
||||
wayneNamespaces.value = []
|
||||
ElMessage.error(error instanceof Error ? error.message : '查询 Wayne namespace 失败')
|
||||
} finally {
|
||||
loadingNamespaces.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSelectedUserRoles() {
|
||||
if (!selectedUsername.value) {
|
||||
userRoles.value = null
|
||||
selectedRoleIds.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
userRoles.value = await subsystemAuthApi.getWayneUserRoles(selectedUsername.value)
|
||||
syncSelectedRoleIds()
|
||||
} catch (error) {
|
||||
userRoles.value = null
|
||||
selectedRoleIds.value = []
|
||||
ElMessage.error(error instanceof Error ? error.message : '查询用户 Wayne 角色失败')
|
||||
}
|
||||
}
|
||||
|
||||
function syncSelectedRoleIds() {
|
||||
const namespaceId = selectedNamespaceId.value
|
||||
if (!namespaceId || !userRoles.value?.namespaces) {
|
||||
selectedRoleIds.value = []
|
||||
return
|
||||
}
|
||||
const binding = userRoles.value.namespaces.find((item) => Number(item.namespace?.id) === namespaceId)
|
||||
selectedRoleIds.value = (binding?.groups || []).map((item) => Number(item.id)).filter(Boolean)
|
||||
}
|
||||
|
||||
async function saveRoles() {
|
||||
const businessLineId = currentBusinessLineId.value
|
||||
const namespaceId = selectedNamespaceId.value
|
||||
const username = selectedUsername.value
|
||||
if (!businessLineId || !namespaceId || !username || !selectedRoleIds.value.length) {
|
||||
ElMessage.warning('请选择用户、namespace 和角色')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await subsystemAuthApi.bindWayneNamespaceRoles(businessLineId, namespaceId, username, {
|
||||
groupIds: selectedRoleIds.value,
|
||||
replace: true,
|
||||
requestId: requestId('wayne-bind'),
|
||||
reason: '子系统赋权',
|
||||
})
|
||||
ElMessage.success('Wayne 角色已保存')
|
||||
await loadSelectedUserRoles()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存 Wayne 角色失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearRoles() {
|
||||
const namespaceId = selectedNamespaceId.value
|
||||
if (!namespaceId) return
|
||||
await clearNamespaceRoles(namespaceId)
|
||||
}
|
||||
|
||||
async function clearNamespaceRoles(namespaceId: number) {
|
||||
const businessLineId = currentBusinessLineId.value
|
||||
const username = selectedUsername.value
|
||||
if (!businessLineId || !username) {
|
||||
ElMessage.warning('请选择用户')
|
||||
return
|
||||
}
|
||||
await ElMessageBox.confirm('确认清空该用户在此 Wayne namespace 下的角色?', '清空角色', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '清空',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
})
|
||||
clearing.value = true
|
||||
try {
|
||||
await subsystemAuthApi.unbindWayneNamespaceRoles(businessLineId, namespaceId, username, {
|
||||
requestId: requestId('wayne-clear'),
|
||||
reason: '子系统赋权清空角色',
|
||||
})
|
||||
ElMessage.success('Wayne 角色已清空')
|
||||
await loadSelectedUserRoles()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '清空 Wayne 角色失败')
|
||||
} finally {
|
||||
clearing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function initVisitor() {
|
||||
const businessLineId = currentBusinessLineId.value
|
||||
const userId = selectedUserId.value
|
||||
if (!businessLineId || !userId) {
|
||||
ElMessage.warning('请选择用户')
|
||||
return
|
||||
}
|
||||
initializing.value = true
|
||||
try {
|
||||
await subsystemAuthApi.initWayneBusinessLineUser(businessLineId, userId)
|
||||
ElMessage.success('已初始化 Wayne 访客角色')
|
||||
await loadSelectedUserRoles()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '初始化 Wayne 访客角色失败')
|
||||
} finally {
|
||||
initializing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function chooseNamespace(namespaceId: number) {
|
||||
selectedNamespaceId.value = namespaceId
|
||||
}
|
||||
|
||||
function roleNamesForNamespace(namespaceId: number): string[] {
|
||||
const binding = userRoles.value?.namespaces?.find((item) => Number(item.namespace?.id) === namespaceId)
|
||||
return (binding?.groups || []).map((item) => item.name).filter(Boolean)
|
||||
}
|
||||
|
||||
function namespaceLabel(namespace: WayneBusinessLineNamespace) {
|
||||
const ability = namespace.can_bind ? '可授权' : namespace.can_unbind ? '可清空' : '无权限'
|
||||
return `${namespace.name || namespace.id} / ${namespace.kubeNamespace || '-'} · ${ability}`
|
||||
}
|
||||
|
||||
function requestId(prefix: string) {
|
||||
return `${prefix}-${Date.now()}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -201,6 +471,13 @@ const filteredGrants = computed(() => {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.head-actions,
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
@@ -229,19 +506,24 @@ const filteredGrants = computed(() => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.state-value {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.state-value.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.state-value.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.stat-card .delta {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.matrix {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -256,6 +538,10 @@ const filteredGrants = computed(() => {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.muted-card {
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.system-top {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -276,13 +562,13 @@ const filteredGrants = computed(() => {
|
||||
}
|
||||
|
||||
.logo.wayne {
|
||||
background: #1C2A3A;
|
||||
color: #7FB8FF;
|
||||
background: #1c2a3a;
|
||||
color: #7fb8ff;
|
||||
}
|
||||
|
||||
.logo.clouddm {
|
||||
background: #1C3A2E;
|
||||
color: #7FFFC2;
|
||||
background: #1c3a2e;
|
||||
color: #7fffc2;
|
||||
}
|
||||
|
||||
.system-top h3 {
|
||||
@@ -319,10 +605,20 @@ const filteredGrants = computed(() => {
|
||||
color: var(--text-hi);
|
||||
}
|
||||
|
||||
.empty-cell {
|
||||
justify-content: center;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
@@ -345,6 +641,42 @@ const filteredGrants = computed(() => {
|
||||
font-family: var(--mono);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 520px) 1fr;
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.hint-panel {
|
||||
border: 1px solid var(--line-soft);
|
||||
background: var(--bg-panel-2);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.hint-title {
|
||||
color: var(--text-hi);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hint-panel p {
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.readonly-panel {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.readonly-body {
|
||||
padding: 16px;
|
||||
color: var(--text-dim);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 4px 0;
|
||||
overflow-x: auto;
|
||||
@@ -352,7 +684,7 @@ const filteredGrants = computed(() => {
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
min-width: 980px;
|
||||
min-width: 920px;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
@@ -375,7 +707,7 @@ td {
|
||||
}
|
||||
|
||||
.tr-hover:hover {
|
||||
background: #171D28;
|
||||
background: #171d28;
|
||||
}
|
||||
|
||||
.strong {
|
||||
@@ -389,28 +721,18 @@ td {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.principal {
|
||||
.role-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: #2A3142;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-hi);
|
||||
font-size: 11px;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.role {
|
||||
color: var(--text-hi);
|
||||
margin-right: 8px;
|
||||
background: var(--bg-panel-2);
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 5px;
|
||||
padding: 3px 7px;
|
||||
}
|
||||
|
||||
.scope {
|
||||
@@ -418,6 +740,18 @@ td {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.status-text.ok {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.status-text.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.status-text.idle {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -432,12 +766,31 @@ td {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions button:hover {
|
||||
.actions button:hover:not(:disabled) {
|
||||
color: var(--text-hi);
|
||||
border-color: #3A4356;
|
||||
border-color: #3a4356;
|
||||
}
|
||||
|
||||
.actions button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.actions button.danger {
|
||||
color: #FF9A95;
|
||||
color: #ff9a95;
|
||||
}
|
||||
|
||||
.empty-row {
|
||||
text-align: center;
|
||||
color: var(--text-dim);
|
||||
padding: 26px 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.stat-row,
|
||||
.matrix,
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="panel-body">
|
||||
<table>
|
||||
<tbody>
|
||||
<tr v-for="task in tasks" :key="task.id" class="tr-hover" :class="{ active: task.id === selectedTask }" @click="selectedTask = task.id">
|
||||
<tr v-for="task in visibleTasks" :key="task.id" class="tr-hover" :class="{ active: task.id === selectedTask }" @click="selectedTask = task.id">
|
||||
<td :class="['status-text', task.statusClass]">● {{ task.status }}</td>
|
||||
<td class="strong">{{ task.name }}</td>
|
||||
<td class="mono text-xs text-dim">{{ task.playbook }}</td>
|
||||
@@ -23,7 +23,7 @@
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="pagination">
|
||||
<span>共 86 条 · 每页 6 条</span>
|
||||
<span>共 {{ visibleTasks.length }} 条 · 当前业务线:{{ currentName }}</span>
|
||||
<div class="pg-btns">
|
||||
<span class="pg-btn disabled">‹</span>
|
||||
<span class="pg-btn active">1</span>
|
||||
@@ -39,11 +39,11 @@
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>实时日志 · RKE2 节点加入</h3>
|
||||
<h3>实时日志 · {{ currentName }} RKE2 节点加入</h3>
|
||||
<span class="meta">WebSocket 流式输出</span>
|
||||
</div>
|
||||
<div class="panel-body log-stream">
|
||||
<div v-for="(log, index) in logs" :key="index" :class="['task-log-line', log.class]">
|
||||
<div v-for="(log, index) in visibleLogs" :key="index" :class="['task-log-line', log.class]">
|
||||
<span class="t">{{ log.time }}</span>{{ log.message }}
|
||||
</div>
|
||||
<div class="task-log-line">
|
||||
@@ -57,17 +57,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||
|
||||
const { currentName } = useBusinessLineMockProfile()
|
||||
|
||||
const selectedTask = ref(1)
|
||||
|
||||
const tasks = ref([
|
||||
{ id: 1, name: 'RKE2 节点加入 · bj-node-061', playbook: 'roles/rke2-node-join', status: '执行中', statusClass: 'warn' },
|
||||
{ id: 2, name: 'MySQL 主从部署 · kodo', playbook: 'roles/mysql-deploy', status: '成功', statusClass: 'ok' },
|
||||
{ id: 3, name: 'Redis Cluster 部署 · las', playbook: 'roles/redis-deploy', status: '成功', statusClass: 'ok' },
|
||||
{ id: 3, name: 'Redis Cluster 部署 · linxi', playbook: 'roles/redis-deploy', status: '成功', statusClass: 'ok' },
|
||||
{ id: 4, name: 'openresty 网关部署', playbook: 'roles/openresty-deploy', status: '失败', statusClass: 'err' },
|
||||
{ id: 5, name: '业务线标签同步 · LDAP', playbook: 'internal/label-sync', status: '成功', statusClass: 'ok' },
|
||||
{ id: 6, name: 'MySQL 主从部署 · ltoken', playbook: 'roles/mysql-deploy', status: '成功', statusClass: 'ok' },
|
||||
{ id: 6, name: 'MySQL 主从部署 · xinfra', playbook: 'roles/mysql-deploy', status: '成功', statusClass: 'ok' },
|
||||
])
|
||||
|
||||
const logs = ref([
|
||||
@@ -80,6 +83,16 @@ const logs = ref([
|
||||
{ time: '10:42:20', message: 'changed: [bj-node-061] => labels applied', class: 'tag-ok' },
|
||||
{ time: '10:42:21', message: 'TASK [加入集群 rke2-bj-prod-01] ...', class: '' },
|
||||
])
|
||||
|
||||
const visibleTasks = computed(() => tasks.value.map((task) => ({
|
||||
...task,
|
||||
name: task.name.replace(/ · (kodo|linxi|xinfra|las)|$/, ` · ${currentName.value}`),
|
||||
})))
|
||||
|
||||
const visibleLogs = computed(() => logs.value.map((log) => ({
|
||||
...log,
|
||||
message: log.message.replace(/business-line=(kodo|linxi|xinfra|las)/, `business-line=${currentName.value}`),
|
||||
})))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+58
-5
@@ -285,14 +285,14 @@ Wayne 会把回调地址拼成:
|
||||
|
||||
## Wayne 授权代理接口
|
||||
|
||||
AuthServer 的 Wayne 授权代理接口不要求调用方传 Wayne user ID。后端会从当前 `authserver_token` 里取 `email`,把它作为 Wayne username 传给 Wayne internal API。
|
||||
AuthServer 的 Wayne 授权代理接口要求调用方传目标 Wayne `username`。后端会从当前 `authserver_token` 里取 `email` 作为操作者 `operatorName`,目标用户名由请求体或路径参数提供。
|
||||
|
||||
对外接口:
|
||||
|
||||
```text
|
||||
GET /auth/api/v1/wayne/namespaces
|
||||
GET /auth/api/v1/wayne/groups
|
||||
GET /auth/api/v1/wayne/users/me/roles
|
||||
GET /auth/api/v1/wayne/users/:username/roles
|
||||
GET /auth/api/v1/wayne/namespaces/:namespaceid/operator-permissions
|
||||
GET /auth/api/v1/wayne/apps/:appid/operator-permissions
|
||||
PUT /auth/api/v1/wayne/namespaces/:namespaceid/roles
|
||||
@@ -309,6 +309,7 @@ Authorization: Bearer <authserver_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "target@example.com",
|
||||
"groupIds": [10, 11],
|
||||
"replace": false,
|
||||
"requestId": "req-001",
|
||||
@@ -316,13 +317,13 @@ Content-Type: application/json
|
||||
}
|
||||
```
|
||||
|
||||
AuthServer 转发到 Wayne internal API 时会使用 token email:
|
||||
AuthServer 转发到 Wayne internal API 时会使用请求体里的 `username`:
|
||||
|
||||
```text
|
||||
PUT /api/v1/internal/namespaces/1/users/<token-email>/roles
|
||||
PUT /api/v1/internal/namespaces/1/users/target@example.com/roles
|
||||
```
|
||||
|
||||
并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。
|
||||
并覆盖请求体中的 `operatorName` 为 token email,忽略外部传入的 `operatorUserId`。`username` 只用于 Wayne path,不会透传到 Wayne 请求体。
|
||||
|
||||
相关配置:
|
||||
|
||||
@@ -341,6 +342,58 @@ signature = HMAC_SHA256_HEX(secret, payload)
|
||||
X-Wayne-Signature = "sha256=" + signature
|
||||
```
|
||||
|
||||
## 子系统赋权接口
|
||||
|
||||
子系统赋权接口是平台业务层接口,前端应优先调用这一组,而不是直接调用低层 `/wayne/*` 代理。当前只实现 Wayne,CloudDM 先返回未启用占位。
|
||||
|
||||
权限规则:
|
||||
|
||||
- 平台管理员可以操作任意业务线。
|
||||
- 非平台管理员必须是当前业务线管理员,也就是 `business_line_users.permission = 0`。
|
||||
- Wayne 写操作前还会查询 Wayne `operator-permissions`,确认当前登录用户在目标 namespace 下具备创建/更新/删除用户角色的权限。
|
||||
- 用户首次加入业务线时,如果该业务线绑定了 Wayne namespace,会自动给该用户初始化 Wayne namespace `访客` 角色。
|
||||
|
||||
接口列表:
|
||||
|
||||
```text
|
||||
GET /auth/api/v1/subsystem-auth/systems
|
||||
GET /auth/api/v1/subsystem-auth/wayne/roles
|
||||
GET /auth/api/v1/subsystem-auth/wayne/business-lines/:id/namespaces
|
||||
GET /auth/api/v1/subsystem-auth/wayne/users/:username/roles
|
||||
PUT /auth/api/v1/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles
|
||||
DELETE /auth/api/v1/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles
|
||||
POST /auth/api/v1/subsystem-auth/wayne/business-lines/:id/users/:userid/init
|
||||
```
|
||||
|
||||
Wayne 授权示例:
|
||||
|
||||
```http
|
||||
PUT /auth/api/v1/subsystem-auth/wayne/business-lines/1/namespaces/3/users/eastsales@qiniu.com/roles
|
||||
Authorization: Bearer <authserver_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"groupIds": [2],
|
||||
"replace": true,
|
||||
"requestId": "req-001",
|
||||
"reason": "业务线授权"
|
||||
}
|
||||
```
|
||||
|
||||
Wayne 解绑示例:
|
||||
|
||||
```http
|
||||
DELETE /auth/api/v1/subsystem-auth/wayne/business-lines/1/namespaces/3/users/eastsales@qiniu.com/roles
|
||||
Authorization: Bearer <authserver_token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"groupIds": [2],
|
||||
"requestId": "req-002",
|
||||
"reason": "回收业务线授权"
|
||||
}
|
||||
```
|
||||
|
||||
管理员可查看当前 SAML metadata 配置:
|
||||
|
||||
```text
|
||||
|
||||
@@ -4,16 +4,19 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1024XEngineer/xinfra/server/internal/model"
|
||||
"github.com/1024XEngineer/xinfra/server/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type BusinessLineHandler struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
wayne *service.WayneRoleBindingService
|
||||
}
|
||||
|
||||
type BusinessLineWithPermission struct {
|
||||
@@ -45,8 +48,8 @@ type WayneNamespaceBindingItem struct {
|
||||
KubeNamespace string `json:"kubeNamespace"`
|
||||
}
|
||||
|
||||
func NewBusinessLineHandler(db *gorm.DB) *BusinessLineHandler {
|
||||
return &BusinessLineHandler{db: db}
|
||||
func NewBusinessLineHandler(db *gorm.DB, wayne *service.WayneRoleBindingService) *BusinessLineHandler {
|
||||
return &BusinessLineHandler{db: db, wayne: wayne}
|
||||
}
|
||||
|
||||
func (h *BusinessLineHandler) ListCurrentUserBusinessLines(c *gin.Context) {
|
||||
@@ -269,6 +272,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
|
||||
}
|
||||
|
||||
var binding model.BusinessLineUser
|
||||
created := false
|
||||
err := h.db.Where("business_line_id = ? AND user_id = ?", req.TargetBusinessLineID, req.TargetUserID).First(&binding).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
binding = model.BusinessLineUser{
|
||||
@@ -280,6 +284,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
created = true
|
||||
} else if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -291,6 +296,24 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
|
||||
binding.Permission = req.Permission
|
||||
}
|
||||
|
||||
var initializedWayne []gin.H
|
||||
if created {
|
||||
operatorEmail, ok := subsystemOperatorEmail(c, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetUsername := wayneUsernameForUser(targetUser)
|
||||
if targetUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "target user has no Wayne username"})
|
||||
return
|
||||
}
|
||||
initialized, ok := h.initializeWayneVisitorForBusinessLine(c, req.TargetBusinessLineID, targetUsername, operatorEmail, claims.IsAdmin)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
initializedWayne = initialized
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": binding.ID,
|
||||
"business_line_id": binding.BusinessLineID,
|
||||
@@ -298,6 +321,7 @@ func (h *BusinessLineHandler) GrantPermission(c *gin.Context) {
|
||||
"permission": binding.Permission,
|
||||
"created_at": binding.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": binding.UpdatedAt.Format(time.RFC3339),
|
||||
"wayne_init": initializedWayne,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -399,6 +423,60 @@ func (h *BusinessLineHandler) canManageBusinessLine(c *gin.Context, businessLine
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *BusinessLineHandler) initializeWayneVisitorForBusinessLine(c *gin.Context, businessLineID uint64, targetUsername string, operatorEmail string, skipWaynePermissionCheck bool) ([]gin.H, bool) {
|
||||
var namespaces []model.BusinessLineWayneNamespace
|
||||
if err := h.db.Where("business_line_id = ?", businessLineID).Order("wayne_namespace_id ASC").Find(&namespaces).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
if len(namespaces) == 0 {
|
||||
return []gin.H{}, true
|
||||
}
|
||||
if h.wayne == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "wayne internal role binding api is not configured"})
|
||||
return nil, false
|
||||
}
|
||||
|
||||
groupIDs, err := h.wayne.NamespaceVisitorGroupIDs(c.Request.Context())
|
||||
if err != nil {
|
||||
writeWayneRoleBindingError(c, nil, err)
|
||||
return nil, false
|
||||
}
|
||||
replace := true
|
||||
req := service.WayneRoleBindingRequest{
|
||||
GroupIDs: groupIDs,
|
||||
Replace: &replace,
|
||||
RequestID: "business-line-user-init-" + strconv.FormatInt(time.Now().UnixNano(), 10),
|
||||
Reason: "初始化业务线 Wayne 访客角色",
|
||||
}
|
||||
|
||||
items := make([]gin.H, 0, len(namespaces))
|
||||
for _, namespace := range namespaces {
|
||||
if !skipWaynePermissionCheck {
|
||||
permissions, err := h.wayne.NamespaceOperatorPermissionsParsed(c.Request.Context(), namespace.WayneNamespaceID, operatorEmail)
|
||||
if err != nil {
|
||||
writeWayneRoleBindingError(c, nil, err)
|
||||
return nil, false
|
||||
}
|
||||
if !permissions.Create && !permissions.Update {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "current user does not have Wayne namespace role create or update permission"})
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
result, err := h.wayne.BindNamespace(c.Request.Context(), namespace.WayneNamespaceID, strings.TrimSpace(targetUsername), operatorEmail, req)
|
||||
if err != nil {
|
||||
writeWayneRoleBindingError(c, result, err)
|
||||
return nil, false
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"namespace_id": namespace.WayneNamespaceID,
|
||||
"namespace_name": namespace.WayneNamespaceName,
|
||||
"group_ids": groupIDs,
|
||||
})
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
|
||||
func parseBusinessLineID(c *gin.Context) (uint64, bool) {
|
||||
value, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil || value == 0 {
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/1024XEngineer/xinfra/server/internal/auth"
|
||||
"github.com/1024XEngineer/xinfra/server/internal/model"
|
||||
"github.com/1024XEngineer/xinfra/server/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SubsystemAuthHandler struct {
|
||||
db *gorm.DB
|
||||
wayne *service.WayneRoleBindingService
|
||||
audit *service.AuditService
|
||||
}
|
||||
|
||||
func NewSubsystemAuthHandler(db *gorm.DB, wayne *service.WayneRoleBindingService, audit *service.AuditService) *SubsystemAuthHandler {
|
||||
return &SubsystemAuthHandler{db: db, wayne: wayne, audit: audit}
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) ListSystems(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"items": []gin.H{
|
||||
{"key": "wayne", "name": "Wayne", "enabled": true},
|
||||
{"key": "clouddm", "name": "CloudDM", "enabled": false},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) ListWayneNamespaceRoles(c *gin.Context) {
|
||||
groups, err := h.wayne.ListNamespaceRoleGroups(c.Request.Context())
|
||||
if err != nil {
|
||||
writeWayneRoleBindingError(c, nil, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": groups})
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) ListWayneBusinessLineNamespaces(c *gin.Context) {
|
||||
claims, ok := CurrentClaims(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
businessLineID, ok := parseBusinessLineID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.canManageBusinessLine(c, claims, businessLineID) {
|
||||
return
|
||||
}
|
||||
|
||||
rows, ok := h.listBusinessLineWayneNamespaces(c, businessLineID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
operatorEmail, ok := subsystemOperatorEmail(c, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
item := gin.H{
|
||||
"id": row.WayneNamespaceID,
|
||||
"name": row.WayneNamespaceName,
|
||||
"kubeNamespace": row.KubeNamespace,
|
||||
}
|
||||
if claims.IsAdmin {
|
||||
item["permissions"] = &service.WayneOperatorPermissions{Create: true, Update: true, Delete: true}
|
||||
item["can_bind"] = true
|
||||
item["can_unbind"] = true
|
||||
} else {
|
||||
permissions, err := h.wayne.NamespaceOperatorPermissionsParsed(c.Request.Context(), row.WayneNamespaceID, operatorEmail)
|
||||
if err != nil {
|
||||
item["permission_error"] = err.Error()
|
||||
} else {
|
||||
item["permissions"] = permissions
|
||||
item["can_bind"] = permissions.Create || permissions.Update
|
||||
item["can_unbind"] = permissions.Delete
|
||||
}
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) GetWayneUserRoles(c *gin.Context) {
|
||||
username := strings.TrimSpace(c.Param("username"))
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid username"})
|
||||
return
|
||||
}
|
||||
result, err := h.wayne.GetUserRoles(c.Request.Context(), username)
|
||||
if err != nil {
|
||||
writeWayneRoleBindingError(c, result, err)
|
||||
return
|
||||
}
|
||||
writeWayneRoleBindingResult(c, result)
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) BindWayneNamespaceRoles(c *gin.Context) {
|
||||
h.handleWayneNamespaceRoles(c, http.MethodPut)
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) UnbindWayneNamespaceRoles(c *gin.Context) {
|
||||
h.handleWayneNamespaceRoles(c, http.MethodDelete)
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) InitWayneBusinessLineUser(c *gin.Context) {
|
||||
claims, ok := CurrentClaims(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
businessLineID, ok := parseBusinessLineID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetUserID, ok := parseUintPathParam(c, "userid")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.canManageBusinessLine(c, claims, businessLineID) {
|
||||
return
|
||||
}
|
||||
|
||||
var target model.User
|
||||
if err := h.db.Where("id = ? AND deleted_at IS NULL", targetUserID).First(&target).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "target user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
operatorEmail, ok := subsystemOperatorEmail(c, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetUsername := wayneUsernameForUser(target)
|
||||
result, ok := h.initializeWayneVisitor(c, businessLineID, targetUsername, operatorEmail, claims.IsAdmin, service.WayneRoleBindingRequest{
|
||||
RequestID: "business-line-user-init-" + strconv.FormatUint(targetUserID, 10) + "-" + strconv.FormatInt(time.Now().Unix(), 10),
|
||||
Reason: "初始化业务线 Wayne 访客角色",
|
||||
})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "items": result})
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) handleWayneNamespaceRoles(c *gin.Context, method string) {
|
||||
claims, ok := CurrentClaims(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return
|
||||
}
|
||||
businessLineID, ok := parseBusinessLineID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
namespaceID, ok := parseUintPathParam(c, "namespaceid")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetUsername := strings.TrimSpace(c.Param("username"))
|
||||
if targetUsername == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid username"})
|
||||
return
|
||||
}
|
||||
if !h.canManageBusinessLine(c, claims, businessLineID) {
|
||||
return
|
||||
}
|
||||
if !h.ensureNamespaceBelongsToBusinessLine(c, businessLineID, namespaceID) {
|
||||
return
|
||||
}
|
||||
if !h.ensureLocalUserExists(c, targetUsername) {
|
||||
return
|
||||
}
|
||||
operatorEmail, ok := subsystemOperatorEmail(c, claims)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !claims.IsAdmin && !h.ensureWayneOperatorPermission(c, namespaceID, operatorEmail, method) {
|
||||
return
|
||||
}
|
||||
|
||||
req, ok := parseRoleBindingRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.Username = ""
|
||||
if method == http.MethodPut && len(req.GroupIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "groupIds is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var result *service.WayneRoleBindingResult
|
||||
var err error
|
||||
if method == http.MethodPut {
|
||||
result, err = h.wayne.BindNamespace(c.Request.Context(), namespaceID, targetUsername, operatorEmail, req)
|
||||
} else {
|
||||
result, err = h.wayne.UnbindNamespace(c.Request.Context(), namespaceID, targetUsername, operatorEmail, req)
|
||||
}
|
||||
if err != nil {
|
||||
h.writeAudit(c, claims, businessLineID, namespaceID, targetUsername, method, "deny", req.RequestID, err.Error())
|
||||
writeWayneRoleBindingError(c, result, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeAudit(c, claims, businessLineID, namespaceID, targetUsername, method, "allow", req.RequestID, "")
|
||||
writeWayneRoleBindingResult(c, result)
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) canManageBusinessLine(c *gin.Context, claims *auth.Claims, businessLineID uint64) bool {
|
||||
var businessLine model.BusinessLine
|
||||
if err := h.db.First(&businessLine, "id = ?", businessLineID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "business line not found"})
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
if claims.IsAdmin {
|
||||
return true
|
||||
}
|
||||
|
||||
var binding model.BusinessLineUser
|
||||
if err := h.db.Where("business_line_id = ? AND user_id = ? AND permission = ?", businessLineID, claims.UserID, 0).
|
||||
First(&binding).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "current user is not platform admin or business line admin"})
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) ensureNamespaceBelongsToBusinessLine(c *gin.Context, businessLineID, namespaceID uint64) bool {
|
||||
var row model.BusinessLineWayneNamespace
|
||||
if err := h.db.Where("business_line_id = ? AND wayne_namespace_id = ?", businessLineID, namespaceID).First(&row).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "wayne namespace is not bound to current business line"})
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) ensureLocalUserExists(c *gin.Context, username string) bool {
|
||||
var user model.User
|
||||
if err := h.db.Where("(username = ? OR email = ?) AND deleted_at IS NULL", username, username).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "target user not found"})
|
||||
return false
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) ensureWayneOperatorPermission(c *gin.Context, namespaceID uint64, operatorEmail string, method string) bool {
|
||||
permissions, err := h.wayne.NamespaceOperatorPermissionsParsed(c.Request.Context(), namespaceID, operatorEmail)
|
||||
if err != nil {
|
||||
writeWayneRoleBindingError(c, nil, err)
|
||||
return false
|
||||
}
|
||||
if method == http.MethodDelete {
|
||||
if permissions.Delete {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "current user does not have Wayne namespace role delete permission"})
|
||||
return false
|
||||
}
|
||||
if permissions.Create || permissions.Update {
|
||||
return true
|
||||
}
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "current user does not have Wayne namespace role create or update permission"})
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) listBusinessLineWayneNamespaces(c *gin.Context, businessLineID uint64) ([]model.BusinessLineWayneNamespace, bool) {
|
||||
var rows []model.BusinessLineWayneNamespace
|
||||
if err := h.db.Where("business_line_id = ?", businessLineID).Order("wayne_namespace_id ASC").Find(&rows).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return nil, false
|
||||
}
|
||||
return rows, true
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) initializeWayneVisitor(c *gin.Context, businessLineID uint64, targetUsername string, operatorEmail string, skipWaynePermissionCheck bool, req service.WayneRoleBindingRequest) ([]gin.H, bool) {
|
||||
namespaces, ok := h.listBusinessLineWayneNamespaces(c, businessLineID)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if len(namespaces) == 0 {
|
||||
return []gin.H{}, true
|
||||
}
|
||||
groupIDs, err := h.wayne.NamespaceVisitorGroupIDs(c.Request.Context())
|
||||
if err != nil {
|
||||
writeWayneRoleBindingError(c, nil, err)
|
||||
return nil, false
|
||||
}
|
||||
req.GroupIDs = groupIDs
|
||||
replace := true
|
||||
req.Replace = &replace
|
||||
|
||||
items := make([]gin.H, 0, len(namespaces))
|
||||
for _, namespace := range namespaces {
|
||||
if !skipWaynePermissionCheck && !h.ensureWayneOperatorPermission(c, namespace.WayneNamespaceID, operatorEmail, http.MethodPut) {
|
||||
return nil, false
|
||||
}
|
||||
result, err := h.wayne.BindNamespace(c.Request.Context(), namespace.WayneNamespaceID, targetUsername, operatorEmail, req)
|
||||
if err != nil {
|
||||
writeWayneRoleBindingError(c, result, err)
|
||||
return nil, false
|
||||
}
|
||||
items = append(items, gin.H{
|
||||
"namespace_id": namespace.WayneNamespaceID,
|
||||
"namespace_name": namespace.WayneNamespaceName,
|
||||
"group_ids": groupIDs,
|
||||
})
|
||||
}
|
||||
return items, true
|
||||
}
|
||||
|
||||
func (h *SubsystemAuthHandler) writeAudit(c *gin.Context, claims *auth.Claims, businessLineID, namespaceID uint64, targetUsername, method, decision, requestID, reason string) {
|
||||
h.audit.Write(service.AuditEntry{
|
||||
RequestID: requestID,
|
||||
ActorUserID: claims.UserID,
|
||||
ActorUsername: actorNameFromClaims(claims),
|
||||
ClientIP: c.ClientIP(),
|
||||
UserAgent: c.Request.UserAgent(),
|
||||
Action: "subsystem_auth.wayne." + strings.ToLower(method) + "." + decision,
|
||||
ResourceType: "wayne_namespace",
|
||||
ResourceID: strconv.FormatUint(namespaceID, 10),
|
||||
ScopeType: "business_line",
|
||||
ScopeID: businessLineID,
|
||||
BusinessLineID: businessLineID,
|
||||
NamespaceID: namespaceID,
|
||||
Decision: decision,
|
||||
Reason: truncateAuditReason(reason),
|
||||
Metadata: map[string]any{
|
||||
"targetUsername": targetUsername,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func subsystemOperatorEmail(c *gin.Context, claims *auth.Claims) (string, bool) {
|
||||
operatorEmail := strings.TrimSpace(claims.Email)
|
||||
if operatorEmail == "" {
|
||||
operatorEmail = strings.TrimSpace(claims.Username)
|
||||
}
|
||||
if operatorEmail == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username is missing in token"})
|
||||
return "", false
|
||||
}
|
||||
return operatorEmail, true
|
||||
}
|
||||
|
||||
func actorNameFromClaims(claims *auth.Claims) string {
|
||||
if claims == nil {
|
||||
return ""
|
||||
}
|
||||
if value := strings.TrimSpace(claims.Email); value != "" {
|
||||
return value
|
||||
}
|
||||
return claims.Username
|
||||
}
|
||||
|
||||
func wayneUsernameForUser(user model.User) string {
|
||||
if value := strings.TrimSpace(user.Email); value != "" {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(user.Username)
|
||||
}
|
||||
@@ -64,8 +64,9 @@ func (h *WayneRoleBindingHandler) ListGroups(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *WayneRoleBindingHandler) GetCurrentUserRoles(c *gin.Context) {
|
||||
username, ok := currentTokenEmail(c)
|
||||
if !ok {
|
||||
username := strings.TrimSpace(c.Param("username"))
|
||||
if username == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid username"})
|
||||
return
|
||||
}
|
||||
h.handleQuery(c, "user_roles", 0, username)
|
||||
@@ -111,19 +112,23 @@ func (h *WayneRoleBindingHandler) handle(c *gin.Context, scope string, method st
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
targetUsername, ok := roleBindingTargetUsername(c, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if method == http.MethodPut && len(req.GroupIDs) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "groupIds is required"})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.call(c, scope, method, resourceID, operatorEmail, req)
|
||||
result, err := h.call(c, scope, method, resourceID, targetUsername, operatorEmail, req)
|
||||
if err != nil {
|
||||
h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, operatorEmail, "deny", req.RequestID, err.Error())
|
||||
h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, targetUsername, "deny", req.RequestID, err.Error())
|
||||
writeWayneRoleBindingError(c, result, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, operatorEmail, "allow", req.RequestID, "")
|
||||
h.writeAudit(c, claims.UserID, operatorEmail, scope, resourceID, targetUsername, "allow", req.RequestID, "")
|
||||
writeWayneRoleBindingResult(c, result)
|
||||
}
|
||||
|
||||
@@ -177,17 +182,17 @@ func (h *WayneRoleBindingHandler) handleOperatorPermissions(c *gin.Context, scop
|
||||
writeWayneRoleBindingResult(c, result)
|
||||
}
|
||||
|
||||
func (h *WayneRoleBindingHandler) call(c *gin.Context, scope, method string, resourceID uint64, operatorEmail string, req service.WayneRoleBindingRequest) (*service.WayneRoleBindingResult, error) {
|
||||
func (h *WayneRoleBindingHandler) call(c *gin.Context, scope, method string, resourceID uint64, targetUsername string, operatorEmail string, req service.WayneRoleBindingRequest) (*service.WayneRoleBindingResult, error) {
|
||||
if scope == "namespace" {
|
||||
if method == http.MethodPut {
|
||||
return h.wayne.BindNamespace(c.Request.Context(), resourceID, operatorEmail, req)
|
||||
return h.wayne.BindNamespace(c.Request.Context(), resourceID, targetUsername, operatorEmail, req)
|
||||
}
|
||||
return h.wayne.UnbindNamespace(c.Request.Context(), resourceID, operatorEmail, req)
|
||||
return h.wayne.UnbindNamespace(c.Request.Context(), resourceID, targetUsername, operatorEmail, req)
|
||||
}
|
||||
if method == http.MethodPut {
|
||||
return h.wayne.BindApp(c.Request.Context(), resourceID, operatorEmail, req)
|
||||
return h.wayne.BindApp(c.Request.Context(), resourceID, targetUsername, operatorEmail, req)
|
||||
}
|
||||
return h.wayne.UnbindApp(c.Request.Context(), resourceID, operatorEmail, req)
|
||||
return h.wayne.UnbindApp(c.Request.Context(), resourceID, targetUsername, operatorEmail, req)
|
||||
}
|
||||
|
||||
func parseRoleBindingRequest(c *gin.Context) (service.WayneRoleBindingRequest, bool) {
|
||||
@@ -208,6 +213,21 @@ func parseRoleBindingRequest(c *gin.Context) (service.WayneRoleBindingRequest, b
|
||||
return req, true
|
||||
}
|
||||
|
||||
func roleBindingTargetUsername(c *gin.Context, req service.WayneRoleBindingRequest) (string, bool) {
|
||||
if username := strings.TrimSpace(req.Username); username != "" {
|
||||
return username, true
|
||||
}
|
||||
for _, key := range []string{"username", "userName", "user_name"} {
|
||||
raw := strings.TrimSpace(c.Query(key))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username is required"})
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseUintPathParam(c *gin.Context, name string) (uint64, bool) {
|
||||
raw := strings.TrimSpace(c.Param(name))
|
||||
value, err := strconv.ParseUint(raw, 10, 64)
|
||||
@@ -256,20 +276,6 @@ func writeWayneRoleBindingError(c *gin.Context, result *service.WayneRoleBinding
|
||||
}
|
||||
}
|
||||
|
||||
func currentTokenEmail(c *gin.Context) (string, bool) {
|
||||
claims, ok := CurrentClaims(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||
return "", false
|
||||
}
|
||||
email := strings.TrimSpace(claims.Email)
|
||||
if email == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "email is missing in token"})
|
||||
return "", false
|
||||
}
|
||||
return email, true
|
||||
}
|
||||
|
||||
func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, operatorEmail, scope string, resourceID uint64, targetUsername string, decision, requestID, reason string) {
|
||||
h.audit.Write(service.AuditEntry{
|
||||
RequestID: requestID,
|
||||
@@ -283,7 +289,7 @@ func (h *WayneRoleBindingHandler) writeAudit(c *gin.Context, userID uint64, oper
|
||||
ScopeType: scope,
|
||||
ScopeID: resourceID,
|
||||
Decision: decision,
|
||||
Reason: reason,
|
||||
Reason: truncateAuditReason(reason),
|
||||
Metadata: map[string]any{
|
||||
"targetUsername": targetUsername,
|
||||
},
|
||||
@@ -310,6 +316,14 @@ func (h *WayneRoleBindingHandler) writeQueryAudit(c *gin.Context, resourceType s
|
||||
ResourceType: "wayne_" + resourceType,
|
||||
ResourceID: strconv.FormatUint(resourceID, 10),
|
||||
Decision: decision,
|
||||
Reason: reason,
|
||||
Reason: truncateAuditReason(reason),
|
||||
})
|
||||
}
|
||||
|
||||
func truncateAuditReason(reason string) string {
|
||||
const maxReasonBytes = 512
|
||||
if len(reason) <= maxReasonBytes {
|
||||
return reason
|
||||
}
|
||||
return reason[:maxReasonBytes]
|
||||
}
|
||||
|
||||
@@ -74,9 +74,10 @@ 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)
|
||||
businessLineHandler := handler.NewBusinessLineHandler(deps.DB, wayneRoleBindingService)
|
||||
wayenHandler := handler.NewWayenHandler(deps.DB, wayenService, auditService)
|
||||
wayneRoleBindingHandler := handler.NewWayneRoleBindingHandler(wayneRoleBindingService, auditService)
|
||||
subsystemAuthHandler := handler.NewSubsystemAuthHandler(deps.DB, wayneRoleBindingService, auditService)
|
||||
clouddmHandler := handler.NewCloudDMHandler(deps.Config, auditService)
|
||||
samlHandler := handler.NewSAMLHandler(deps.Config, authService)
|
||||
oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService)
|
||||
@@ -115,13 +116,20 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
|
||||
protected.PUT("/wayen/credential", wayenHandler.SaveCredential)
|
||||
protected.GET("/wayne/namespaces", wayneRoleBindingHandler.ListNamespaces)
|
||||
protected.GET("/wayne/groups", wayneRoleBindingHandler.ListGroups)
|
||||
protected.GET("/wayne/users/me/roles", wayneRoleBindingHandler.GetCurrentUserRoles)
|
||||
protected.GET("/wayne/users/:username/roles", wayneRoleBindingHandler.GetCurrentUserRoles)
|
||||
protected.GET("/wayne/namespaces/:namespaceid/operator-permissions", wayneRoleBindingHandler.NamespaceOperatorPermissions)
|
||||
protected.GET("/wayne/apps/:appid/operator-permissions", wayneRoleBindingHandler.AppOperatorPermissions)
|
||||
protected.PUT("/wayne/namespaces/:namespaceid/roles", wayneRoleBindingHandler.BindNamespace)
|
||||
protected.DELETE("/wayne/namespaces/:namespaceid/roles", wayneRoleBindingHandler.UnbindNamespace)
|
||||
protected.PUT("/wayne/apps/:appid/roles", wayneRoleBindingHandler.BindApp)
|
||||
protected.DELETE("/wayne/apps/:appid/roles", wayneRoleBindingHandler.UnbindApp)
|
||||
protected.GET("/subsystem-auth/systems", subsystemAuthHandler.ListSystems)
|
||||
protected.GET("/subsystem-auth/wayne/roles", subsystemAuthHandler.ListWayneNamespaceRoles)
|
||||
protected.GET("/subsystem-auth/wayne/business-lines/:id/namespaces", subsystemAuthHandler.ListWayneBusinessLineNamespaces)
|
||||
protected.GET("/subsystem-auth/wayne/users/:username/roles", subsystemAuthHandler.GetWayneUserRoles)
|
||||
protected.PUT("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.BindWayneNamespaceRoles)
|
||||
protected.DELETE("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.UnbindWayneNamespaceRoles)
|
||||
protected.POST("/subsystem-auth/wayne/business-lines/:id/users/:userid/init", subsystemAuthHandler.InitWayneBusinessLineUser)
|
||||
protected.GET("/clouddm/login", clouddmHandler.Login)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ var (
|
||||
)
|
||||
|
||||
type WayneRoleBindingRequest struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
GroupIDs []uint64 `json:"groupIds,omitempty"`
|
||||
OperatorUserID *uint64 `json:"operatorUserId,omitempty"`
|
||||
OperatorName string `json:"operatorName,omitempty"`
|
||||
@@ -39,6 +40,19 @@ type WayneRoleBindingResult struct {
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type WayneRoleGroup struct {
|
||||
ID uint64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Comment string `json:"comment"`
|
||||
Type int `json:"type"`
|
||||
}
|
||||
|
||||
type WayneOperatorPermissions struct {
|
||||
Create bool `json:"create"`
|
||||
Update bool `json:"update"`
|
||||
Delete bool `json:"delete"`
|
||||
}
|
||||
|
||||
type WayneRoleBindingHTTPError struct {
|
||||
StatusCode int
|
||||
Body []byte
|
||||
@@ -71,20 +85,20 @@ func NewWayneRoleBindingService(cfg config.Config) *WayneRoleBindingService {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), username, req)
|
||||
func (s *WayneRoleBindingService) BindNamespace(ctx context.Context, namespaceID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), operatorEmail, req)
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) UnbindNamespace(ctx context.Context, namespaceID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), username, req)
|
||||
func (s *WayneRoleBindingService) UnbindNamespace(ctx context.Context, namespaceID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/namespaces/%d/users/%s/roles", namespaceID, url.PathEscape(username)), operatorEmail, req)
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) BindApp(ctx context.Context, appID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), username, req)
|
||||
func (s *WayneRoleBindingService) BindApp(ctx context.Context, appID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||
return s.call(ctx, http.MethodPut, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), operatorEmail, req)
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) UnbindApp(ctx context.Context, appID uint64, username string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), username, req)
|
||||
func (s *WayneRoleBindingService) UnbindApp(ctx context.Context, appID uint64, username string, operatorEmail string, req WayneRoleBindingRequest) (*WayneRoleBindingResult, error) {
|
||||
return s.call(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/internal/apps/%d/users/%s/roles", appID, url.PathEscape(username)), operatorEmail, req)
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRoleBindingResult, error) {
|
||||
@@ -101,6 +115,32 @@ func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int
|
||||
return s.callRaw(ctx, http.MethodGet, internalPath, nil)
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) ListNamespaceRoleGroups(ctx context.Context) ([]WayneRoleGroup, error) {
|
||||
groupType := 1
|
||||
result, err := s.ListGroups(ctx, &groupType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseWayneRoleGroups(result.Body)
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) NamespaceVisitorGroupIDs(ctx context.Context) ([]uint64, error) {
|
||||
groups, err := s.ListNamespaceRoleGroups(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]uint64, 0, 1)
|
||||
for _, group := range groups {
|
||||
if isWayneVisitorRoleName(group.Name) {
|
||||
ids = append(ids, group.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("wayne visitor role group not found")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) GetUserRoles(ctx context.Context, username string) (*WayneRoleBindingResult, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
if username == "" {
|
||||
@@ -113,6 +153,14 @@ func (s *WayneRoleBindingService) NamespaceOperatorPermissions(ctx context.Conte
|
||||
return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/namespaces/%d/operator-permissions", namespaceID), operatorEmail)
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) NamespaceOperatorPermissionsParsed(ctx context.Context, namespaceID uint64, operatorEmail string) (*WayneOperatorPermissions, error) {
|
||||
result, err := s.NamespaceOperatorPermissions(ctx, namespaceID, operatorEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseWayneOperatorPermissions(result.Body)
|
||||
}
|
||||
|
||||
func (s *WayneRoleBindingService) AppOperatorPermissions(ctx context.Context, appID uint64, operatorEmail string) (*WayneRoleBindingResult, error) {
|
||||
return s.operatorPermissions(ctx, fmt.Sprintf("/api/v1/internal/apps/%d/operator-permissions", appID), operatorEmail)
|
||||
}
|
||||
@@ -128,6 +176,7 @@ func (s *WayneRoleBindingService) call(ctx context.Context, method, internalPath
|
||||
|
||||
req.OperatorUserID = nil
|
||||
req.OperatorName = operatorEmail
|
||||
req.Username = ""
|
||||
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
@@ -248,3 +297,44 @@ func truncateForDebugLog(value string, limit int) string {
|
||||
}
|
||||
return value[:limit] + "...(truncated)"
|
||||
}
|
||||
|
||||
func parseWayneRoleGroups(body []byte) ([]WayneRoleGroup, error) {
|
||||
var wrapped struct {
|
||||
Data []WayneRoleGroup `json:"data"`
|
||||
Items []WayneRoleGroup `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrapped); err == nil {
|
||||
if wrapped.Data != nil {
|
||||
return wrapped.Data, nil
|
||||
}
|
||||
if wrapped.Items != nil {
|
||||
return wrapped.Items, nil
|
||||
}
|
||||
}
|
||||
var direct []WayneRoleGroup
|
||||
if err := json.Unmarshal(body, &direct); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return direct, nil
|
||||
}
|
||||
|
||||
func parseWayneOperatorPermissions(body []byte) (*WayneOperatorPermissions, error) {
|
||||
var wrapped struct {
|
||||
Data struct {
|
||||
Permissions WayneOperatorPermissions `json:"permissions"`
|
||||
} `json:"data"`
|
||||
Permissions WayneOperatorPermissions `json:"permissions"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wrapped.Data.Permissions != (WayneOperatorPermissions{}) {
|
||||
return &wrapped.Data.Permissions, nil
|
||||
}
|
||||
return &wrapped.Permissions, nil
|
||||
}
|
||||
|
||||
func isWayneVisitorRoleName(name string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(name))
|
||||
return normalized == "访客" || normalized == "visitor" || strings.Contains(normalized, "visitor")
|
||||
}
|
||||
|
||||
@@ -40,7 +40,8 @@ func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testin
|
||||
|
||||
operatorUserID := uint64(123)
|
||||
replace := false
|
||||
result, err := svc.BindNamespace(context.Background(), 1, "eastsales@qiniu.com", WayneRoleBindingRequest{
|
||||
result, err := svc.BindNamespace(context.Background(), 1, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{
|
||||
Username: "target@example.com",
|
||||
GroupIDs: []uint64{10, 11},
|
||||
OperatorUserID: &operatorUserID,
|
||||
OperatorName: "attacker@example.com",
|
||||
@@ -54,7 +55,7 @@ func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testin
|
||||
if result.StatusCode != http.StatusOK {
|
||||
t.Fatalf("StatusCode = %d, want 200", result.StatusCode)
|
||||
}
|
||||
if requestPath != "/api/v1/internal/namespaces/1/users/eastsales@qiniu.com/roles" {
|
||||
if requestPath != "/api/v1/internal/namespaces/1/users/target@example.com/roles" {
|
||||
t.Fatalf("requestPath = %q", requestPath)
|
||||
}
|
||||
if payload.OperatorName != "eastsales@qiniu.com" {
|
||||
@@ -63,6 +64,9 @@ func TestWayneRoleBindingServiceBindNamespaceSignsAndOverridesOperator(t *testin
|
||||
if payload.OperatorUserID != nil {
|
||||
t.Fatalf("OperatorUserID should be omitted, got %v", *payload.OperatorUserID)
|
||||
}
|
||||
if payload.Username != "" {
|
||||
t.Fatalf("Username should be omitted from Wayne body, got %q", payload.Username)
|
||||
}
|
||||
if payload.Replace == nil || *payload.Replace {
|
||||
t.Fatalf("Replace = %v, want false", payload.Replace)
|
||||
}
|
||||
@@ -77,23 +81,23 @@ func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) {
|
||||
{
|
||||
name: "unbind namespace",
|
||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
||||
return s.UnbindNamespace(context.Background(), 1, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}})
|
||||
return s.UnbindNamespace(context.Background(), 1, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{10}})
|
||||
},
|
||||
want: "DELETE /api/v1/internal/namespaces/1/users/eastsales@qiniu.com/roles",
|
||||
want: "DELETE /api/v1/internal/namespaces/1/users/target@example.com/roles",
|
||||
},
|
||||
{
|
||||
name: "bind app",
|
||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
||||
return s.BindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
|
||||
return s.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
|
||||
},
|
||||
want: "PUT /api/v1/internal/apps/3/users/eastsales@qiniu.com/roles",
|
||||
want: "PUT /api/v1/internal/apps/3/users/target@example.com/roles",
|
||||
},
|
||||
{
|
||||
name: "unbind app",
|
||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
||||
return s.UnbindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
|
||||
return s.UnbindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
|
||||
},
|
||||
want: "DELETE /api/v1/internal/apps/3/users/eastsales@qiniu.com/roles",
|
||||
want: "DELETE /api/v1/internal/apps/3/users/target@example.com/roles",
|
||||
},
|
||||
{
|
||||
name: "list namespaces",
|
||||
@@ -120,9 +124,9 @@ func TestWayneRoleBindingServiceCallsAllDocumentedEndpoints(t *testing.T) {
|
||||
{
|
||||
name: "get user roles",
|
||||
call: func(s *WayneRoleBindingService) (*WayneRoleBindingResult, error) {
|
||||
return s.GetUserRoles(context.Background(), "eastsales@qiniu.com")
|
||||
return s.GetUserRoles(context.Background(), "target@example.com")
|
||||
},
|
||||
want: "GET /api/v1/internal/users/eastsales@qiniu.com/roles",
|
||||
want: "GET /api/v1/internal/users/target@example.com/roles",
|
||||
},
|
||||
{
|
||||
name: "namespace operator permissions",
|
||||
@@ -226,7 +230,7 @@ func TestWayneRoleBindingServiceHTTPError(t *testing.T) {
|
||||
WayneServiceName: "xinfra",
|
||||
WayneServiceAPISecretKey: "service-secret",
|
||||
})
|
||||
result, err := svc.BindApp(context.Background(), 3, "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
|
||||
result, err := svc.BindApp(context.Background(), 3, "target@example.com", "eastsales@qiniu.com", WayneRoleBindingRequest{GroupIDs: []uint64{20}})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user