Merge pull request #98 from ztkkOip/auth-login
fix(Auth login) 增加了一些主系统数据抽取
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
import { getToken } from '@/utils/auth'
|
||||||
|
|
||||||
|
export interface ContainerServiceSummary {
|
||||||
|
businessLineId: number
|
||||||
|
businessLineName: string
|
||||||
|
namespaces: number
|
||||||
|
clusters: number
|
||||||
|
nodes: number
|
||||||
|
readyNodes: number
|
||||||
|
schedulableNodes: number
|
||||||
|
workloads: number
|
||||||
|
pods: number
|
||||||
|
readyPods: number
|
||||||
|
cpuUsed: number
|
||||||
|
cpuTotal: number
|
||||||
|
cpuPercent: number
|
||||||
|
memoryUsed: number
|
||||||
|
memoryTotal: number
|
||||||
|
memoryPercent: number
|
||||||
|
errors?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContainerWorkload {
|
||||||
|
name: string
|
||||||
|
cluster: string
|
||||||
|
namespace: string
|
||||||
|
workload: string
|
||||||
|
ready: string
|
||||||
|
readyPods: number
|
||||||
|
pods: number
|
||||||
|
image: string
|
||||||
|
biz: string
|
||||||
|
status: string
|
||||||
|
statusClass: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const emptyContainerServiceSummary: ContainerServiceSummary = {
|
||||||
|
businessLineId: 0,
|
||||||
|
businessLineName: '',
|
||||||
|
namespaces: 0,
|
||||||
|
clusters: 0,
|
||||||
|
nodes: 0,
|
||||||
|
readyNodes: 0,
|
||||||
|
schedulableNodes: 0,
|
||||||
|
workloads: 0,
|
||||||
|
pods: 0,
|
||||||
|
readyPods: 0,
|
||||||
|
cpuUsed: 0,
|
||||||
|
cpuTotal: 0,
|
||||||
|
cpuPercent: 0,
|
||||||
|
memoryUsed: 0,
|
||||||
|
memoryTotal: 0,
|
||||||
|
memoryPercent: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const containerServiceApi = {
|
||||||
|
async getSummary(businessLineId: number): Promise<ContainerServiceSummary> {
|
||||||
|
return authRequest(`/auth/api/v1/container-services/business-lines/${businessLineId}/summary`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async listWorkloads(businessLineId: number): Promise<{ items: ContainerWorkload[]; summary: ContainerServiceSummary }> {
|
||||||
|
const data = await authRequest(`/auth/api/v1/container-services/business-lines/${businessLineId}/workloads`)
|
||||||
|
return {
|
||||||
|
items: Array.isArray(data.items) ? data.items : [],
|
||||||
|
summary: data.summary || emptyContainerServiceSummary,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="page-head">
|
<div class="page-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>业务线管理</h2>
|
<h2>业务线管理</h2>
|
||||||
<p>平台管理员维护全局业务线</p>
|
<p>新增时自动查找或创建同名 namespace,并完成业务线绑定</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ async function createBusinessLine() {
|
|||||||
newName.value = ''
|
newName.value = ''
|
||||||
await loadAllBusinessLines()
|
await loadAllBusinessLines()
|
||||||
await businessLineStore.loadMine()
|
await businessLineStore.loadMine()
|
||||||
ElMessage.success('已新增业务线')
|
ElMessage.success('已新增业务线,并完成同名 namespace 绑定')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : '新增业务线失败')
|
ElMessage.error(error instanceof Error ? error.message : '新增业务线失败')
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -36,6 +36,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>容器化服务 · 资源概览</h3>
|
||||||
|
<span class="meta">{{ containerLoading ? '同步中...' : containerMetaText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="container-overview">
|
||||||
|
<div class="container-metric">
|
||||||
|
<span>命名空间</span>
|
||||||
|
<strong>{{ containerSummary.namespaces }}</strong>
|
||||||
|
<small>{{ currentName }} 业务线绑定</small>
|
||||||
|
</div>
|
||||||
|
<div class="container-metric">
|
||||||
|
<span>工作负载</span>
|
||||||
|
<strong>{{ containerSummary.workloads }}</strong>
|
||||||
|
<small>Deployment</small>
|
||||||
|
</div>
|
||||||
|
<div class="container-metric">
|
||||||
|
<span>Pod 实例</span>
|
||||||
|
<strong>{{ containerSummary.pods }}</strong>
|
||||||
|
<small>{{ containerSummary.readyPods }} ready</small>
|
||||||
|
</div>
|
||||||
|
<div class="container-metric">
|
||||||
|
<span>CPU 已用</span>
|
||||||
|
<strong>{{ formatMetric(containerSummary.cpuUsed) }}C</strong>
|
||||||
|
<small>{{ containerSummary.cpuPercent }}% of allocatable</small>
|
||||||
|
</div>
|
||||||
|
<div class="container-metric">
|
||||||
|
<span>Memory 已用</span>
|
||||||
|
<strong>{{ formatMetric(containerSummary.memoryUsed) }}G</strong>
|
||||||
|
<small>{{ containerSummary.memoryPercent }}% of allocatable</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="cols-2">
|
<div class="cols-2">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
@@ -121,13 +155,57 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { containerServiceApi, emptyContainerServiceSummary, type ContainerServiceSummary } from '@/api/containerService'
|
||||||
|
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||||
|
|
||||||
const { currentName, profile } = useBusinessLineMockProfile()
|
const { currentName, profile } = useBusinessLineMockProfile()
|
||||||
|
const businessLineStore = useBusinessLineStore()
|
||||||
|
|
||||||
|
const containerSummary = ref<ContainerServiceSummary>({ ...emptyContainerServiceSummary })
|
||||||
|
const containerLoading = ref(false)
|
||||||
|
const containerError = ref('')
|
||||||
|
|
||||||
|
const containerMetaText = computed(() => {
|
||||||
|
if (containerError.value) {
|
||||||
|
return containerError.value
|
||||||
|
}
|
||||||
|
const errors = containerSummary.value.errors?.length || 0
|
||||||
|
return errors > 0 ? `数据源:Kubernetes 集群运行态 · ${errors} 项同步失败` : '数据源:Kubernetes 集群运行态'
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadContainerSummary = async () => {
|
||||||
|
const businessLineId = businessLineStore.current?.id
|
||||||
|
if (!businessLineId) {
|
||||||
|
containerSummary.value = { ...emptyContainerServiceSummary }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
containerLoading.value = true
|
||||||
|
containerError.value = ''
|
||||||
|
try {
|
||||||
|
containerSummary.value = await containerServiceApi.getSummary(businessLineId)
|
||||||
|
} catch (error) {
|
||||||
|
containerSummary.value = { ...emptyContainerServiceSummary }
|
||||||
|
containerError.value = error instanceof Error ? error.message : '容器化服务数据同步失败'
|
||||||
|
} finally {
|
||||||
|
containerLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
// 刷新数据
|
loadContainerSummary()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatMetric = (value: number) => Number(value || 0).toFixed(1).replace(/\.0$/, '')
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => businessLineStore.current?.id,
|
||||||
|
() => {
|
||||||
|
loadContainerSummary()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -202,4 +280,40 @@ const refresh = () => {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container-overview {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-metric {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 13px 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--bg-panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-metric span,
|
||||||
|
.container-metric small {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-metric strong {
|
||||||
|
display: block;
|
||||||
|
margin: 6px 0 4px;
|
||||||
|
color: var(--text-hi);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.container-overview {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -107,14 +107,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<h3>容器化服务台账 · 按集群 / Namespace 同步</h3>
|
||||||
|
<span class="meta">{{ containerLoading ? '同步中...' : containerMetaText }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>服务名</th>
|
||||||
|
<th>集群</th>
|
||||||
|
<th>Namespace</th>
|
||||||
|
<th>工作负载</th>
|
||||||
|
<th>Pod Ready</th>
|
||||||
|
<th>镜像</th>
|
||||||
|
<th>业务标签</th>
|
||||||
|
<th>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="!containerLoading && containerServices.length === 0">
|
||||||
|
<td colspan="8" class="text-dim">当前业务线暂无容器化服务</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="service in containerServices" :key="`${service.cluster}-${service.namespace}-${service.name}`" class="tr-hover">
|
||||||
|
<td class="strong mono">{{ service.name }}</td>
|
||||||
|
<td><span class="tag">{{ service.cluster }}</span></td>
|
||||||
|
<td class="mono">{{ service.namespace }}</td>
|
||||||
|
<td>{{ service.workload }}</td>
|
||||||
|
<td class="mono">{{ service.ready }}</td>
|
||||||
|
<td class="mono text-xs">{{ service.image || '-' }}</td>
|
||||||
|
<td class="mono">{{ service.biz }}</td>
|
||||||
|
<td :class="['status-text', service.statusClass]">● {{ service.status }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="pagination">
|
||||||
|
<span>共 {{ containerServices.length }} 条容器化服务 · 当前业务线:{{ currentName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { containerServiceApi, emptyContainerServiceSummary, type ContainerServiceSummary, type ContainerWorkload } from '@/api/containerService'
|
||||||
|
import { useBusinessLineStore } from '@/stores/businessLine'
|
||||||
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
import { useBusinessLineMockProfile } from '@/utils/businessLineMock'
|
||||||
|
|
||||||
const { currentName } = useBusinessLineMockProfile()
|
const { currentName } = useBusinessLineMockProfile()
|
||||||
|
const businessLineStore = useBusinessLineStore()
|
||||||
|
|
||||||
const services = ref([
|
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-gateway-svc', dc: 'YZH', dcClass: 'zone-a', biz: 'kodo', instances: 12, healthy: '12 / 12', ip: '10.21.4.51', status: '健康', statusClass: 'ok' },
|
||||||
@@ -125,8 +169,50 @@ const services = ref([
|
|||||||
{ name: 'las-order-svc', dc: '达拉斯 IDC', dcClass: '', biz: 'las', instances: 5, healthy: '5 / 5', ip: '10.66.2.20', status: '健康', statusClass: 'ok' },
|
{ name: 'las-order-svc', dc: '达拉斯 IDC', dcClass: '', biz: 'las', instances: 5, healthy: '5 / 5', ip: '10.66.2.20', status: '健康', statusClass: 'ok' },
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const containerServices = ref<ContainerWorkload[]>([])
|
||||||
|
const containerSummary = ref<ContainerServiceSummary>({ ...emptyContainerServiceSummary })
|
||||||
|
const containerLoading = ref(false)
|
||||||
|
const containerError = ref('')
|
||||||
|
|
||||||
const filteredServices = computed(() => services.value.filter((service) => service.biz === currentName.value))
|
const filteredServices = computed(() => services.value.filter((service) => service.biz === currentName.value))
|
||||||
const serviceInstances = computed(() => filteredServices.value.reduce((sum, service) => sum + service.instances, 0))
|
const serviceInstances = computed(() => filteredServices.value.reduce((sum, service) => sum + service.instances, 0))
|
||||||
|
const containerMetaText = computed(() => {
|
||||||
|
if (containerError.value) {
|
||||||
|
return containerError.value
|
||||||
|
}
|
||||||
|
const errors = containerSummary.value.errors?.length || 0
|
||||||
|
return errors > 0 ? `数据源:Kubernetes Workload API · ${errors} 项同步失败` : '数据源:Kubernetes Workload API'
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadContainerServices = async () => {
|
||||||
|
const businessLineId = businessLineStore.current?.id
|
||||||
|
if (!businessLineId) {
|
||||||
|
containerServices.value = []
|
||||||
|
containerSummary.value = { ...emptyContainerServiceSummary }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
containerLoading.value = true
|
||||||
|
containerError.value = ''
|
||||||
|
try {
|
||||||
|
const data = await containerServiceApi.listWorkloads(businessLineId)
|
||||||
|
containerServices.value = data.items
|
||||||
|
containerSummary.value = data.summary
|
||||||
|
} catch (error) {
|
||||||
|
containerServices.value = []
|
||||||
|
containerSummary.value = { ...emptyContainerServiceSummary }
|
||||||
|
containerError.value = error instanceof Error ? error.message : '容器化服务数据同步失败'
|
||||||
|
} finally {
|
||||||
|
containerLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => businessLineStore.current?.id,
|
||||||
|
() => {
|
||||||
|
loadContainerServices()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ const operatorStateText = computed(() => {
|
|||||||
})
|
})
|
||||||
const operatorStateDetail = computed(() => {
|
const operatorStateDetail = computed(() => {
|
||||||
if (!canManageBusinessLine.value) return '需要平台管理员或当前业务线管理员'
|
if (!canManageBusinessLine.value) return '需要平台管理员或当前业务线管理员'
|
||||||
if (!wayneNamespaces.value.length) return '先在业务线分配中绑定 Wayne namespace'
|
if (!wayneNamespaces.value.length) return '新增业务线会自动绑定同名 namespace,也可在业务线分配中手动绑定'
|
||||||
if (!hasWayneRoleBindingPermission.value) return '没有 Wayne 角色绑定权限'
|
if (!hasWayneRoleBindingPermission.value) return '没有 Wayne 角色绑定权限'
|
||||||
return `${wayneNamespaces.value.filter((item) => item.can_bind).length} 个 namespace 可授权`
|
return `${wayneNamespaces.value.filter((item) => item.can_bind).length} 个 namespace 可授权`
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -144,9 +144,53 @@ func (h *BusinessLineHandler) Create(c *gin.Context) {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
req.Name = strings.TrimSpace(req.Name)
|
||||||
|
if req.Name == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "business line name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing model.BusinessLine
|
||||||
|
if err := h.db.Where("name = ?", req.Name).First(&existing).Error; err == nil {
|
||||||
|
c.JSON(http.StatusConflict, gin.H{"error": "business line already exists"})
|
||||||
|
return
|
||||||
|
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.wayne == nil {
|
||||||
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "wayne native api is not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
namespace, err := h.wayne.EnsureNamespace(c.Request.Context(), req.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeWayneRoleBindingError(c, nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if namespace == nil || namespace.ID == 0 {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": "wayne namespace response is invalid"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(namespace.Name) == "" {
|
||||||
|
namespace.Name = req.Name
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(namespace.KubeNamespace) == "" {
|
||||||
|
namespace.KubeNamespace = req.Name
|
||||||
|
}
|
||||||
|
|
||||||
item := model.BusinessLine{Name: req.Name}
|
item := model.BusinessLine{Name: req.Name}
|
||||||
if err := h.db.Create(&item).Error; err != nil {
|
if err := h.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Create(&item).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&model.BusinessLineWayneNamespace{
|
||||||
|
BusinessLineID: item.ID,
|
||||||
|
WayneNamespaceID: namespace.ID,
|
||||||
|
WayneNamespaceName: namespace.Name,
|
||||||
|
KubeNamespace: namespace.KubeNamespace,
|
||||||
|
}).Error
|
||||||
|
}); err != nil {
|
||||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"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 ContainerServiceHandler struct {
|
||||||
|
db *gorm.DB
|
||||||
|
wayne *service.WayneRoleBindingService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewContainerServiceHandler(db *gorm.DB, wayne *service.WayneRoleBindingService) *ContainerServiceHandler {
|
||||||
|
return &ContainerServiceHandler{db: db, wayne: wayne}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContainerServiceHandler) Summary(c *gin.Context) {
|
||||||
|
data, ok := h.loadData(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, data.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContainerServiceHandler) Workloads(c *gin.Context) {
|
||||||
|
data, ok := h.loadData(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": data.Workloads, "summary": data.Summary})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContainerServiceHandler) loadData(c *gin.Context) (*service.ContainerServiceData, bool) {
|
||||||
|
claims, ok := CurrentClaims(c)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"})
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
businessLineID, ok := parseBusinessLineID(c)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
businessLine, ok := h.getBusinessLine(c, businessLineID)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if !h.canReadBusinessLine(c, claims, businessLineID) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
namespaces, ok := h.listBusinessLineNamespaces(c, businessLineID)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
data, err := h.wayne.ContainerServiceData(c.Request.Context(), service.ContainerBusinessLineQuery{
|
||||||
|
BusinessLineID: businessLineID,
|
||||||
|
BusinessLineName: businessLine.Name,
|
||||||
|
Namespaces: namespaces,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeWayneRoleBindingError(c, nil, err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContainerServiceHandler) getBusinessLine(c *gin.Context, id uint64) (model.BusinessLine, bool) {
|
||||||
|
var businessLine model.BusinessLine
|
||||||
|
if err := h.db.First(&businessLine, "id = ?", id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "business line not found"})
|
||||||
|
return businessLine, false
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return businessLine, false
|
||||||
|
}
|
||||||
|
return businessLine, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContainerServiceHandler) canReadBusinessLine(c *gin.Context, claims *auth.Claims, businessLineID uint64) bool {
|
||||||
|
if claims.IsAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
var binding model.BusinessLineUser
|
||||||
|
if err := h.db.Where("business_line_id = ? AND user_id = ?", businessLineID, claims.UserID).First(&binding).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "current user is not bound to current business line"})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ContainerServiceHandler) listBusinessLineNamespaces(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
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
|
|||||||
samlHandler := handler.NewSAMLHandler(deps.Config, authService)
|
samlHandler := handler.NewSAMLHandler(deps.Config, authService)
|
||||||
oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService)
|
oauthHandler := handler.NewOAuthHandler(deps.Config, deps.DB, auditService)
|
||||||
deploymentHandler := handler.NewDeploymentHandler(deps.Config, deps.DB, deploymentService)
|
deploymentHandler := handler.NewDeploymentHandler(deps.Config, deps.DB, deploymentService)
|
||||||
|
containerServiceHandler := handler.NewContainerServiceHandler(deps.DB, wayneRoleBindingService)
|
||||||
|
|
||||||
r.GET("/healthz", healthHandler.Healthz)
|
r.GET("/healthz", healthHandler.Healthz)
|
||||||
r.GET("/readyz", healthHandler.Readyz)
|
r.GET("/readyz", healthHandler.Readyz)
|
||||||
@@ -134,6 +135,8 @@ func registerAuthServerRoutes(r *gin.Engine, deps Dependencies) {
|
|||||||
protected.PUT("/subsystem-auth/wayne/business-lines/:id/namespaces/:namespaceid/users/:username/roles", subsystemAuthHandler.BindWayneNamespaceRoles)
|
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.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.POST("/subsystem-auth/wayne/business-lines/:id/users/:userid/init", subsystemAuthHandler.InitWayneBusinessLineUser)
|
||||||
|
protected.GET("/container-services/business-lines/:id/summary", containerServiceHandler.Summary)
|
||||||
|
protected.GET("/container-services/business-lines/:id/workloads", containerServiceHandler.Workloads)
|
||||||
protected.POST("/deployments", deploymentHandler.Create)
|
protected.POST("/deployments", deploymentHandler.Create)
|
||||||
protected.GET("/deployments/:id", deploymentHandler.Get)
|
protected.GET("/deployments/:id", deploymentHandler.Get)
|
||||||
protected.GET("/deployments/:id/events", deploymentHandler.Events)
|
protected.GET("/deployments/:id/events", deploymentHandler.Events)
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/1024XEngineer/xinfra/server/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ContainerServiceSummary struct {
|
||||||
|
BusinessLineID uint64 `json:"businessLineId"`
|
||||||
|
BusinessLineName string `json:"businessLineName"`
|
||||||
|
Namespaces int `json:"namespaces"`
|
||||||
|
Clusters int `json:"clusters"`
|
||||||
|
Nodes int `json:"nodes"`
|
||||||
|
ReadyNodes int `json:"readyNodes"`
|
||||||
|
SchedulableNodes int `json:"schedulableNodes"`
|
||||||
|
Workloads int `json:"workloads"`
|
||||||
|
Pods int `json:"pods"`
|
||||||
|
ReadyPods int `json:"readyPods"`
|
||||||
|
CPUUsed float64 `json:"cpuUsed"`
|
||||||
|
CPUTotal float64 `json:"cpuTotal"`
|
||||||
|
CPUPercent int `json:"cpuPercent"`
|
||||||
|
MemoryUsed float64 `json:"memoryUsed"`
|
||||||
|
MemoryTotal float64 `json:"memoryTotal"`
|
||||||
|
MemoryPercent int `json:"memoryPercent"`
|
||||||
|
Errors []string `json:"errors,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContainerWorkload struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Cluster string `json:"cluster"`
|
||||||
|
Namespace string `json:"namespace"`
|
||||||
|
Workload string `json:"workload"`
|
||||||
|
Ready string `json:"ready"`
|
||||||
|
ReadyPods int `json:"readyPods"`
|
||||||
|
Pods int `json:"pods"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
BusinessLine string `json:"biz"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
StatusClass string `json:"statusClass"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContainerServiceData struct {
|
||||||
|
Summary ContainerServiceSummary `json:"summary"`
|
||||||
|
Workloads []ContainerWorkload `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContainerBusinessLineQuery struct {
|
||||||
|
BusinessLineID uint64
|
||||||
|
BusinessLineName string
|
||||||
|
Namespaces []model.BusinessLineWayneNamespace
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) ContainerServiceData(ctx context.Context, query ContainerBusinessLineQuery) (*ContainerServiceData, error) {
|
||||||
|
summary := ContainerServiceSummary{
|
||||||
|
BusinessLineID: query.BusinessLineID,
|
||||||
|
BusinessLineName: query.BusinessLineName,
|
||||||
|
Namespaces: len(query.Namespaces),
|
||||||
|
}
|
||||||
|
workloads := make([]ContainerWorkload, 0)
|
||||||
|
clusterNodes := map[string]wayneNodeSummary{}
|
||||||
|
seenWorkloads := map[string]struct{}{}
|
||||||
|
errors := make([]string, 0)
|
||||||
|
|
||||||
|
for _, binding := range query.Namespaces {
|
||||||
|
namespaceDetail, err := s.getWayneNamespace(ctx, binding.WayneNamespaceID)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("namespace %d: %v", binding.WayneNamespaceID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kubeNamespace := strings.TrimSpace(binding.KubeNamespace)
|
||||||
|
if kubeNamespace == "" {
|
||||||
|
kubeNamespace = namespaceDetail.KubeNamespace
|
||||||
|
}
|
||||||
|
clusters := namespaceDetail.Clusters()
|
||||||
|
if len(clusters) == 0 {
|
||||||
|
errors = append(errors, fmt.Sprintf("namespace %d has no clusterMeta", binding.WayneNamespaceID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
apps, err := s.listWayneNamespaceApps(ctx, binding.WayneNamespaceID)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("namespace %d apps: %v", binding.WayneNamespaceID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(apps) == 0 {
|
||||||
|
errors = append(errors, fmt.Sprintf("namespace %d has no apps for deployment query", binding.WayneNamespaceID))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appID := apps[0].ID
|
||||||
|
for _, cluster := range clusters {
|
||||||
|
if _, ok := clusterNodes[cluster]; !ok {
|
||||||
|
nodes, err := s.getWayneNodes(ctx, cluster)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("cluster %s nodes: %v", cluster, err))
|
||||||
|
} else {
|
||||||
|
clusterNodes[cluster] = nodes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items, err := s.listWayneDeployments(ctx, appID, kubeNamespace, cluster)
|
||||||
|
if err != nil {
|
||||||
|
errors = append(errors, fmt.Sprintf("namespace %d cluster %s app %d deployments: %v", binding.WayneNamespaceID, cluster, appID, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range items {
|
||||||
|
workload := containerWorkloadFromMap(item, cluster, kubeNamespace, query.BusinessLineName)
|
||||||
|
if workload.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.Join([]string{workload.Cluster, workload.Namespace, workload.Workload, workload.Name}, "\x00")
|
||||||
|
if _, ok := seenWorkloads[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenWorkloads[key] = struct{}{}
|
||||||
|
workloads = append(workloads, workload)
|
||||||
|
summary.Pods += workload.Pods
|
||||||
|
summary.ReadyPods += workload.ReadyPods
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, nodes := range clusterNodes {
|
||||||
|
summary.Nodes += nodes.Total
|
||||||
|
summary.ReadyNodes += nodes.Ready
|
||||||
|
summary.SchedulableNodes += nodes.Schedulable
|
||||||
|
summary.CPUUsed += nodes.CPUUsed
|
||||||
|
summary.CPUTotal += nodes.CPUTotal
|
||||||
|
summary.MemoryUsed += nodes.MemoryUsed
|
||||||
|
summary.MemoryTotal += nodes.MemoryTotal
|
||||||
|
}
|
||||||
|
summary.Clusters = len(clusterNodes)
|
||||||
|
summary.Workloads = len(workloads)
|
||||||
|
summary.CPUPercent = ratioPercent(summary.CPUUsed, summary.CPUTotal)
|
||||||
|
summary.MemoryPercent = ratioPercent(summary.MemoryUsed, summary.MemoryTotal)
|
||||||
|
summary.Errors = errors
|
||||||
|
|
||||||
|
return &ContainerServiceData{Summary: summary, Workloads: workloads}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) getWayneNamespace(ctx context.Context, id uint64) (wayneNamespaceDetail, error) {
|
||||||
|
result, err := s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/namespaces/%d", id), nil)
|
||||||
|
if err != nil {
|
||||||
|
return wayneNamespaceDetail{}, err
|
||||||
|
}
|
||||||
|
return parseWayneNamespaceDetail(result.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) listWayneNamespaceApps(ctx context.Context, namespaceID uint64) ([]wayneApp, error) {
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("pageNo", "1")
|
||||||
|
values.Set("pageSize", "500")
|
||||||
|
result, err := s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/namespaces/%d/apps?%s", namespaceID, values.Encode()), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseWayneApps(result.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) getWayneNodes(ctx context.Context, cluster string) (wayneNodeSummary, error) {
|
||||||
|
result, err := s.callRaw(ctx, http.MethodGet, fmt.Sprintf("/api/v1/kubernetes/nodes/clusters/%s", url.PathEscape(cluster)), nil)
|
||||||
|
if err != nil {
|
||||||
|
return wayneNodeSummary{}, err
|
||||||
|
}
|
||||||
|
return parseWayneNodeSummary(result.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) listWayneDeployments(ctx context.Context, appID uint64, namespace string, cluster string) ([]map[string]any, error) {
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("pageNo", "1")
|
||||||
|
values.Set("pageSize", "500")
|
||||||
|
path := fmt.Sprintf(
|
||||||
|
"/api/v1/kubernetes/apps/%d/deployments/namespaces/%s/clusters/%s?%s",
|
||||||
|
appID,
|
||||||
|
url.PathEscape(namespace),
|
||||||
|
url.PathEscape(cluster),
|
||||||
|
values.Encode(),
|
||||||
|
)
|
||||||
|
result, err := s.callRaw(ctx, http.MethodGet, path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseWayneMapList(result.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
type wayneNamespaceDetail struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KubeNamespace string `json:"kubeNamespace"`
|
||||||
|
MetaData string `json:"metaData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n wayneNamespaceDetail) Clusters() []string {
|
||||||
|
var meta struct {
|
||||||
|
ClusterMeta map[string]json.RawMessage `json:"clusterMeta"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(n.MetaData), &meta); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clusters := make([]string, 0, len(meta.ClusterMeta))
|
||||||
|
for cluster := range meta.ClusterMeta {
|
||||||
|
if strings.TrimSpace(cluster) != "" {
|
||||||
|
clusters = append(clusters, cluster)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clusters
|
||||||
|
}
|
||||||
|
|
||||||
|
type wayneApp struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wayneNodeSummary struct {
|
||||||
|
Total int
|
||||||
|
Ready int
|
||||||
|
Schedulable int
|
||||||
|
CPUUsed float64
|
||||||
|
CPUTotal float64
|
||||||
|
MemoryUsed float64
|
||||||
|
MemoryTotal float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWayneNamespaceDetail(body []byte) (wayneNamespaceDetail, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data wayneNamespaceDetail `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return wayneNamespaceDetail{}, err
|
||||||
|
}
|
||||||
|
return wrapped.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWayneApps(body []byte) ([]wayneApp, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data struct {
|
||||||
|
List []wayneApp `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return wrapped.Data.List, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWayneMapList(body []byte) ([]map[string]any, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data struct {
|
||||||
|
List []map[string]any `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return wrapped.Data.List, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWayneNodeSummary(body []byte) (wayneNodeSummary, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data struct {
|
||||||
|
NodeSummary struct {
|
||||||
|
Total int `json:"total"`
|
||||||
|
Ready int `json:"ready"`
|
||||||
|
Schedulable int `json:"schedulable"`
|
||||||
|
} `json:"nodeSummary"`
|
||||||
|
CPUSummary struct {
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
Used float64 `json:"used"`
|
||||||
|
} `json:"cpuSummary"`
|
||||||
|
MemorySummary struct {
|
||||||
|
Total float64 `json:"total"`
|
||||||
|
Used float64 `json:"used"`
|
||||||
|
} `json:"memorySummary"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return wayneNodeSummary{}, err
|
||||||
|
}
|
||||||
|
return wayneNodeSummary{
|
||||||
|
Total: wrapped.Data.NodeSummary.Total,
|
||||||
|
Ready: wrapped.Data.NodeSummary.Ready,
|
||||||
|
Schedulable: wrapped.Data.NodeSummary.Schedulable,
|
||||||
|
CPUUsed: wrapped.Data.CPUSummary.Used,
|
||||||
|
CPUTotal: wrapped.Data.CPUSummary.Total,
|
||||||
|
MemoryUsed: wrapped.Data.MemorySummary.Used,
|
||||||
|
MemoryTotal: wrapped.Data.MemorySummary.Total,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerWorkloadFromMap(item map[string]any, cluster string, namespace string, businessLineName string) ContainerWorkload {
|
||||||
|
name := firstString(item, "name", "objectMeta.name", "metadata.name")
|
||||||
|
ready := firstInt(item, "pods.current", "readyReplicas", "status.readyReplicas", "availableReplicas", "status.availableReplicas")
|
||||||
|
total := firstInt(item, "pods.desired", "replicas", "status.replicas", "desiredReplicas", "spec.replicas")
|
||||||
|
if total == 0 && ready > 0 {
|
||||||
|
total = ready
|
||||||
|
}
|
||||||
|
status := "健康"
|
||||||
|
statusClass := "ok"
|
||||||
|
if total == 0 {
|
||||||
|
status = "未就绪"
|
||||||
|
statusClass = "warn"
|
||||||
|
} else if ready < total {
|
||||||
|
status = "部分异常"
|
||||||
|
statusClass = "warn"
|
||||||
|
}
|
||||||
|
return ContainerWorkload{
|
||||||
|
Name: name,
|
||||||
|
Cluster: cluster,
|
||||||
|
Namespace: namespace,
|
||||||
|
Workload: "Deployment",
|
||||||
|
Ready: strconv.Itoa(ready) + " / " + strconv.Itoa(total),
|
||||||
|
ReadyPods: ready,
|
||||||
|
Pods: total,
|
||||||
|
Image: firstImage(item),
|
||||||
|
BusinessLine: businessLineName,
|
||||||
|
Status: status,
|
||||||
|
StatusClass: statusClass,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstImage(item map[string]any) string {
|
||||||
|
if value := firstString(item, "image", "containerImage"); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
for _, path := range []string{"containers", "images", "containerImages"} {
|
||||||
|
value, ok := nestedValue(item, path)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if images, ok := value.([]any); ok && len(images) > 0 {
|
||||||
|
if image, ok := images[0].(string); ok {
|
||||||
|
return image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if containers, ok := nestedValue(item, "spec.template.spec.containers"); ok {
|
||||||
|
if rows, ok := containers.([]any); ok && len(rows) > 0 {
|
||||||
|
if row, ok := rows[0].(map[string]any); ok {
|
||||||
|
return stringFromAny(row["image"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstString(item map[string]any, paths ...string) string {
|
||||||
|
for _, path := range paths {
|
||||||
|
value, ok := nestedValue(item, path)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if text := strings.TrimSpace(stringFromAny(value)); text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstInt(item map[string]any, paths ...string) int {
|
||||||
|
for _, path := range paths {
|
||||||
|
value, ok := nestedValue(item, path)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(typed)
|
||||||
|
case int:
|
||||||
|
return typed
|
||||||
|
case json.Number:
|
||||||
|
intValue, _ := typed.Int64()
|
||||||
|
return int(intValue)
|
||||||
|
case string:
|
||||||
|
intValue, _ := strconv.Atoi(strings.TrimSpace(typed))
|
||||||
|
return intValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func nestedValue(item map[string]any, path string) (any, bool) {
|
||||||
|
current := any(item)
|
||||||
|
for _, part := range strings.Split(path, ".") {
|
||||||
|
row, ok := current.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
current, ok = row[part]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringFromAny(value any) string {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case string:
|
||||||
|
return typed
|
||||||
|
case fmt.Stringer:
|
||||||
|
return typed.String()
|
||||||
|
default:
|
||||||
|
if value == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ratioPercent(used, total float64) int {
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int(used * 100 / total)
|
||||||
|
}
|
||||||
@@ -53,6 +53,13 @@ type WayneRoleGroup struct {
|
|||||||
Type int `json:"type"`
|
Type int `json:"type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WayneNamespace struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
KubeNamespace string `json:"kubeNamespace"`
|
||||||
|
MetaData string `json:"metaData,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type WayneOperatorPermissions struct {
|
type WayneOperatorPermissions struct {
|
||||||
Create bool `json:"create"`
|
Create bool `json:"create"`
|
||||||
Update bool `json:"update"`
|
Update bool `json:"update"`
|
||||||
@@ -115,6 +122,77 @@ func (s *WayneRoleBindingService) ListNamespaces(ctx context.Context) (*WayneRol
|
|||||||
return s.callRaw(ctx, http.MethodGet, "/api/v1/namespaces", nil)
|
return s.callRaw(ctx, http.MethodGet, "/api/v1/namespaces", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) EnsureNamespace(ctx context.Context, name string) (*WayneNamespace, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("wayne namespace name is required")
|
||||||
|
}
|
||||||
|
namespace, err := s.FindNamespaceByName(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if namespace != nil {
|
||||||
|
return namespace, nil
|
||||||
|
}
|
||||||
|
return s.CreateNamespace(ctx, WayneNamespace{
|
||||||
|
Name: name,
|
||||||
|
KubeNamespace: name,
|
||||||
|
MetaData: "{}",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) FindNamespaceByName(ctx context.Context, name string) (*WayneNamespace, error) {
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("wayne namespace name is required")
|
||||||
|
}
|
||||||
|
values := url.Values{}
|
||||||
|
values.Set("name", name)
|
||||||
|
values.Set("pageNo", "1")
|
||||||
|
values.Set("pageSize", "500")
|
||||||
|
result, err := s.callRaw(ctx, http.MethodGet, "/api/v1/namespaces?"+values.Encode(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
namespaces, err := parseWayneNamespaces(result.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, namespace := range namespaces {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(namespace.Name), name) {
|
||||||
|
return &namespace, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WayneRoleBindingService) CreateNamespace(ctx context.Context, namespace WayneNamespace) (*WayneNamespace, error) {
|
||||||
|
namespace.Name = strings.TrimSpace(namespace.Name)
|
||||||
|
namespace.KubeNamespace = strings.TrimSpace(namespace.KubeNamespace)
|
||||||
|
if namespace.Name == "" {
|
||||||
|
return nil, fmt.Errorf("wayne namespace name is required")
|
||||||
|
}
|
||||||
|
if namespace.KubeNamespace == "" {
|
||||||
|
namespace.KubeNamespace = namespace.Name
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(namespace.MetaData) == "" {
|
||||||
|
namespace.MetaData = "{}"
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(namespace)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := s.callRaw(ctx, http.MethodPost, "/api/v1/namespaces", body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
created, err := parseWayneNamespace(result.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &created, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) {
|
func (s *WayneRoleBindingService) ListGroups(ctx context.Context, groupType *int) (*WayneRoleBindingResult, error) {
|
||||||
path := "/api/v1/groups"
|
path := "/api/v1/groups"
|
||||||
if groupType != nil {
|
if groupType != nil {
|
||||||
@@ -615,6 +693,28 @@ func parseWayneResourceIDs(body []byte) ([]uint64, error) {
|
|||||||
return resourceIDsFromItems(wrapped.Data.List), nil
|
return resourceIDsFromItems(wrapped.Data.List), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseWayneNamespace(body []byte) (WayneNamespace, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data WayneNamespace `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return WayneNamespace{}, err
|
||||||
|
}
|
||||||
|
return wrapped.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseWayneNamespaces(body []byte) ([]WayneNamespace, error) {
|
||||||
|
var wrapped struct {
|
||||||
|
Data struct {
|
||||||
|
List []WayneNamespace `json:"list"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrapped); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return wrapped.Data.List, nil
|
||||||
|
}
|
||||||
|
|
||||||
func resourceIDsFromItems(items []struct {
|
func resourceIDsFromItems(items []struct {
|
||||||
ID uint64 `json:"id"`
|
ID uint64 `json:"id"`
|
||||||
}) []uint64 {
|
}) []uint64 {
|
||||||
|
|||||||
@@ -81,6 +81,75 @@ func TestParseWayneUsersSupportsNativePageList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWayneRoleBindingServiceEnsureNamespaceUsesExisting(t *testing.T) {
|
||||||
|
var got []string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
got = append(got, r.Method+" "+r.URL.RequestURI())
|
||||||
|
switch r.URL.RequestURI() {
|
||||||
|
case "/login/db":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
|
||||||
|
case "/api/v1/namespaces?name=kodo&pageNo=1&pageSize=500":
|
||||||
|
assertBearer(t, r)
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"list":[{"id":12,"name":"kodo","kubeNamespace":"kodo-prod"}]}}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
|
||||||
|
namespace, err := svc.EnsureNamespace(context.Background(), "kodo")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureNamespace() error = %v", err)
|
||||||
|
}
|
||||||
|
if namespace.ID != 12 || namespace.Name != "kodo" || namespace.KubeNamespace != "kodo-prod" {
|
||||||
|
t.Fatalf("namespace = %#v", namespace)
|
||||||
|
}
|
||||||
|
if strings.Join(got, "\n") != strings.Join([]string{
|
||||||
|
"POST /login/db",
|
||||||
|
"GET /api/v1/namespaces?name=kodo&pageNo=1&pageSize=500",
|
||||||
|
}, "\n") {
|
||||||
|
t.Fatalf("requests:\n%s", strings.Join(got, "\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWayneRoleBindingServiceEnsureNamespaceCreatesMissing(t *testing.T) {
|
||||||
|
var createBody WayneNamespace
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.RequestURI() {
|
||||||
|
case "/login/db":
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"token":"` + testJWT(time.Now().Add(time.Hour)) + `"}}`))
|
||||||
|
case "/api/v1/namespaces?name=las&pageNo=1&pageSize=500":
|
||||||
|
assertBearer(t, r)
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"list":[]}}`))
|
||||||
|
case "/api/v1/namespaces":
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Fatalf("method = %s, want POST", r.Method)
|
||||||
|
}
|
||||||
|
assertBearer(t, r)
|
||||||
|
if err := json.Unmarshal(readTestBody(t, r), &createBody); err != nil {
|
||||||
|
t.Fatalf("invalid create body: %v", err)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"data":{"id":21,"name":"las","kubeNamespace":"las"}}`))
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected request %s %s", r.Method, r.URL.RequestURI())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
svc := NewWayneRoleBindingService(testWayneConfig(server.URL), nil)
|
||||||
|
namespace, err := svc.EnsureNamespace(context.Background(), "las")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EnsureNamespace() error = %v", err)
|
||||||
|
}
|
||||||
|
if namespace.ID != 21 || namespace.Name != "las" || namespace.KubeNamespace != "las" {
|
||||||
|
t.Fatalf("namespace = %#v", namespace)
|
||||||
|
}
|
||||||
|
if createBody.Name != "las" || createBody.KubeNamespace != "las" || createBody.MetaData != "{}" {
|
||||||
|
t.Fatalf("create body = %#v", createBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWayneRoleBindingServiceCreateAndDeleteNativeBindings(t *testing.T) {
|
func TestWayneRoleBindingServiceCreateAndDeleteNativeBindings(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Reference in New Issue
Block a user