feat(alert): A2 告警引擎分布式安全 — Redis 分布式锁(SetNX)

- Cache 接口新增 SetNX 方法(go-redis 原子 / redka check-then-set 单副本安全)
- AlertEngine 改造为支持分布式锁: tryAcquireLock/releaseLock/GetEngineStatus
- 多副本部署时仅 Leader 执行告警检查, 60s TTL 防死锁, fail-open 容错
- 新增 GET /alerts/engine/status 监控端点(is_leader/instance_id/lock_holder)
- 修复 GrafanaDashboard.tsx Panel.options 类型不匹配导致构建中断
- 同步未提交的前端类型安全迁移(140+ 文件)
- ROADMAP/TDD_PLAN/PROJECT_EVALUATION 三文档同步更新

验证: go build 零错误, Docker 部署 healthy, 引擎状态端点正常,
锁竞争测试通过(非 Leader 跳过检查), 5/5 核心 API 回归通过
This commit is contained in:
attacker committed 2026-06-26 21:13:15 +08:00
1 parent 5ca0e6d83a
commit c68aef4444
150 files changed
+1037 -689

No files matched your search

+2 -2
View File
@@ -137,7 +137,7 @@
| 前端 React Hooks exhaustive-deps 缺失 | 38 处 | P1 | | 前端 React Hooks exhaustive-deps 缺失 | 38 处 | P1 |
| 后端 `_ =` 忽略错误 | 13 处(3 处中风险) | P2 | | 后端 `_ =` 忽略错误 | 13 处(3 处中风险) | P2 |
| 前端硬编码内网 IP(10.10.10.75 等) | 6+ 处 | P2 | | 前端硬编码内网 IP(10.10.10.75 等) | 6+ 处 | P2 |
| 后端告警引擎单实例 + 无优雅退出 | 多副本会重复告警 | P2 | | 后端告警引擎分布式安全 | ✅ Redis 分布式锁(SetNX + 60s TTL) | 后端 | ~~P2~~ done |
### 3.3 🟢 低优先级 ### 3.3 🟢 低优先级
@@ -202,6 +202,6 @@
| 功能完整性 | **88/100** | 8 大模块齐全,K8s 管理极为完善;扣分项:监控大屏/链路追踪/告警静默未对接 | | 功能完整性 | **88/100** | 8 大模块齐全,K8s 管理极为完善;扣分项:监控大屏/链路追踪/告警静默未对接 |
| 代码质量 | **72/100** | 后端规范好(0 panic, 1 TODO);前端 1026 处 any + 218 处未用变量是主要扣分项 | | 代码质量 | **72/100** | 后端规范好(0 panic, 1 TODO);前端 1026 处 any + 218 处未用变量是主要扣分项 |
| 架构设计 | **82/100** | 前后端分离清晰,自研轻量 K8s 客户端;扣分项:kubernetes.go/api.ts 巨型文件 | | 架构设计 | **82/100** | 前后端分离清晰,自研轻量 K8s 客户端;扣分项:kubernetes.go/api.ts 巨型文件 |
| 部署运维 | **85/100** | 三种部署方式 + run.sh 统一入口;扣分项:后端镜像偏大,告警引擎单实例 | | 部署运维 | **87/100** | 三种部署方式 + run.sh 统一入口;扣分项:后端镜像偏大 |
| 用户体验 | **86/100** | 骨架屏 + 页面过渡 + 5 套主题 + 懒加载;扣分项:无 i18n,无虚拟滚动 | | 用户体验 | **86/100** | 骨架屏 + 页面过渡 + 5 套主题 + 懒加载;扣分项:无 i18n,无虚拟滚动 |
| **综合** | **83/100** | 企业级运维平台,功能覆盖全面,工程治理是当前首要提升方向 | | **综合** | **83/100** | 企业级运维平台,功能覆盖全面,工程治理是当前首要提升方向 |
+11 -3
View File
@@ -162,11 +162,19 @@ services/api.ts (1324L)
**方案**: 以 Kuboard code 路由为主,ID 路由保留向后兼容(标记 deprecated) **方案**: 以 Kuboard code 路由为主,ID 路由保留向后兼容(标记 deprecated)
### A2: 告警引擎分布式安全 [pending] ### A2: 告警引擎分布式安全 [done]
**问题**: 告警引擎用 goroutine + 30s ticker 常驻,多副本会重复告警 **问题**: 告警引擎用 goroutine + 30s ticker 常驻,多副本会重复告警
**方案**: Redis 分布式锁(每轮 ticker 先抢锁),或选主机制 **方案**: Redis 分布式锁(SetNX 原子抢锁),每轮 ticker 先抢锁,60s TTL 防死锁。单实例/redka 模式自动 fallback 为无锁直行。
**验收标准**:
- [x] Cache 接口新增 `SetNX` 方法(go-redis 原子 / redka check-then-set 单副本安全)
- [x] 多副本部署时仅 Leader 副本执行告警检查(SetNX 分布式锁 + 实例 ID 校验)
- [x] Leader 崩溃后锁自动过期(60s TTL),其他副本自动接管
- [x] Redis 故障时 fail-open(不阻断告警)
- [x] `GET /alerts/engine/status` 状态监控端点(is_leader / instance_id / lock_holder)
- [x] 本地/redka 模式(cache=nil)自动 fallback 为单实例直行
### A3: WebSocket 连接统一管理 [pending] ### A3: WebSocket 连接统一管理 [pending]
@@ -207,7 +215,7 @@ services/api.ts (1324L)
| F2 | 审计日志导出 | P1 | **done** | | F2 | 审计日志导出 | P1 | **done** |
| F3 | 前端 RBAC 控制 | P1 | **done** | | F3 | 前端 RBAC 控制 | P1 | **done** |
| A1 | K8s 路由统一 | P2 | pending | | A1 | K8s 路由统一 | P2 | pending |
| A2 | 告警引擎分布式 | P2 | pending | | A2 | 告警引擎分布式 | P2 | **done** |
| A3 | WebSocket 管理 | P2 | pending | | A3 | WebSocket 管理 | P2 | pending |
| U1 | 虚拟滚动 | P3 | pending | | U1 | 虚拟滚动 | P3 | pending |
| U2 | 统一 DataTable | P3 | pending | | U2 | 统一 DataTable | P3 | pending |
+46
View File
@@ -340,6 +340,52 @@ curl -s -X POST "http://127.0.0.1:8080/api/v1/servers" \
--- ---
## P2-A2: 告警引擎分布式安全
### 验证用例
```bash
TOKEN=$(python3 -c "
import urllib.request, json
login_data = json.dumps({'username': 'admin', 'password': '123456'}).encode()
req = urllib.request.Request('http://127.0.0.1:8080/api/v1/auth/login', data=login_data, method='POST')
req.add_header('Content-Type', 'application/json')
resp = urllib.request.urlopen(req)
print(json.loads(resp.read())['data']['token'])
")
# U1: 引擎状态端点
curl -s http://127.0.0.1:8080/api/v1/alerts/engine/status \
-H "Authorization: Bearer $TOKEN" | python3 -m json.tool
# 期望: code=200, distributed=true, is_leader=true, instance_id 非空
# U2: 模拟其他副本持有锁 — 引擎应识别为非 Leader
docker exec aiops-redis redis-cli SET aiops:alert_engine:lock "ae-OTHER" EX 40
curl -s http://127.0.0.1:8080/api/v1/alerts/engine/status \
-H "Authorization: Bearer $TOKEN" | python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(f'is_leader={d[\"is_leader\"]} lock_holder={d[\"lock_holder\"]}')"
# 期望: is_leader=False, lock_holder=ae-OTHER
# U3: 等待 35s,验证引擎跳过检查(日志输出)
sleep 35
docker logs aiops 2>&1 | grep "AlertEngine.*非 Leader" | tail -1
# 期望: [AlertEngine] 非 Leader 副本(xxx),跳过本轮检查
# U4: 释放锁,引擎恢复 Leader
docker exec aiops-redis redis-cli DEL aiops:alert_engine:lock
curl -s http://127.0.0.1:8080/api/v1/alerts/engine/status \
-H "Authorization: Bearer $TOKEN" | python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(f'is_leader={d[\"is_leader\"]}')"
# 期望: is_leader=True
```
### 通过标准
- [x] U1: 引擎状态端点返回 distributed/is_leader/instance_id/lock_ttl_sec
- [x] U2: 其他副本持锁时正确识别为非 Leader
- [x] U3: 日志确认引擎跳过检查(非 Leader 副本不执行告警扫描)
- [x] U4: 锁释放后恢复 Leader 角色
- [x] `go build` 零错误,5/5 核心 API 回归通过
---
## 回归测试基线(每次改动后必跑) ## 回归测试基线(每次改动后必跑)
```bash ```bash
+4
View File
@@ -0,0 +1,4 @@
--mode
local
--port
8080
+89 -4
View File
@@ -1,32 +1,117 @@
package controller package controller
import ( import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"time" "time"
"ezops/model" "ezops/model"
ezredis "ezops/pkg/redis"
"gorm.io/gorm" "gorm.io/gorm"
) )
// AlertEngine 告警引擎 — 定时扫描 Agent 上报数据并触发告警 // Logan.Li // AlertEngine 告警引擎 — 定时扫描 Agent 上报数据并触发告警
// 支持分布式锁:多副本部署时仅 Leader 执行检查,避免重复告警
type AlertEngine struct { type AlertEngine struct {
db *gorm.DB db *gorm.DB
cache ezredis.Cache // 可选 Redis 缓存(分布式锁)
instanceID string // 当前实例唯一标识
} }
func NewAlertEngine(db *gorm.DB) *AlertEngine { const (
return &AlertEngine{db: db} alertEngineLockKey = "aiops:alert_engine:lock"
alertEngineLockTTL = 60 * time.Second // 锁 TTL:2x tick 间隔,保证处理时间内不释放
)
// NewAlertEngine 创建告警引擎实例
// cache 参数用于分布式锁(多副本部署)。nil 时为单实例模式,不竞争锁。
func NewAlertEngine(db *gorm.DB, cache ezredis.Cache) *AlertEngine {
// 生成唯一实例 ID(随机 hex,防止多副本 PID/hostname 碰撞)
idBytes := make([]byte, 8)
_, _ = rand.Read(idBytes)
instanceID := fmt.Sprintf("ae-%s", hex.EncodeToString(idBytes))
return &AlertEngine{
db: db,
cache: cache,
instanceID: instanceID,
}
} }
// Check 每 30 秒调用一次 // Check 每 30 秒调用一次。多副本模式下通过分布式锁保证仅 Leader 执行。
// 无 cache(单实例/缓存初始化失败)→ 直接执行
// 有 cache(Docker/K8s 多副本)→ SetNX 原子抢锁
// Redis 故障 → fail-open(继续执行,避免告警遗漏)
func (e *AlertEngine) Check() { func (e *AlertEngine) Check() {
if !e.tryAcquireLock() {
log.Printf("[AlertEngine] 非 Leader 副本(%s),跳过本轮检查", e.instanceID)
return
}
defer e.releaseLock()
e.checkResourceAlerts() e.checkResourceAlerts()
e.checkOfflineAlerts() e.checkOfflineAlerts()
e.checkPortAlerts() e.checkPortAlerts()
e.autoResolveAlerts() e.autoResolveAlerts()
} }
// tryAcquireLock 尝试获取分布式锁,返回是否获得 Leader 角色
func (e *AlertEngine) tryAcquireLock() bool {
if e.cache == nil {
return true // 单实例模式
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
acquired, err := e.cache.SetNX(ctx, alertEngineLockKey, e.instanceID, alertEngineLockTTL)
if err != nil {
log.Printf("[AlertEngine] 获取分布式锁失败(%s),fail-open 继续: %v", e.instanceID, err)
return true
}
return acquired
}
// releaseLock 释放分布式锁(仅当当前实例持有锁时才释放,防止误删其他副本的锁)
func (e *AlertEngine) releaseLock() {
if e.cache == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
val, err := e.cache.Get(ctx, alertEngineLockKey)
if err != nil {
return
}
if val == e.instanceID {
_ = e.cache.Del(ctx, alertEngineLockKey)
}
}
// GetEngineStatus 返回告警引擎运行状态(用于运维监控)
func (e *AlertEngine) GetEngineStatus() map[string]interface{} {
isLeader := true
lockHolder := ""
if e.cache != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if val, err := e.cache.Get(ctx, alertEngineLockKey); err == nil {
lockHolder = val
isLeader = lockHolder == "" || lockHolder == e.instanceID
}
}
return map[string]interface{}{
"is_leader": isLeader,
"instance_id": e.instanceID,
"lock_holder": lockHolder,
"distributed": e.cache != nil,
"tick_interval": 30,
"lock_ttl_sec": int(alertEngineLockTTL / time.Second),
}
}
// 检查 CPU/内存/磁盘/进程/TCP连接/端口 阈值 // 检查 CPU/内存/磁盘/进程/TCP连接/端口 阈值
func (e *AlertEngine) checkResourceAlerts() { func (e *AlertEngine) checkResourceAlerts() {
var rules []model.AgentRule var rules []model.AgentRule
+5
View File
@@ -49,6 +49,11 @@ func (r *goRedisCache) Set(ctx context.Context, key string, value string, expira
return r.client.Set(ctx, key, value, expiration).Err() return r.client.Set(ctx, key, value, expiration).Err()
} }
// SetNX 原子 SET if Not eXists — go-redis 原生支持,用于分布式锁
func (r *goRedisCache) SetNX(ctx context.Context, key string, value string, expiration time.Duration) (bool, error) {
return r.client.SetNX(ctx, key, value, expiration).Result()
}
func (r *goRedisCache) Del(ctx context.Context, keys ...string) error { func (r *goRedisCache) Del(ctx context.Context, keys ...string) error {
return r.client.Del(ctx, keys...).Err() return r.client.Del(ctx, keys...).Err()
} }
+2
View File
@@ -51,6 +51,8 @@ type Cache interface {
Capabilities() Capabilities Capabilities() Capabilities
Get(ctx context.Context, key string) (string, error) Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value string, expiration time.Duration) error Set(ctx context.Context, key string, value string, expiration time.Duration) error
// SetNX 原子「Set if Not eXists」— 用于分布式锁(多副本 Leader 选举)
SetNX(ctx context.Context, key string, value string, expiration time.Duration) (bool, error)
Del(ctx context.Context, keys ...string) error Del(ctx context.Context, keys ...string) error
Exists(ctx context.Context, keys ...string) (int64, error) Exists(ctx context.Context, keys ...string) (int64, error)
Expire(ctx context.Context, key string, expiration time.Duration) error Expire(ctx context.Context, key string, expiration time.Duration) error
+21
View File
@@ -65,6 +65,27 @@ func (r *redkaCache) Set(ctx context.Context, key string, value string, expirati
return nil return nil
} }
// SetNX check-then-set — Redka v0.2.0 无原子 SetNX,使用 Exists+Set 模拟。
// 本地/redka 模式为单副本运行,不存在并发竞争,非原子实现安全。
func (r *redkaCache) SetNX(ctx context.Context, key string, value string, expiration time.Duration) (bool, error) {
ok, err := r.db.Key().Exists(key)
if err != nil {
return false, err
}
if ok {
return false, nil
}
if err := r.db.Str().Set(key, value); err != nil {
return false, err
}
if expiration > 0 {
if _, err := r.db.Key().Expire(key, expiration); err != nil {
return false, err
}
}
return true, nil
}
func (r *redkaCache) Del(ctx context.Context, keys ...string) error { func (r *redkaCache) Del(ctx context.Context, keys ...string) error {
for _, key := range keys { for _, key := range keys {
if _, err := r.db.Key().Delete(key); err != nil { if _, err := r.db.Key().Delete(key); err != nil {
+8 -2
View File
@@ -486,6 +486,9 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
kubernetesRouter.GET("/compare", k8sHandler.GetMultiClusterComparison) kubernetesRouter.GET("/compare", k8sHandler.GetMultiClusterComparison)
} }
// 告警引擎(分布式锁 — 多副本部署时仅 Leader 执行检查)
alertEngine := controller.NewAlertEngine(db, redisCache)
// 告警路由 // 告警路由
alertHandler := controller.NewAlertHandler(db) alertHandler := controller.NewAlertHandler(db)
alerts := authorized.Group("/alerts") alerts := authorized.Group("/alerts")
@@ -522,6 +525,10 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
alerts.PUT("/silences/:id", alertHandler.UpdateSilence) alerts.PUT("/silences/:id", alertHandler.UpdateSilence)
alerts.DELETE("/silences/:id", alertHandler.DeleteSilence) alerts.DELETE("/silences/:id", alertHandler.DeleteSilence)
alerts.POST("/silences/:id/toggle", alertHandler.ToggleSilence) alerts.POST("/silences/:id/toggle", alertHandler.ToggleSilence)
// 告警引擎状态 — 查看当前 Leader 副本和分布式锁状态
alerts.GET("/engine/status", func(c *gin.Context) {
c.JSON(200, model.Response{Code: 200, Data: alertEngine.GetEngineStatus()})
})
} }
// 流水线路由 // 流水线路由
@@ -919,8 +926,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c
siteGroup.GET("/stats", siteHandler.GetSiteStats) siteGroup.GET("/stats", siteHandler.GetSiteStats)
} }
// 启动告警引擎(每 30 秒检查一次) // 启动告警引擎(每 30 秒检查一次;alertEngine 在告警路由上方创建)
alertEngine := controller.NewAlertEngine(db)
go func() { go func() {
ticker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop() defer ticker.Stop()
+29
View File
@@ -0,0 +1,29 @@
mode: "local"
port: "8080"
log_level: "info"
database:
driver: "sqlite"
host: "localhost"
port: 3306
user: ""
password: ""
dbname: "sqlite3.db"
charset: "utf8mb4"
max_conn: 100
max_idle: 10
redis:
host: "localhost"
port: 6379
driver: "redka"
password: ""
db: 0
jwt:
secret: "aiops-local-dev-secret-change-in-production"
expire_hour: 24
websocket:
read_buffer_size: 1024
write_buffer_size: 1024
@@ -27,18 +27,19 @@ import {
severityBadge, severityBadge,
} from './agentAlertHelper'; } from './agentAlertHelper';
import { AgentRuleDialog } from './AgentRuleDialog'; import { AgentRuleDialog } from './AgentRuleDialog';
import type { JsonData } from '@/types/common';
export function AgentAlerts() { export function AgentAlerts() {
const confirm = useConfirm(); const confirm = useConfirm();
const [activeTab, setActiveTab] = useState('events'); const [activeTab, setActiveTab] = useState('events');
const [alerts, setAlerts] = useState<any[]>([]); const [alerts, setAlerts] = useState<JsonData[]>([]);
const [rules, setRules] = useState<any[]>([]); const [rules, setRules] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [resolvedView, setResolvedView] = useState(false); const [resolvedView, setResolvedView] = useState(false);
// 规则编辑弹窗 // 规则编辑弹窗
const [ruleOpen, setRuleOpen] = useState(false); const [ruleOpen, setRuleOpen] = useState(false);
const [editingRule, setEditingRule] = useState<any>(null); const [editingRule, setEditingRule] = useState<JsonData>(null);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -80,7 +81,7 @@ export function AgentAlerts() {
setRuleOpen(true); setRuleOpen(true);
}; };
const openEditRule = ( rule: any) => { const openEditRule = (rule: JsonData) => {
setEditingRule(rule); setEditingRule(rule);
setRuleOpen(true); setRuleOpen(true);
}; };
@@ -97,7 +98,7 @@ export function AgentAlerts() {
} }
}; };
const handleToggleRule = async ( rule: any) => { const handleToggleRule = async (rule: JsonData) => {
try { try {
await agentApi.updateRule(rule.id, { ...rule, enabled: !rule.enabled }); await agentApi.updateRule(rule.id, { ...rule, enabled: !rule.enabled });
loadData(); loadData();
@@ -23,11 +23,12 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { METRIC_OPTIONS, OPERATOR_OPTIONS, SEVERITY_OPTIONS } from './agentAlertHelper'; import { METRIC_OPTIONS, OPERATOR_OPTIONS, SEVERITY_OPTIONS } from './agentAlertHelper';
import type { JsonData } from '@/types/common';
interface Props { interface Props {
open: boolean; open: boolean;
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
editingRule: any; editingRule?: JsonData;
onSuccess: () => void; onSuccess: () => void;
} }
@@ -29,17 +29,18 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
import type { JsonData } from '@/types/common';
export function HostDetailDialog({ export function HostDetailDialog({
host, host,
onClose, onClose,
onCommand, onCommand,
}: { }: {
host: any; host?: JsonData;
onClose: () => void; onClose: () => void;
onCommand: (hostname: string) => void; onCommand: (hostname: string) => void;
}) { }) {
const [history, setHistory] = useState<any[]>([]); const [history, setHistory] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hours, setHours] = useState(1); const [hours, setHours] = useState(1);
@@ -60,7 +61,7 @@ export function HostDetailDialog({
if (!host) return null; if (!host) return null;
const online = isOnline(host.last_report || host.last_reported_at || host.updated_at); const online = isOnline(host.last_report || host.last_reported_at || host.updated_at);
const ports: Record<string, any>[] = Array.isArray(host.ports) ? host.ports : []; const ports: JsonData[] = Array.isArray(host.ports) ? host.ports : [];
const chartData = history.map((h) => ({ const chartData = history.map((h) => ({
time: new Date(h.timestamp || h.time || h.created_at || Date.now()).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }), time: new Date(h.timestamp || h.time || h.created_at || Date.now()).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
@@ -1,6 +1,7 @@
// Agent 告警常量与辅助函数(从 AgentAlerts.tsx 提取) // Logan.Li // Agent 告警常量与辅助函数(从 AgentAlerts.tsx 提取) // Logan.Li
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { AlertTriangle, Cpu, MemoryStick, HardDrive, WifiOff, Network } from 'lucide-react'; import { AlertTriangle, Cpu, MemoryStick, HardDrive, WifiOff, Network } from 'lucide-react';
import type { JsonData } from '@/types/common';
// 监控指标选项(含图标,告警事件/规则/编辑弹窗共用) // Logan.Li // 监控指标选项(含图标,告警事件/规则/编辑弹窗共用) // Logan.Li
export const METRIC_OPTIONS = [ export const METRIC_OPTIONS = [
@@ -33,7 +34,7 @@ export function getMetricLabel(metric: string) {
} }
// 兼容多种列表响应格式(数组 / {list} / {items} / {data}) // Logan.Li // 兼容多种列表响应格式(数组 / {list} / {items} / {data}) // Logan.Li
export function asList( data: any): unknown[] { export function asList(data: JsonData): unknown[] {
if (!data) return []; if (!data) return [];
if (Array.isArray(data)) return data; if (Array.isArray(data)) return data;
if (Array.isArray(data.list)) return data.list; if (Array.isArray(data.list)) return data.list;
@@ -25,6 +25,7 @@ import {
} from './analyzerHelpers'; } from './analyzerHelpers';
import { AIAnalysisResult } from './AIAnalysisResult'; import { AIAnalysisResult } from './AIAnalysisResult';
import { AutoFixDialog } from './AutoFixDialog'; import { AutoFixDialog } from './AutoFixDialog';
import type { JsonData } from '@/types/common';
// Parse LLM JSON response (may have markdown fences) into AIAnalysis // Parse LLM JSON response (may have markdown fences) into AIAnalysis
function parseAIResponse(raw: string, fallback: AIAnalysis): AIAnalysis { function parseAIResponse(raw: string, fallback: AIAnalysis): AIAnalysis {
@@ -61,7 +62,7 @@ export function AIAlertAnalyzer() {
const loadAlerts = async () => { const loadAlerts = async () => {
setLoading(true); setLoading(true);
try { try {
const res: Record<string, any> = await alertApi.getAlerts({ page: 1, page_size: 50 }); const res: JsonData = await alertApi.getAlerts({ page: 1, page_size: 50 });
// 列表响应格式:http.ts 已提取 .data,此处仍是 {list, total} // 列表响应格式:http.ts 已提取 .data,此处仍是 {list, total}
const list = res?.list || res || []; const list = res?.list || res || [];
setAlerts(list.map(mapAlertEvent)); setAlerts(list.map(mapAlertEvent));
@@ -102,7 +103,7 @@ export function AIAlertAnalyzer() {
}`; }`;
const res = await aiApi.chat([{ role: 'user', content: prompt }]); const res = await aiApi.chat([{ role: 'user', content: prompt }]);
const content = (res as Record<string, any>)?.content || (res as Record<string, any>)?.message?.content || ''; const content = (res as JsonData)?.content || (res as JsonData)?.message?.content || '';
if (content) { if (content) {
setAnalysis(parseAIResponse(content, fallback)); setAnalysis(parseAIResponse(content, fallback));
} else { } else {
@@ -42,6 +42,7 @@ import {
Wifi, Wifi,
Square, Square,
} from 'lucide-react'; } from 'lucide-react';
import type { JsonData } from '@/types/common';
// LLM 提供商列表(OpenAI 兼容接口) // Logan.Li // LLM 提供商列表(OpenAI 兼容接口) // Logan.Li
const LLM_PROVIDERS: { id: string; name: string; defaultEndpoint: string; models: string[] }[] = [ const LLM_PROVIDERS: { id: string; name: string; defaultEndpoint: string; models: string[] }[] = [
@@ -68,7 +69,7 @@ const defaultConfig: LLMConfigData = {
// 拉取最近告警事件,格式化为紧凑列表供 LLM 分析 // 拉取最近告警事件,格式化为紧凑列表供 LLM 分析
async function loadAlertsContext(): Promise<string> { async function loadAlertsContext(): Promise<string> {
const res = await alertApi.getAlerts({ page: 1, page_size: 20 }); const res = await alertApi.getAlerts({ page: 1, page_size: 20 });
const alerts: Record<string, any>[] = (res as Record<string, any>)?.list || []; const alerts: JsonData[] = (res as JsonData)?.list || [];
if (!alerts.length) return '(当前无告警事件)'; if (!alerts.length) return '(当前无告警事件)';
const lines = alerts.slice(0, 15).map((a, i) => const lines = alerts.slice(0, 15).map((a, i) =>
`${i + 1}. [${a.severity || '未知'}|${a.status || '-'}] ${a.rule_name || '告警'} → 目标:${a.target || '-'} 值:${a.value ?? '-'} 时间:${(a.started_at || '').slice(0, 16)}`, `${i + 1}. [${a.severity || '未知'}|${a.status || '-'}] ${a.rule_name || '告警'} → 目标:${a.target || '-'} 值:${a.value ?? '-'} 时间:${(a.started_at || '').slice(0, 16)}`,
@@ -80,7 +81,7 @@ async function loadAlertsContext(): Promise<string> {
// 拉取 Agent 实时主机指标,识别高负载主机 // 拉取 Agent 实时主机指标,识别高负载主机
async function loadMetricsContext(): Promise<string> { async function loadMetricsContext(): Promise<string> {
const res = await agentApi.getLatest(); const res = await agentApi.getLatest();
const hosts: Record<string, any>[] = Array.isArray(res) ? res : (res as Record<string, any>)?.list || []; const hosts: JsonData[] = Array.isArray(res) ? res : (res as JsonData)?.list || [];
if (!hosts.length) return '(当前无 Agent 上报数据,可能未部署监控 Agent)'; if (!hosts.length) return '(当前无 Agent 上报数据,可能未部署监控 Agent)';
const lines = hosts.slice(0, 20).map((h, i) => { const lines = hosts.slice(0, 20).map((h, i) => {
const cpu = h.cpu_usage ?? '-'; const cpu = h.cpu_usage ?? '-';
@@ -101,11 +102,11 @@ async function loadReportContext(): Promise<string> {
dashboardStatsApi.getAlerts(10).catch(() => null), dashboardStatsApi.getAlerts(10).catch(() => null),
dashboardStatsApi.getReleases(10).catch(() => null), dashboardStatsApi.getReleases(10).catch(() => null),
]); ]);
const s: Record<string, any> = stats || {}; const s: JsonData = stats || {};
const ss = s.server_stats || {}, ks = s.kubernetes_stats || {}, as = s.alert_stats || {}, ps = s.pipeline_stats || {}, cs = s.cost_stats || {}; const ss = s.server_stats || {}, ks = s.kubernetes_stats || {}, as = s.alert_stats || {}, ps = s.pipeline_stats || {}, cs = s.cost_stats || {};
const overview = `服务器:${ss.total ?? 0}(运行${ss.running ?? 0}) | Kubernetes集群:${ks.total ?? 0} 节点:${ks.total_nodes ?? 0} Pod:${ks.total_pods ?? 0} | 告警:${as.total ?? 0}(firing ${as.firing ?? 0}) | 流水线:${ps.total ?? 0}(成功率${ps.success_rate ?? 0}%) | 本月成本:¥${cs.month_cost ?? 0}`; const overview = `服务器:${ss.total ?? 0}(运行${ss.running ?? 0}) | Kubernetes集群:${ks.total ?? 0} 节点:${ks.total_nodes ?? 0} Pod:${ks.total_pods ?? 0} | 告警:${as.total ?? 0}(firing ${as.firing ?? 0}) | 流水线:${ps.total ?? 0}(成功率${ps.success_rate ?? 0}%) | 本月成本:¥${cs.month_cost ?? 0}`;
const alertList: Record<string, any>[] = Array.isArray(alerts) ? alerts : (alerts as Record<string, any>)?.list || []; const alertList: JsonData[] = Array.isArray(alerts) ? alerts : (alerts as JsonData)?.list || [];
const releaseList: Record<string, any>[] = Array.isArray(releases) ? releases : (releases as Record<string, any>)?.list || []; const releaseList: JsonData[] = Array.isArray(releases) ? releases : (releases as JsonData)?.list || [];
const alertLines = alertList.slice(0, 10).map((a, i) => `${i + 1}.[${a.severity || '?'}|${a.status || '?'}] ${a.rule_name || '-'} → ${a.target || '-'}`); const alertLines = alertList.slice(0, 10).map((a, i) => `${i + 1}.[${a.severity || '?'}|${a.status || '?'}] ${a.rule_name || '-'} → ${a.target || '-'}`);
const releaseLines = releaseList.slice(0, 10).map((r, i) => `${i + 1}.${r.app_name || '-'} ${r.version || ''} [${r.environment || '-'}/${r.status || '-'}] by ${r.author || '-'}`); const releaseLines = releaseList.slice(0, 10).map((r, i) => `${i + 1}.${r.app_name || '-'} ${r.version || ''} [${r.environment || '-'}/${r.status || '-'}] by ${r.author || '-'}`);
return `平台概览:${overview}\n\n最近告警:\n${alertLines.join('\n') || '(无)'}\n\n最近发布:\n${releaseLines.join('\n') || '(无)'}`; return `平台概览:${overview}\n\n最近告警:\n${alertLines.join('\n') || '(无)'}\n\n最近发布:\n${releaseLines.join('\n') || '(无)'}`;
@@ -188,7 +189,7 @@ export function AIOpsAssistant() {
const loadHistory = async () => { const loadHistory = async () => {
try { try {
const res = await aiApi.getHistory(sessionId); const res = await aiApi.getHistory(sessionId);
const items: ChatHistoryItem[] = (res as Record<string, any>)?.list || []; const items: ChatHistoryItem[] = (res as JsonData)?.list || [];
setMessages( setMessages(
items.map((m) => ({ items.map((m) => ({
id: String(m.id), id: String(m.id),
@@ -227,7 +228,7 @@ export function AIOpsAssistant() {
try { try {
const saved = await aiApi.saveConfig(config); const saved = await aiApi.saveConfig(config);
setConfig(saved); setConfig(saved);
setConfigReady(!!(saved.enabled && (saved as Record<string, any>).configured)); setConfigReady(!!(saved.enabled && (saved as JsonData).configured));
toast.success('配置已保存'); toast.success('配置已保存');
setIsConfigOpen(false); setIsConfigOpen(false);
} catch (e) { } catch (e) {
@@ -36,6 +36,7 @@ import {
trendColorClass, trendColorClass,
type CapacityForecast, type CapacityForecast,
} from './predictUtils'; } from './predictUtils';
import type { JsonData } from '@/types/common';
// 重新导出 CapacityForecast,兼容已有 import 路径 './AIPredictiveAnalytics' // 重新导出 CapacityForecast,兼容已有 import 路径 './AIPredictiveAnalytics'
export type { CapacityForecast }; export type { CapacityForecast };
@@ -46,10 +47,10 @@ export function AIPredictiveAnalytics() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// 原始数据 // 原始数据
const [rawMetrics, setRawMetrics] = useState<any[]>([]); const [rawMetrics, setRawMetrics] = useState<JsonData[]>([]);
const [serverStats, setServerStats] = useState<any>({}); const [serverStats, setServerStats] = useState<JsonData>({});
const [alertEvents, setAlertEvents] = useState<any[]>([]); const [alertEvents, setAlertEvents] = useState<JsonData[]>([]);
const [alertRules, setAlertRules] = useState<any[]>([]); const [alertRules, setAlertRules] = useState<JsonData[]>([]);
useEffect(() => { useEffect(() => {
void loadData(); void loadData();
@@ -66,9 +67,9 @@ export function AIPredictiveAnalytics() {
]); ]);
setRawMetrics(Array.isArray(metricsData) ? metricsData : []); setRawMetrics(Array.isArray(metricsData) ? metricsData : []);
setServerStats(statsResp || {}); setServerStats(statsResp || {});
const alertsRaw = (alertsResp as Record<string, any>)?.list || (alertsResp as Record<string, any>) || []; const alertsRaw = (alertsResp as JsonData)?.list || (alertsResp as JsonData) || [];
setAlertEvents(Array.isArray(alertsRaw) ? alertsRaw : []); setAlertEvents(Array.isArray(alertsRaw) ? alertsRaw : []);
const rulesRaw = (rulesResp as Record<string, any>)?.list || (rulesResp as Record<string, any>) || []; const rulesRaw = (rulesResp as JsonData)?.list || (rulesResp as JsonData) || [];
setAlertRules(Array.isArray(rulesRaw) ? rulesRaw : []); setAlertRules(Array.isArray(rulesRaw) ? rulesRaw : []);
} catch { } catch {
// 保持空数据,组件显示空状态 // 保持空数据,组件显示空状态
@@ -1,3 +1,4 @@
import type { JsonData } from "@/types/common";
// AI 告警分析纯逻辑辅助函数:类型定义 + 后端告警映射 + 动态 AI 分析生成 + 严重等级配色 // Logan.Li // AI 告警分析纯逻辑辅助函数:类型定义 + 后端告警映射 + 动态 AI 分析生成 + 严重等级配色 // Logan.Li
// 告警条目(前端统一模型,从后端 alert event 映射而来) // 告警条目(前端统一模型,从后端 alert event 映射而来)
@@ -145,7 +146,7 @@ export function generateAIAnalysis(alert: AlertItem): AIAnalysis {
// ─── 后端告警事件 → AlertItem 映射 ─── // ─── 后端告警事件 → AlertItem 映射 ───
// 后端 alert event 字段为 snake_case(rule_name/target/started_at/value 等),此处统一映射为前端 AlertItem // 后端 alert event 字段为 snake_case(rule_name/target/started_at/value 等),此处统一映射为前端 AlertItem
export function mapAlertEvent( event: any): AlertItem { export function mapAlertEvent(event: JsonData): AlertItem {
const expr = event.rule?.expression || ''; const expr = event.rule?.expression || '';
// 从表达式(如 "cpu_usage > 80")中提取指标名与阈值 // 从表达式(如 "cpu_usage > 80")中提取指标名与阈值
const metricMatch = expr.match(/([\w_]+)\s*[><=!]+\s*([\d.]+)/); const metricMatch = expr.match(/([\w_]+)\s*[><=!]+\s*([\d.]+)/);
@@ -1,3 +1,4 @@
import type { JsonData } from "@/types/common";
/** /**
* AI 预测分析模块的纯计算工具 — 类型定义、常量、线性回归、预测推算 * AI 预测分析模块的纯计算工具 — 类型定义、常量、线性回归、预测推算
* *
@@ -88,7 +89,7 @@ export function linearRegression(values: number[]): { slope: number; r2: number
} }
/** 从时序指标中提取按时间排序的数值序列 */ /** 从时序指标中提取按时间排序的数值序列 */
export function extractSeries( metrics: any[], name: string): number[] { export function extractSeries( metrics: JsonData[], name: string): number[] {
return metrics return metrics
.filter((m) => m.name === name) .filter((m) => m.name === name)
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())
@@ -140,10 +141,10 @@ export function anomalyAction(metricKey: string): string {
/** computePredictions 的输入参数 */ /** computePredictions 的输入参数 */
export interface ComputePredictionsInput { export interface ComputePredictionsInput {
rawMetrics: any[]; rawMetrics: JsonData[];
serverStats: any; serverStats?: JsonData;
alertEvents: any[]; alertEvents: JsonData[];
alertRules: any[]; alertRules: JsonData[];
selectedTimeRange: string; // '1h' | '24h' | '7d' | '30d' selectedTimeRange: string; // '1h' | '24h' | '7d' | '30d'
} }
@@ -23,11 +23,12 @@ import { Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { approvalApi } from '@/api'; import { approvalApi } from '@/api';
import { parseFields, APPROVAL_TYPE_OPTIONS } from './helpers'; import { parseFields, APPROVAL_TYPE_OPTIONS } from './helpers';
import type { JsonData } from '@/types/common';
interface ApprovalCreateDialogProps { interface ApprovalCreateDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
templates: any[]; templates: JsonData[];
onSuccess: () => void; onSuccess: () => void;
} }
@@ -38,8 +39,8 @@ export function ApprovalCreateDialog({
templates, templates,
onSuccess, onSuccess,
}: ApprovalCreateDialogProps) { }: ApprovalCreateDialogProps) {
const [selectedTemplate, setSelectedTemplate] = useState<any>(null); const [selectedTemplate, setSelectedTemplate] = useState<JsonData>(null);
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<JsonData>({});
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// 提交新建申请 // Logan.Li // 提交新建申请 // Logan.Li
@@ -143,7 +144,7 @@ export function ApprovalCreateDialog({
</button> </button>
</div> </div>
<div className="grid grid-cols-2 gap-x-4 gap-y-4"> <div className="grid grid-cols-2 gap-x-4 gap-y-4">
{parseFields(selectedTemplate.form_schema).map(( field: any) => { {parseFields(selectedTemplate.form_schema).map((field: JsonData) => {
const isTextarea = field.type === 'textarea'; const isTextarea = field.type === 'textarea';
return ( return (
<div key={field.key} className={cn('space-y-1.5', isTextarea && 'col-span-2')}> <div key={field.key} className={cn('space-y-1.5', isTextarea && 'col-span-2')}>
@@ -16,14 +16,15 @@ import {
getApprovalTypeColor, getApprovalTypeColor,
getInstanceType, getInstanceType,
} from './helpers'; } from './helpers';
import type { JsonData } from '@/types/common';
interface ApprovalDetailDialogProps { interface ApprovalDetailDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
instance: any; instance?: JsonData;
templates: any[]; templates: JsonData[];
onApprove: ( instance: any) => void; onApprove: (instance: JsonData) => void;
onReject: ( instance: any) => void; onReject: (instance: JsonData) => void;
} }
// 审批详情弹窗 — 展示流程详情/表单/进度/操作记录 // Logan.Li // 审批详情弹窗 — 展示流程详情/表单/进度/操作记录 // Logan.Li
@@ -91,7 +92,7 @@ export function ApprovalDetailDialog({
<div className="bg-[var(--dg-bg-primary)] rounded-lg p-4"> <div className="bg-[var(--dg-bg-primary)] rounded-lg p-4">
<p className="text-sm font-medium text-[var(--dg-text-secondary)] mb-3">操作记录</p> <p className="text-sm font-medium text-[var(--dg-text-secondary)] mb-3">操作记录</p>
<div className="space-y-2"> <div className="space-y-2">
{instance.actions.map(( action: any, index: number) => ( {instance.actions.map((action: JsonData, index: number) => (
<div key={index} className="flex items-center gap-3 text-sm"> <div key={index} className="flex items-center gap-3 text-sm">
{action.action === 'approve' ? ( {action.action === 'approve' ? (
<CheckCircle2 className="h-4 w-4 text-emerald-400" /> <CheckCircle2 className="h-4 w-4 text-emerald-400" />
@@ -33,13 +33,14 @@ import {
getApprovalTypeColor, getApprovalTypeColor,
getInstanceType, getInstanceType,
} from './helpers'; } from './helpers';
import type { JsonData } from '@/types/common';
export function ApprovalFlow() { export function ApprovalFlow() {
const [activeTab, setActiveTab] = useState<'instances' | 'flows' | 'my-approvals'>('instances'); const [activeTab, setActiveTab] = useState<'instances' | 'flows' | 'my-approvals'>('instances');
const [processes, setProcesses] = useState<any[]>([]); const [processes, setProcesses] = useState<JsonData[]>([]);
const [templates, setTemplates] = useState<any[]>([]); const [templates, setTemplates] = useState<JsonData[]>([]);
const [myApprovals, setMyApprovals] = useState<any[]>([]); const [myApprovals, setMyApprovals] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -48,7 +49,7 @@ export function ApprovalFlow() {
// 详情弹窗 // 详情弹窗
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [selectedInstance, setSelectedInstance] = useState<any>(null); const [selectedInstance, setSelectedInstance] = useState<JsonData>(null);
// 审批操作弹窗 // 审批操作弹窗
const [actionDialogOpen, setActionDialogOpen] = useState(false); const [actionDialogOpen, setActionDialogOpen] = useState(false);
@@ -95,13 +96,13 @@ export function ApprovalFlow() {
}); });
// 打开详情弹窗 // Logan.Li // 打开详情弹窗 // Logan.Li
const handleView = ( instance: any) => { const handleView = (instance: JsonData) => {
setSelectedInstance(instance); setSelectedInstance(instance);
setDetailOpen(true); setDetailOpen(true);
}; };
// 打开审批操作弹窗 // Logan.Li // 打开审批操作弹窗 // Logan.Li
const openActionDialog = (type: 'approve' | 'reject', instance: any) => { const openActionDialog = (type: 'approve' | 'reject', instance: JsonData) => {
setActionType(type); setActionType(type);
setSelectedInstance(instance); setSelectedInstance(instance);
setActionComment(''); setActionComment('');
@@ -123,7 +124,7 @@ export function ApprovalFlow() {
setActionDialogOpen(false); setActionDialogOpen(false);
loadData(); loadData();
} catch (error) { } catch (error) {
toast.error('操作失败', { description: (error as Record<string, any>).message }); toast.error('操作失败', { description: (error as JsonData).message });
} finally { } finally {
setActionSubmitting(false); setActionSubmitting(false);
} }
@@ -19,6 +19,7 @@ import {
getApprovalTypeIcon, getApprovalTypeIcon,
getInstanceType, getInstanceType,
} from './helpers'; } from './helpers';
import type { JsonData } from '@/types/common';
// 状态标签页定义 // 状态标签页定义
const STATUS_TABS = [ const STATUS_TABS = [
@@ -69,16 +70,16 @@ function getStatusBadge(status: string) {
} }
interface ApprovalProcessTabProps { interface ApprovalProcessTabProps {
processes: any[]; processes: JsonData[];
filteredInstances: any[]; filteredInstances: JsonData[];
loading: boolean; loading: boolean;
searchQuery: string; searchQuery: string;
setSearchQuery: (v: string) => void; setSearchQuery: (v: string) => void;
statusFilter: string; statusFilter: string;
setStatusFilter: (v: string) => void; setStatusFilter: (v: string) => void;
onView: ( instance: any) => void; onView: (instance: JsonData) => void;
onApprove: ( instance: any) => void; onApprove: (instance: JsonData) => void;
onReject: ( instance: any) => void; onReject: (instance: JsonData) => void;
} }
export function ApprovalProcessTab({ export function ApprovalProcessTab({
@@ -3,8 +3,9 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Filter, Edit, GitBranch, ArrowRight } from 'lucide-react'; import { Filter, Edit, GitBranch, ArrowRight } from 'lucide-react';
import type { JsonData } from '@/types/common';
function parseSteps( steps: any) { function parseSteps(steps: JsonData) {
if (Array.isArray(steps)) return steps; if (Array.isArray(steps)) return steps;
if (typeof steps === 'string') { if (typeof steps === 'string') {
try { try {
@@ -31,14 +32,14 @@ const getApprovalTypeColor = (value: string): string =>
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)'; APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.color || 'var(--dg-text-secondary)';
// 模拟审批流程图 // 模拟审批流程图
function renderFlowChart( flow: any) { function renderFlowChart(flow: JsonData) {
const nodes = parseSteps(flow.steps); const nodes = parseSteps(flow.steps);
if (nodes.length === 0) { if (nodes.length === 0) {
return <div className="text-[var(--dg-text-muted)] text-center py-4">暂无流程节点</div>; return <div className="text-[var(--dg-text-muted)] text-center py-4">暂无流程节点</div>;
} }
return ( return (
<div className="flex items-center gap-2 overflow-x-auto py-4"> <div className="flex items-center gap-2 overflow-x-auto py-4">
{nodes.map(( node: any, index: number) => ( {nodes.map((node: JsonData, index: number) => (
<div key={node.id || index} className="flex items-center"> <div key={node.id || index} className="flex items-center">
<div <div
className={cn( className={cn(
@@ -66,7 +67,7 @@ function renderFlowChart( flow: any) {
} }
interface ApprovalTemplateTabProps { interface ApprovalTemplateTabProps {
filteredTemplates: any[]; filteredTemplates: JsonData[];
templateTypeFilter: string; templateTypeFilter: string;
setTemplateTypeFilter: (value: string) => void; setTemplateTypeFilter: (value: string) => void;
} }
+9 -8
View File
@@ -8,9 +8,10 @@ import {
GitCommit, GitCommit,
FileText, FileText,
} from 'lucide-react'; } from 'lucide-react';
import type { JsonData } from '@/types/common';
// 解析审批步骤数组(兼容 JSON 字符串) // Logan.Li // 解析审批步骤数组(兼容 JSON 字符串) // Logan.Li
export function parseSteps( steps: any) { export function parseSteps(steps: JsonData) {
if (Array.isArray(steps)) return steps; if (Array.isArray(steps)) return steps;
if (typeof steps === 'string') { if (typeof steps === 'string') {
try { try {
@@ -24,7 +25,7 @@ export function parseSteps( steps: any) {
} }
// 解析模板的 form_schema,返回字段定义数组 // Logan.Li // 解析模板的 form_schema,返回字段定义数组 // Logan.Li
export function parseFields( formSchema: any): any[] { export function parseFields(formSchema: JsonData): JsonData[] {
if (!formSchema) return []; if (!formSchema) return [];
const schema = typeof formSchema === 'string' ? (() => { try { return JSON.parse(formSchema); } catch { return null; } })() : formSchema; const schema = typeof formSchema === 'string' ? (() => { try { return JSON.parse(formSchema); } catch { return null; } })() : formSchema;
if (!schema) return []; if (!schema) return [];
@@ -32,7 +33,7 @@ export function parseFields( formSchema: any): any[] {
} }
// 在详情弹窗中渲染表单数据为键值对展示 // Logan.Li // 在详情弹窗中渲染表单数据为键值对展示 // Logan.Li
export function renderFormData(formDataStr: string, template?: any) { export function renderFormData(formDataStr: string, template?: JsonData) {
if (!formDataStr) return null; if (!formDataStr) return null;
let data: Record<string, unknown>; let data: Record<string, unknown>;
try { try {
@@ -78,26 +79,26 @@ export const getApprovalTypeIcon = (value: string) =>
APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.icon || FileText; APPROVAL_TYPE_OPTIONS.find((o) => o.value === value)?.icon || FileText;
// 获取审批实例的类型(优先从关联模板取) // Logan.Li // 获取审批实例的类型(优先从关联模板取) // Logan.Li
export const getInstanceType = ( instance: any): string => export const getInstanceType = (instance: JsonData): string =>
instance?.template?.type || instance?.type || ''; instance?.template?.type || instance?.type || '';
// 计算审批进度信息 // Logan.Li // 计算审批进度信息 // Logan.Li
export function getProgressInfo( instance: any) { export function getProgressInfo(instance: JsonData) {
const steps = parseSteps(instance.steps); const steps = parseSteps(instance.steps);
const currentIndex = instance.current_step ?? 0; const currentIndex = instance.current_step ?? 0;
const total = steps.length; const total = steps.length;
const completed = steps.filter(( s: any) => s.status === 'approved' || s.status === 'completed').length; const completed = steps.filter((s: JsonData) => s.status === 'approved' || s.status === 'completed').length;
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0; const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
return { steps, currentIndex, total, completed, percentage }; return { steps, currentIndex, total, completed, percentage };
} }
// 渲染审批进度(用于详情弹窗) // Logan.Li // 渲染审批进度(用于详情弹窗) // Logan.Li
export function renderStepProgress( instance: any) { export function renderStepProgress(instance: JsonData) {
const { steps, currentIndex } = getProgressInfo(instance); const { steps, currentIndex } = getProgressInfo(instance);
if (steps.length === 0) return null; if (steps.length === 0) return null;
return ( return (
<div className="flex items-center gap-1 overflow-x-auto"> <div className="flex items-center gap-1 overflow-x-auto">
{steps.map(( step: any, index: number) => { {steps.map((step: JsonData, index: number) => {
const isCompleted = step.status === 'approved' || step.status === 'completed'; const isCompleted = step.status === 'approved' || step.status === 'completed';
const isCurrent = index === currentIndex; const isCurrent = index === currentIndex;
const isRejected = step.status === 'rejected'; const isRejected = step.status === 'rejected';
@@ -21,6 +21,7 @@ import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cronJobApi, scriptApi } from '@/api'; import { cronJobApi, scriptApi } from '@/api';
import type { JsonData } from '@/types/common';
// 定时任务类型定义 // Logan.Li // 定时任务类型定义 // Logan.Li
const jobTypes = [ const jobTypes = [
@@ -37,7 +38,7 @@ export function CronJobFormDialog({
}: { }: {
open: boolean; open: boolean;
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
editJob: any | null; editJob: JsonData | null;
onSuccess: () => void; onSuccess: () => void;
}) { }) {
const [form, setForm] = useState({ const [form, setForm] = useState({
@@ -48,15 +49,15 @@ export function CronJobFormDialog({
content: '', content: '',
script_id: null as number | null, script_id: null as number | null,
}); });
const [scripts, setScripts] = useState<any[]>([]); const [scripts, setScripts] = useState<JsonData[]>([]);
const [selectedScript, setSelectedScript] = useState<any>(null); const [selectedScript, setSelectedScript] = useState<JsonData>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// 弹窗打开时:加载脚本库 + 初始化表单 // Logan.Li // 弹窗打开时:加载脚本库 + 初始化表单 // Logan.Li
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
// 拉取脚本库 // 拉取脚本库
scriptApi.getScripts({ page_size: 200 }).then(( res: any) => { scriptApi.getScripts({ page_size: 200 }).then((res: JsonData) => {
setScripts(res?.list || []); setScripts(res?.list || []);
}).catch(() => setScripts([])); }).catch(() => setScripts([]));
@@ -71,7 +72,7 @@ export function CronJobFormDialog({
}); });
// 如果关联了脚本,拉取脚本详情 // 如果关联了脚本,拉取脚本详情
if (editJob.script_id) { if (editJob.script_id) {
scriptApi.getScript(editJob.script_id).then(( res: any) => { scriptApi.getScript(editJob.script_id).then((res: JsonData) => {
setSelectedScript(res); setSelectedScript(res);
}).catch(() => setSelectedScript(null)); }).catch(() => setSelectedScript(null));
} else { } else {
@@ -17,6 +17,7 @@ import {
import { History, Loader2, Terminal } from 'lucide-react'; import { History, Loader2, Terminal } from 'lucide-react';
import { taskExecutionApi } from '@/api'; import { taskExecutionApi } from '@/api';
import { getStatusBadge } from './cronJobHelpers'; import { getStatusBadge } from './cronJobHelpers';
import type { JsonData } from '@/types/common';
// 定时任务执行历史弹窗(打开时自动加载,点击行展开输出) // Logan.Li // 定时任务执行历史弹窗(打开时自动加载,点击行展开输出) // Logan.Li
export function ExecutionHistoryDialog({ export function ExecutionHistoryDialog({
@@ -26,7 +27,7 @@ export function ExecutionHistoryDialog({
open: boolean; open: boolean;
onOpenChange: (v: boolean) => void; onOpenChange: (v: boolean) => void;
}) { }) {
const [executions, setExecutions] = useState<any[]>([]); const [executions, setExecutions] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [expandedExec, setExpandedExec] = useState<number | null>(null); const [expandedExec, setExpandedExec] = useState<number | null>(null);
@@ -20,6 +20,7 @@ import ScriptDetailDialog from './ScriptDetailDialog';
import ScriptLogsDialog from './ScriptLogsDialog'; import ScriptLogsDialog from './ScriptLogsDialog';
import ScriptCard from './ScriptCard'; import ScriptCard from './ScriptCard';
import ScriptExecutionsTable from './ScriptExecutionsTable'; import ScriptExecutionsTable from './ScriptExecutionsTable';
import type { JsonData } from '@/types/common';
export function ScriptManager() { export function ScriptManager() {
const _confirm = useConfirm(); const _confirm = useConfirm();
@@ -49,7 +50,7 @@ export function ScriptManager() {
setLoading(true); setLoading(true);
try { try {
const res = await scriptApi.getScripts({ page_size: 100 }); const res = await scriptApi.getScripts({ page_size: 100 });
const items = (res as Record<string, any>)?.list || []; const items = (res as JsonData)?.list || [];
setScripts(items.map(mapScript)); setScripts(items.map(mapScript));
} catch { } catch {
toast.error('获取脚本列表失败'); toast.error('获取脚本列表失败');
@@ -61,7 +62,7 @@ export function ScriptManager() {
const fetchExecutions = async () => { const fetchExecutions = async () => {
try { try {
const res = await taskExecutionApi.getExecutions({ task_type: 'script', page_size: 50 }); const res = await taskExecutionApi.getExecutions({ task_type: 'script', page_size: 50 });
const items = (res as Record<string, any>)?.list || []; const items = (res as JsonData)?.list || [];
setExecutions(items); setExecutions(items);
} catch { } catch {
// 静默失败 // 静默失败
@@ -189,7 +190,7 @@ export function ScriptManager() {
setExecuting(true); setExecuting(true);
try { try {
const res = await scriptApi.executeScript(sc.id); const res = await scriptApi.executeScript(sc.id);
const exec = (res as Record<string, any>)?.data ?? res; const exec = (res as JsonData)?.data ?? res;
if (exec?.status === 'success') { if (exec?.status === 'success') {
toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`); toast.success(`执行成功,耗时 ${exec.duration ?? 0}ms`);
} else { } else {
@@ -2,6 +2,7 @@
import React from 'react'; import React from 'react';
import { Clock, Loader2, CheckCircle, XCircle } from 'lucide-react'; import { Clock, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { JsonData } from '@/types/common';
// ---- 类型定义 ---- // ---- 类型定义 ----
@@ -33,7 +34,7 @@ export interface ExecutionRecord {
} }
// 将后端返回的脚本原始对象映射为前端结构 // 将后端返回的脚本原始对象映射为前端结构
export function mapScript( raw: any): Script { export function mapScript(raw: JsonData): Script {
return { return {
id: raw.id, id: raw.id,
name: raw.name, name: raw.name,
@@ -22,6 +22,7 @@ import { Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { billingApi } from '@/api'; import { billingApi } from '@/api';
import { providerLabels } from './CloudBillingHelpers'; import { providerLabels } from './CloudBillingHelpers';
import type { JsonData } from '@/types/common';
const emptyRecord = { const emptyRecord = {
provider: 'aliyun', provider: 'aliyun',
@@ -36,12 +37,12 @@ const emptyRecord = {
interface Props { interface Props {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
editingRecord: any | null; editingRecord: JsonData | null;
onSuccess: () => void; onSuccess: () => void;
} }
export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSuccess }: Props) { export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSuccess }: Props) {
const [form, setForm] = useState<any>(emptyRecord); const [form, setForm] = useState<JsonData>(emptyRecord);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// 弹窗打开时初始化表单(新建预填今日日期,编辑回填数据) // Logan.Li // 弹窗打开时初始化表单(新建预填今日日期,编辑回填数据) // Logan.Li
@@ -80,7 +81,7 @@ export function BillingRecordDialog({ open, onOpenChange, editingRecord, onSucce
onSuccess(); onSuccess();
toast('保存成功'); toast('保存成功');
} catch (e) { } catch (e) {
toast.error('保存失败', { description: (e as Record<string, any>).message }); toast.error('保存失败', { description: (e as JsonData).message });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -23,18 +23,19 @@ import { Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { billingApi } from '@/api'; import { billingApi } from '@/api';
import { providerLabels } from './CloudBillingHelpers'; import { providerLabels } from './CloudBillingHelpers';
import type { JsonData } from '@/types/common';
const emptyRule = { name: '', provider: '', metric: 'daily', threshold: '', operator: '>', enabled: true }; const emptyRule = { name: '', provider: '', metric: 'daily', threshold: '', operator: '>', enabled: true };
interface Props { interface Props {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
editingRule: any | null; editingRule: JsonData | null;
onSuccess: () => void; onSuccess: () => void;
} }
export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }: Props) { export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }: Props) {
const [form, setForm] = useState<any>(emptyRule); const [form, setForm] = useState<JsonData>(emptyRule);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
// 弹窗打开时初始化表单 // Logan.Li // 弹窗打开时初始化表单 // Logan.Li
@@ -71,7 +72,7 @@ export function BillingRuleDialog({ open, onOpenChange, editingRule, onSuccess }
onSuccess(); onSuccess();
toast('保存成功'); toast('保存成功');
} catch (e) { } catch (e) {
toast.error('保存失败', { description: (e as Record<string, any>).message }); toast.error('保存失败', { description: (e as JsonData).message });
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -26,6 +26,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { cloudApi } from '@/api'; import { cloudApi } from '@/api';
import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon'; import { CloudBrandIcon, getProviderColor } from '@/components/icons/CloudBrandIcon';
import type { JsonData } from '@/types/common';
const providerLabels: Record<string, string> = { const providerLabels: Record<string, string> = {
aws: 'AWS', aws: 'AWS',
@@ -37,7 +38,7 @@ const providerLabels: Record<string, string> = {
}; };
export function CloudAccountManager() { export function CloudAccountManager() {
const [accounts, setAccounts] = useState<any[]>([]); const [accounts, setAccounts] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedProvider, setSelectedProvider] = useState<string>('all'); const [selectedProvider, setSelectedProvider] = useState<string>('all');
@@ -45,7 +46,7 @@ export function CloudAccountManager() {
// 创建/编辑账号表单状态 // 创建/编辑账号表单状态
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [editingAccount, setEditingAccount] = useState<any>(null); const [editingAccount, setEditingAccount] = useState<JsonData>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
provider: 'aliyun', provider: 'aliyun',
@@ -72,7 +73,7 @@ export function CloudAccountManager() {
setFormOpen(true); setFormOpen(true);
}; };
const handleOpenEdit = ( account: any) => { const handleOpenEdit = (account: JsonData) => {
setEditingAccount(account); setEditingAccount(account);
setFormData({ setFormData({
name: account.name || '', name: account.name || '',
@@ -89,7 +90,7 @@ export function CloudAccountManager() {
if (!formData.name.trim() || !formData.provider.trim()) return; if (!formData.name.trim() || !formData.provider.trim()) return;
try { try {
setSubmitting(true); setSubmitting(true);
const payload: Record<string, any> = { const payload: JsonData = {
name: formData.name.trim(), name: formData.name.trim(),
provider: formData.provider, provider: formData.provider,
access_key: formData.access_key.trim(), access_key: formData.access_key.trim(),
@@ -114,7 +115,7 @@ export function CloudAccountManager() {
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true); setLoading(true);
const res: Record<string, any> = await cloudApi.getAccounts(); const res: JsonData = await cloudApi.getAccounts();
setAccounts(res?.list || []); setAccounts(res?.list || []);
} catch (e) { } catch (e) {
console.error('加载云账号列表失败:', e); console.error('加载云账号列表失败:', e);
+18 -17
View File
@@ -28,29 +28,30 @@ import { formatCurrency, TrendChart, providerLabels, providerColors } from './Cl
import { StatStrip } from '@/components/ui/stat-strip'; import { StatStrip } from '@/components/ui/stat-strip';
import { BillingRecordDialog } from './BillingRecordDialog'; import { BillingRecordDialog } from './BillingRecordDialog';
import { BillingRuleDialog } from './BillingRuleDialog'; import { BillingRuleDialog } from './BillingRuleDialog';
import type { JsonData } from '@/types/common';
export function CloudBilling() { export function CloudBilling() {
const confirm = useConfirm(); const confirm = useConfirm();
const [activeTab, setActiveTab] = useState<'overview' | 'records' | 'alerts'>('overview'); const [activeTab, setActiveTab] = useState<'overview' | 'records' | 'alerts'>('overview');
const [stats, setStats] = useState<any>(null); const [stats, setStats] = useState<JsonData>(null);
const [statsLoading, setStatsLoading] = useState(true); const [statsLoading, setStatsLoading] = useState(true);
const [records, setRecords] = useState<any[]>([]); const [records, setRecords] = useState<JsonData[]>([]);
const [recordsLoading, setRecordsLoading] = useState(true); const [recordsLoading, setRecordsLoading] = useState(true);
const [filterProvider, setFilterProvider] = useState(''); const [filterProvider, setFilterProvider] = useState('');
const [filterStart, setFilterStart] = useState(''); const [filterStart, setFilterStart] = useState('');
const [filterEnd, setFilterEnd] = useState(''); const [filterEnd, setFilterEnd] = useState('');
const [rules, setRules] = useState<any[]>([]); const [rules, setRules] = useState<JsonData[]>([]);
const [rulesLoading, setRulesLoading] = useState(true); const [rulesLoading, setRulesLoading] = useState(true);
// 弹窗状态(open + 编辑目标) // Logan.Li // 弹窗状态(open + 编辑目标) // Logan.Li
const [recordDialogOpen, setRecordDialogOpen] = useState(false); const [recordDialogOpen, setRecordDialogOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<any>(null); const [editingRecord, setEditingRecord] = useState<JsonData>(null);
const [ruleDialogOpen, setRuleDialogOpen] = useState(false); const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
const [editingRule, setEditingRule] = useState<any>(null); const [editingRule, setEditingRule] = useState<JsonData>(null);
const loadStats = useCallback(async () => { const loadStats = useCallback(async () => {
setStatsLoading(true); setStatsLoading(true);
@@ -58,7 +59,7 @@ export function CloudBilling() {
const res = await billingApi.getStats(); const res = await billingApi.getStats();
setStats(res); setStats(res);
} catch (e) { } catch (e) {
toast.error('加载统计失败', { description: (e as Record<string, any>).message }); toast.error('加载统计失败', { description: (e as JsonData).message });
} finally { } finally {
setStatsLoading(false); setStatsLoading(false);
} }
@@ -67,14 +68,14 @@ export function CloudBilling() {
const loadRecords = useCallback(async () => { const loadRecords = useCallback(async () => {
setRecordsLoading(true); setRecordsLoading(true);
try { try {
const params: Record<string, any> = {}; const params: JsonData = {};
if (filterProvider) params.provider = filterProvider; if (filterProvider) params.provider = filterProvider;
if (filterStart) params.start_date = filterStart; if (filterStart) params.start_date = filterStart;
if (filterEnd) params.end_date = filterEnd; if (filterEnd) params.end_date = filterEnd;
const res = await billingApi.list(params); const res = await billingApi.list(params);
setRecords(res.list || []); setRecords(res.list || []);
} catch (e) { } catch (e) {
toast.error('加载账单失败', { description: (e as Record<string, any>).message }); toast.error('加载账单失败', { description: (e as JsonData).message });
} finally { } finally {
setRecordsLoading(false); setRecordsLoading(false);
} }
@@ -86,7 +87,7 @@ export function CloudBilling() {
const res = await billingApi.listAlertRules(); const res = await billingApi.listAlertRules();
setRules(res.list || []); setRules(res.list || []);
} catch (e) { } catch (e) {
toast.error('加载预警规则失败', { description: (e as Record<string, any>).message }); toast.error('加载预警规则失败', { description: (e as JsonData).message });
} finally { } finally {
setRulesLoading(false); setRulesLoading(false);
} }
@@ -109,7 +110,7 @@ export function CloudBilling() {
setRecordDialogOpen(true); setRecordDialogOpen(true);
}; };
const openEditRecord = ( r: any) => { const openEditRecord = (r: JsonData) => {
setEditingRecord(r); setEditingRecord(r);
setRecordDialogOpen(true); setRecordDialogOpen(true);
}; };
@@ -119,7 +120,7 @@ export function CloudBilling() {
setRuleDialogOpen(true); setRuleDialogOpen(true);
}; };
const openEditRule = ( r: any) => { const openEditRule = (r: JsonData) => {
setEditingRule(r); setEditingRule(r);
setRuleDialogOpen(true); setRuleDialogOpen(true);
}; };
@@ -133,7 +134,7 @@ export function CloudBilling() {
loadStats(); loadStats();
toast('删除成功'); toast('删除成功');
} catch (e) { } catch (e) {
toast.error('删除失败', { description: (e as Record<string, any>).message }); toast.error('删除失败', { description: (e as JsonData).message });
} }
}; };
@@ -145,7 +146,7 @@ export function CloudBilling() {
loadRules(); loadRules();
toast('删除成功'); toast('删除成功');
} catch (e) { } catch (e) {
toast.error('删除失败', { description: (e as Record<string, any>).message }); toast.error('删除失败', { description: (e as JsonData).message });
} }
}; };
@@ -155,7 +156,7 @@ export function CloudBilling() {
loadRules(); loadRules();
loadStats(); loadStats();
} catch (e) { } catch (e) {
toast.error('操作失败', { description: (e as Record<string, any>).message }); toast.error('操作失败', { description: (e as JsonData).message });
} }
}; };
@@ -222,7 +223,7 @@ export function CloudBilling() {
<span className="text-sm font-medium text-amber-300">成本预警</span> <span className="text-sm font-medium text-amber-300">成本预警</span>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{stats.alerts.map(( a: any, i: number) => ( {stats.alerts.map((a: JsonData, i: number) => (
<div key={i} className="flex items-center justify-between text-sm"> <div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant="outline" className="border-amber-500/50 text-amber-300">{a.rule_name}</Badge> <Badge variant="outline" className="border-amber-500/50 text-amber-300">{a.rule_name}</Badge>
@@ -256,7 +257,7 @@ export function CloudBilling() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex h-8 rounded-lg overflow-hidden"> <div className="flex h-8 rounded-lg overflow-hidden">
{stats.provider_dist.map(( p: any, i: number) => ( {stats.provider_dist.map((p: JsonData, i: number) => (
<div <div
key={i} key={i}
className={`${providerColors[p.provider] || 'bg-slate-600'} flex items-center justify-center text-xs text-white font-medium`} className={`${providerColors[p.provider] || 'bg-slate-600'} flex items-center justify-center text-xs text-white font-medium`}
@@ -268,7 +269,7 @@ export function CloudBilling() {
))} ))}
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2"> <div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{stats.provider_dist.map(( p: any, i: number) => ( {stats.provider_dist.map((p: JsonData, i: number) => (
<div key={i} className="flex items-center justify-between text-sm"> <div key={i} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded ${providerColors[p.provider] || 'bg-slate-600'}`} /> <div className={`w-3 h-3 rounded ${providerColors[p.provider] || 'bg-slate-600'}`} />
@@ -25,9 +25,10 @@ import {
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { cloudApi } from '@/api'; import { cloudApi } from '@/api';
import type { JsonData } from '@/types/common';
export function DomainManager() { export function DomainManager() {
const [domains, setDomains] = useState<any[]>([]); const [domains, setDomains] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedStatus, setSelectedStatus] = useState<string>('all'); const [selectedStatus, setSelectedStatus] = useState<string>('all');
@@ -35,7 +36,7 @@ export function DomainManager() {
// 创建/编辑域名表单状态 // 创建/编辑域名表单状态
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [editingDomain, setEditingDomain] = useState<any>(null); const [editingDomain, setEditingDomain] = useState<JsonData>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
registrar: '', registrar: '',
@@ -66,7 +67,7 @@ export function DomainManager() {
setFormOpen(true); setFormOpen(true);
}; };
const handleOpenEdit = ( domain: any) => { const handleOpenEdit = (domain: JsonData) => {
setEditingDomain(domain); setEditingDomain(domain);
// 后端返回 RFC3339 时间,截取 YYYY-MM-DD 部分给 date input // 后端返回 RFC3339 时间,截取 YYYY-MM-DD 部分给 date input
const toDateInput = (v: string) => (v ? v.substring(0, 10) : ''); const toDateInput = (v: string) => (v ? v.substring(0, 10) : '');
@@ -87,7 +88,7 @@ export function DomainManager() {
if (!formData.name.trim()) return; if (!formData.name.trim()) return;
try { try {
setSubmitting(true); setSubmitting(true);
const payload: Record<string, any> = { const payload: JsonData = {
name: formData.name.trim(), name: formData.name.trim(),
registrar: formData.registrar.trim(), registrar: formData.registrar.trim(),
status: formData.status, status: formData.status,
@@ -114,7 +115,7 @@ export function DomainManager() {
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true); setLoading(true);
const res: Record<string, any> = await cloudApi.getDomains(); const res: JsonData = await cloudApi.getDomains();
setDomains(res?.list || []); setDomains(res?.list || []);
} catch (e) { } catch (e) {
console.error('加载域名列表失败:', e); console.error('加载域名列表失败:', e);
@@ -30,6 +30,7 @@ import {
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { platformApi } from '@/api'; import { platformApi } from '@/api';
import type { JsonData } from '@/types/common';
const typeLabels: Record<string, string> = { const typeLabels: Record<string, string> = {
public: '公有云', public: '公有云',
@@ -44,12 +45,12 @@ const typeColors: Record<string, string> = {
}; };
export function PlatformManager() { export function PlatformManager() {
const [platforms, setPlatforms] = useState<any[]>([]); const [platforms, setPlatforms] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedType, setSelectedType] = useState('all'); const [selectedType, setSelectedType] = useState('all');
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null); const [editingItem, setEditingItem] = useState<JsonData>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', code: '', type: 'public', status: 'active', name: '', code: '', type: 'public', status: 'active',
@@ -60,7 +61,7 @@ export function PlatformManager() {
setLoading(true); setLoading(true);
try { try {
const res = await platformApi.list(); const res = await platformApi.list();
const items = (res as Record<string, any>)?.list || []; const items = (res as JsonData)?.list || [];
setPlatforms(items); setPlatforms(items);
} catch { } catch {
setPlatforms([]); setPlatforms([]);
@@ -84,7 +85,7 @@ export function PlatformManager() {
setDialogOpen(true); setDialogOpen(true);
}; };
const openEdit = ( item: any) => { const openEdit = (item: JsonData) => {
setEditingItem(item); setEditingItem(item);
setFormData({ setFormData({
name: item.name || '', code: item.code || '', type: item.type || 'public', name: item.name || '', code: item.code || '', type: item.type || 'public',
@@ -40,6 +40,7 @@ import {
import { regionApi } from '@/api'; import { regionApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { JsonData } from '@/types/common';
const providerNames: Record<string, string> = { const providerNames: Record<string, string> = {
aws: 'AWS', aws: 'AWS',
@@ -59,13 +60,13 @@ const providerColors: Record<string, string> = {
export function RegionManager() { export function RegionManager() {
const confirm = useConfirm(); const confirm = useConfirm();
const [regions, setRegions] = useState<any[]>([]); const [regions, setRegions] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedProvider, setSelectedProvider] = useState('all'); const [selectedProvider, setSelectedProvider] = useState('all');
const [selectedIds, setSelectedIds] = useState<number[]>([]); const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null); const [editingItem, setEditingItem] = useState<JsonData>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', code: '', provider: 'aliyun', location: '', name: '', code: '', provider: 'aliyun', location: '',
@@ -76,7 +77,7 @@ export function RegionManager() {
setLoading(true); setLoading(true);
try { try {
const res = await regionApi.list(); const res = await regionApi.list();
const items = (res as Record<string, any>)?.list || []; const items = (res as JsonData)?.list || [];
setRegions(items); setRegions(items);
} catch { } catch {
setRegions([]); setRegions([]);
@@ -113,7 +114,7 @@ export function RegionManager() {
setDialogOpen(true); setDialogOpen(true);
}; };
const openEdit = ( item: any) => { const openEdit = (item: JsonData) => {
setEditingItem(item); setEditingItem(item);
setFormData({ setFormData({
name: item.name || '', code: item.code || '', provider: item.provider || 'aliyun', name: item.name || '', code: item.code || '', provider: item.provider || 'aliyun',
@@ -14,6 +14,7 @@ import {
KubernetesClusterCard, KubernetesClusterCard,
} from './DashboardRecent'; } from './DashboardRecent';
import { SkeletonDashboard } from '@/components/layout/SkeletonCard'; import { SkeletonDashboard } from '@/components/layout/SkeletonCard';
import type { JsonData } from '@/types/common';
/** /**
* Dashboard - 优化后的仪表盘页面 * Dashboard - 优化后的仪表盘页面
@@ -30,13 +31,13 @@ export function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const [initialLoading, setInitialLoading] = useState(true); const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [stats, setStats] = useState<any>(null); const [stats, setStats] = useState<JsonData>(null);
const [servers, setServers] = useState<any[]>([]); const [servers, setServers] = useState<JsonData[]>([]);
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<JsonData[]>([]);
const [alerts, setAlerts] = useState<any[]>([]); const [alerts, setAlerts] = useState<JsonData[]>([]);
const [releases, setReleases] = useState<any[]>([]); const [releases, setReleases] = useState<JsonData[]>([]);
const [expCerts, setExpCerts] = useState<any[]>([]); const [expCerts, setExpCerts] = useState<JsonData[]>([]);
const [metrics, setMetrics] = useState<any[]>([]); const [metrics, setMetrics] = useState<JsonData[]>([]);
const refreshData = useCallback(async () => { const refreshData = useCallback(async () => {
setRefreshing(true); setRefreshing(true);
@@ -16,6 +16,7 @@ import {
} from 'recharts'; } from 'recharts';
import { Cpu, HardDrive, Activity, MemoryStick, Database } from 'lucide-react'; import { Cpu, HardDrive, Activity, MemoryStick, Database } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { JsonData } from '@/types/common';
// Cloud platform color mapping (theme-aware) // Cloud platform color mapping (theme-aware)
const CLOUD_COLORS: Record<string, string> = { const CLOUD_COLORS: Record<string, string> = {
@@ -32,7 +33,7 @@ const CLOUD_COLORS: Record<string, string> = {
}; };
const FALLBACK_CLOUD_COLORS = ['#ff6a00', '#ff9900', '#006eff', '#ff0000', '#0078d4', '#10b981', '#a855f7', '#ec4899']; const FALLBACK_CLOUD_COLORS = ['#ff6a00', '#ff9900', '#006eff', '#ff0000', '#0078d4', '#10b981', '#a855f7', '#ec4899'];
export function computeMonthChange( monthTrend: any[]): { export function computeMonthChange( monthTrend: JsonData[]): {
pct: number; pct: number;
label: string; label: string;
direction: 'up' | 'down' | 'neutral'; direction: 'up' | 'down' | 'neutral';
@@ -65,7 +66,7 @@ function generateMetricsData(avgCpu: number, avgMemory: number, avgDisk: number,
return data; return data;
} }
function buildMetricsFromReal( metrics: any[], avgDisk: number) { function buildMetricsFromReal( metrics: JsonData[], avgDisk: number) {
if (!metrics || metrics.length === 0) return []; if (!metrics || metrics.length === 0) return [];
const buckets: Record<string, { cpu?: number; memory?: number }> = {}; const buckets: Record<string, { cpu?: number; memory?: number }> = {};
for (const m of metrics) { for (const m of metrics) {
@@ -88,12 +89,12 @@ function buildMetricsFromReal( metrics: any[], avgDisk: number) {
} }
// Custom tooltip for charts (theme-aware) // Custom tooltip for charts (theme-aware)
function CustomTooltip({ active, payload, label }: any) { function CustomTooltip({ active, payload, label }: JsonData) {
if (!active || !payload) return null; if (!active || !payload) return null;
return ( return (
<div className="chart-tooltip px-3 py-2"> <div className="chart-tooltip px-3 py-2">
<p className="text-xs font-medium mb-1.5" style={{ color: 'var(--dg-text-secondary)' }}>{label}</p> <p className="text-xs font-medium mb-1.5" style={{ color: 'var(--dg-text-secondary)' }}>{label}</p>
{payload.map(( entry: any, index: number) => ( {payload.map((entry: JsonData, index: number) => (
<div key={index} className="flex items-center gap-2 text-xs"> <div key={index} className="flex items-center gap-2 text-xs">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} /> <span className="w-2 h-2 rounded-full" style={{ backgroundColor: entry.color }} />
<span style={{ color: 'var(--dg-text-muted)' }}>{entry.name}:</span> <span style={{ color: 'var(--dg-text-muted)' }}>{entry.name}:</span>
@@ -106,9 +107,9 @@ function CustomTooltip({ active, payload, label }: any) {
); );
} }
export function CostOverviewCard({ costStats }: { costStats: Record<string, any> }) { export function CostOverviewCard({ costStats }: { costStats: JsonData }) {
const cloudCosts: Record<string, any>[] = costStats?.cloud_costs ?? []; const cloudCosts: JsonData[] = costStats?.cloud_costs ?? [];
const monthTrend: Record<string, any>[] = costStats?.month_trend ?? []; const monthTrend: JsonData[] = costStats?.month_trend ?? [];
const costData = cloudCosts.map((c, i) => ({ const costData = cloudCosts.map((c, i) => ({
name: c.cloud, name: c.cloud,
@@ -201,12 +202,12 @@ export function CostOverviewCard({ costStats }: { costStats: Record<string, any>
); );
} }
export function TopResourceUsageCard({ topUsage }: { topUsage: any}) { export function TopResourceUsageCard({ topUsage }: { topUsage: JsonData}) {
const topCpuServers = (topUsage?.cpu ?? []).slice(0, 5); const topCpuServers = (topUsage?.cpu ?? []).slice(0, 5);
const topMemoryServers = (topUsage?.memory ?? []).slice(0, 5); const topMemoryServers = (topUsage?.memory ?? []).slice(0, 5);
const topDiskServers = (topUsage?.disk ?? []).slice(0, 5); const topDiskServers = (topUsage?.disk ?? []).slice(0, 5);
const renderList = ( items: any[], const renderList = ( items: JsonData[],
color: string, color: string,
BarColor: string, BarColor: string,
icon: React.ReactNode, icon: React.ReactNode,
@@ -280,7 +281,7 @@ export function ResourceUsageCard({
avgCpu: number; avgCpu: number;
avgMemory: number; avgMemory: number;
avgDisk: number; avgDisk: number;
metrics?: any[]; metrics?: JsonData[];
}) { }) {
const realData = buildMetricsFromReal(metrics ?? [], avgDisk); const realData = buildMetricsFromReal(metrics ?? [], avgDisk);
const data = realData.length > 0 ? realData : generateMetricsData(avgCpu, avgMemory, avgDisk, 24); const data = realData.length > 0 ? realData : generateMetricsData(avgCpu, avgMemory, avgDisk, 24);
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Plus, Key, Globe, Container } from 'lucide-react'; import { Plus, Key, Globe, Container } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { credentialApi, deployApi } from '@/api'; import { credentialApi, deployApi } from '@/api';
import type { JsonData } from '@/types/common';
interface Repo { interface Repo {
id: number; name: string; url: string; credential_id: number | null; id: number; name: string; url: string; credential_id: number | null;
@@ -47,7 +48,7 @@ export default function CredentialFormDialog({ open, onOpenChange, type, editId,
const loadCredentials = async () => { const loadCredentials = async () => {
try { try {
const res: Record<string, any> = await credentialApi.list({ page_size: 100 }); const res: JsonData = await credentialApi.list({ page_size: 100 });
setCredentials(res?.list || []); setCredentials(res?.list || []);
} catch { /* silent */ } } catch { /* silent */ }
}; };
@@ -12,6 +12,7 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
import { deployApi } from '@/api'; import { deployApi } from '@/api';
import CredentialFormDialog from './CredentialFormDialog'; import CredentialFormDialog from './CredentialFormDialog';
import ToolConfigTab from './ToolConfigTab'; import ToolConfigTab from './ToolConfigTab';
import type { JsonData } from '@/types/common';
interface Repo { interface Repo {
id: number; name: string; description: string; url: string; id: number; name: string; description: string; url: string;
@@ -110,16 +111,16 @@ export default function DeployCredentials() {
setLoading(true); setLoading(true);
try { try {
const [rR, rReg, rEnv] = await Promise.all([deployApi.getRepos(), deployApi.getRegistries(), deployApi.getEnvironments()]); const [rR, rReg, rEnv] = await Promise.all([deployApi.getRepos(), deployApi.getRegistries(), deployApi.getEnvironments()]);
setRepos((rR as Record<string, any>)?.list || []); setRepos((rR as JsonData)?.list || []);
setRegistries((rReg as Record<string, any>)?.list || []); setRegistries((rReg as JsonData)?.list || []);
setEnvironments((rEnv as Record<string, any>)?.list || []); setEnvironments((rEnv as JsonData)?.list || []);
} catch { toast.error('获取数据失败'); } } catch { toast.error('获取数据失败'); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
useEffect(() => { fetchAll(); }, []); useEffect(() => { fetchAll(); }, []);
const handleDelete = async (type: 'repo' | 'registry' | 'env', item: any) => { const handleDelete = async (type: 'repo' | 'registry' | 'env', item: JsonData) => {
const ok = await _confirm({ title: '删除', description: `确定删除「${item.name || item.display_name}」?`, confirmText: '删除', variant: 'destructive' }); const ok = await _confirm({ title: '删除', description: `确定删除「${item.name || item.display_name}」?`, confirmText: '删除', variant: 'destructive' });
if (!ok) return; if (!ok) return;
try { try {
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { deployApi, userApi, pipelineApi } from '@/api'; import { deployApi, userApi, pipelineApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import type { JsonData } from '@/types/common';
interface ItemRole { interface ItemRole {
id: number; name: string; code: string; pattern: string; role: string; id: number; name: string; code: string; pattern: string; role: string;
@@ -27,11 +28,11 @@ export default function DeployRBAC() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Pipeline list for live preview // Pipeline list for live preview
const [pipelines, setPipelines] = useState<any[]>([]); const [pipelines, setPipelines] = useState<JsonData[]>([]);
const [previewRoleId, setPreviewRoleId] = useState<number | null>(null); const [previewRoleId, setPreviewRoleId] = useState<number | null>(null);
// User assignment // User assignment
const [users, setUsers] = useState<any[]>([]); const [users, setUsers] = useState<JsonData[]>([]);
const [assignments, setAssignments] = useState<AssignedUser[]>([]); const [assignments, setAssignments] = useState<AssignedUser[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>(''); const [selectedUserId, setSelectedUserId] = useState<string>('');
const [assignRoleId, setAssignRoleId] = useState<string>(''); const [assignRoleId, setAssignRoleId] = useState<string>('');
@@ -60,9 +61,9 @@ export default function DeployRBAC() {
pipelineApi.getPipelines({ page_size: 200 }), pipelineApi.getPipelines({ page_size: 200 }),
userApi.getUsers({ page_size: 500 }), userApi.getUsers({ page_size: 500 }),
]); ]);
setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as Record<string, any>)?.list || (rolesRes as Record<string, any>)?.data || []); setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as JsonData)?.list || (rolesRes as JsonData)?.data || []);
setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as Record<string, any>)?.list || (pipesRes as Record<string, any>)?.data || []); setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as JsonData)?.list || (pipesRes as JsonData)?.data || []);
const uList = Array.isArray(usersRes) ? usersRes : (usersRes as Record<string, any>)?.list || (usersRes as Record<string, any>)?.data || []; const uList = Array.isArray(usersRes) ? usersRes : (usersRes as JsonData)?.list || (usersRes as JsonData)?.data || [];
setUsers(uList); setUsers(uList);
} catch { /* */ } } catch { /* */ }
finally { setLoading(false); } finally { setLoading(false); }
@@ -73,7 +74,7 @@ export default function DeployRBAC() {
const loadAssignments = async () => { const loadAssignments = async () => {
if (!selectedUserId) { setAssignments([]); return; } if (!selectedUserId) { setAssignments([]); return; }
try { try {
const res: Record<string, any> = await deployApi.getUserItemRoles(Number(selectedUserId)); const res: JsonData = await deployApi.getUserItemRoles(Number(selectedUserId));
setAssignments(Array.isArray(res) ? res : res?.data || res?.list || []); setAssignments(Array.isArray(res) ? res : res?.data || res?.list || []);
} catch { setAssignments([]); } } catch { setAssignments([]); }
}; };
@@ -16,6 +16,7 @@ import {
import { GitBranch, GitCommit, Clock, ChevronDown, Loader2 } from 'lucide-react'; import { GitBranch, GitCommit, Clock, ChevronDown, Loader2 } from 'lucide-react';
import type { PipelineStage } from './pipeline-helpers'; import type { PipelineStage } from './pipeline-helpers';
import { parseStages, getStatusBadge, getStageStatusIcon } from './pipeline-helpers'; import { parseStages, getStatusBadge, getStageStatusIcon } from './pipeline-helpers';
import type { JsonData } from '@/types/common';
// 流水线详情弹窗(阶段/配置/历史三 Tab) // Logan.Li // 流水线详情弹窗(阶段/配置/历史三 Tab) // Logan.Li
export function PipelineDetailDialog({ export function PipelineDetailDialog({
@@ -29,8 +30,8 @@ export function PipelineDetailDialog({
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
selectedPipeline: any; selectedPipeline?: JsonData;
pipelineRuns: any[]; pipelineRuns: JsonData[];
loadingRuns: boolean; loadingRuns: boolean;
activeStage: string | null; activeStage: string | null;
setActiveStage: (id: string | null) => void; setActiveStage: (id: string | null) => void;
@@ -131,7 +132,7 @@ export function PipelineDetailDialog({
<div className="text-[var(--dg-text-secondary)] text-center py-8 text-sm">暂无执行记录</div> <div className="text-[var(--dg-text-secondary)] text-center py-8 text-sm">暂无执行记录</div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{pipelineRuns.map(( run: any) => ( {pipelineRuns.map((run: JsonData) => (
<div <div
key={run.id} key={run.id}
className="flex items-center gap-3 p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]" className="flex items-center gap-3 p-3 rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]"
@@ -44,6 +44,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { toast } from 'sonner'; import { toast } from 'sonner';
import { pipelineApi, deployApi } from '@/api'; import { pipelineApi, deployApi } from '@/api';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import type { JsonData } from '@/types/common';
// ---- types ---- // ---- types ----
interface Task { interface Task {
@@ -58,7 +59,7 @@ interface LogEntry {
} }
const stageTypeLabels: Record<string, string> = { source: '拉取代码', build: '构建', deploy: '部署', test: '测试', custom: '自定义' }; const stageTypeLabels: Record<string, string> = { source: '拉取代码', build: '构建', deploy: '部署', test: '测试', custom: '自定义' };
const taskIcons: Record<string, any> = { GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container }; const taskIcons: JsonData = { GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container };
function uid() { return `tsk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; } function uid() { return `tsk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; }
function fmtDuration(s: number | undefined): string { function fmtDuration(s: number | undefined): string {
@@ -119,7 +120,7 @@ function TaskEditor({ task, onUpdate }: { task: Task | null; onUpdate: (t: Task)
} }
// ====== Log Dialog ====== // ====== Log Dialog ======
function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () => void; run: any; logs: LogEntry[] }) { function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () => void; run?: JsonData; logs: LogEntry[] }) {
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto"> <DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
@@ -160,16 +161,16 @@ function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () =>
export default function PipelineEditor() { export default function PipelineEditor() {
const { code } = useParams<{ code: string }>(); const { code } = useParams<{ code: string }>();
const pipelineCode = code || ''; const pipelineCode = code || '';
const [pipeline, setPipeline] = useState<any>(null); const [pipeline, setPipeline] = useState<JsonData>(null);
const [stages, setStages] = useState<Stage[]>([]); const [stages, setStages] = useState<Stage[]>([]);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null); const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [expandedStages, setExpandedStages] = useState<Record<string, boolean>>({}); const [expandedStages, setExpandedStages] = useState<Record<string, boolean>>({});
const [activeTab, setActiveTab] = useState('flow'); const [activeTab, setActiveTab] = useState('flow');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [runs, setRuns] = useState<any[]>([]); const [runs, setRuns] = useState<JsonData[]>([]);
const [environments, setEnvironments] = useState<any[]>([]); const [environments, setEnvironments] = useState<JsonData[]>([]);
const [members, setMembers] = useState<any[]>([]); const [members, setMembers] = useState<JsonData[]>([]);
const [loadingMembers, setLoadingMembers] = useState(false); const [loadingMembers, setLoadingMembers] = useState(false);
// trigger config // trigger config
@@ -186,7 +187,7 @@ export default function PipelineEditor() {
// log dialog // log dialog
const [logOpen, setLogOpen] = useState(false); const [logOpen, setLogOpen] = useState(false);
const [logRun, setLogRun] = useState<any>(null); const [logRun, setLogRun] = useState<JsonData>(null);
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
const _confirm = useConfirm(); const _confirm = useConfirm();
@@ -194,7 +195,7 @@ export default function PipelineEditor() {
const loadEnvironments = useCallback(async () => { const loadEnvironments = useCallback(async () => {
try { try {
const res = await deployApi.getEnvironments(); const res = await deployApi.getEnvironments();
setEnvironments(Array.isArray(res) ? res : (res as Record<string, any>)?.list || []); setEnvironments(Array.isArray(res) ? res : (res as JsonData)?.list || []);
} catch { /* silent */ } } catch { /* silent */ }
}, []); }, []);
@@ -202,7 +203,7 @@ export default function PipelineEditor() {
setLoadingMembers(true); setLoadingMembers(true);
try { try {
const res = await pipelineApi.getMembers(pipelineCode); const res = await pipelineApi.getMembers(pipelineCode);
setMembers(Array.isArray(res) ? res : (res as Record<string, any>)?.data || []); setMembers(Array.isArray(res) ? res : (res as JsonData)?.data || []);
} catch { setMembers([]); } } catch { setMembers([]); }
finally { setLoadingMembers(false); } finally { setLoadingMembers(false); }
}, [pipelineCode]); }, [pipelineCode]);
@@ -210,7 +211,7 @@ export default function PipelineEditor() {
const loadRuns = useCallback(async () => { const loadRuns = useCallback(async () => {
try { try {
const res = await pipelineApi.getRuns(pipelineCode); const res = await pipelineApi.getRuns(pipelineCode);
setRuns(Array.isArray(res) ? res : (res as Record<string, any>)?.list || []); setRuns(Array.isArray(res) ? res : (res as JsonData)?.list || []);
} catch { setRuns([]); } } catch { setRuns([]); }
}, [pipelineCode]); }, [pipelineCode]);
@@ -218,7 +219,7 @@ export default function PipelineEditor() {
(async () => { (async () => {
setLoading(true); setLoading(true);
try { try {
const p = await pipelineApi.getPipelineDetail(pipelineCode) as Record<string, any>; const p = await pipelineApi.getPipelineDetail(pipelineCode) as JsonData;
// http.get 已自动解包 json.data,p 即为 pipeline 对象 // http.get 已自动解包 json.data,p 即为 pipeline 对象
if (!p || !p.id) { setPipeline(null); setLoading(false); return; } if (!p || !p.id) { setPipeline(null); setLoading(false); return; }
setPipeline(p); setPipeline(p);
@@ -294,11 +295,11 @@ export default function PipelineEditor() {
} catch { toast.error('停止失败'); } } catch { toast.error('停止失败'); }
}; };
const handleViewLog = async ( run: any) => { const handleViewLog = async (run: JsonData) => {
setLogRun(run); setLogRun(run);
setLogOpen(true); setLogOpen(true);
try { try {
const data = await pipelineApi.getRunLog(pipelineCode, run.id) as Record<string, any>; const data = await pipelineApi.getRunLog(pipelineCode, run.id) as JsonData;
setLogs(data?.logs || []); setLogs(data?.logs || []);
} catch { setLogs([]); } } catch { setLogs([]); }
}; };
@@ -482,7 +483,7 @@ export default function PipelineEditor() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{runs.map(( r: any) => ( {runs.map((r: JsonData) => (
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50"> <tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td> <td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@@ -552,8 +553,8 @@ export default function PipelineEditor() {
{/* Members list — split direct users vs group assignments */} {/* Members list — split direct users vs group assignments */}
{(() => { {(() => {
const directUsers = members.filter(( m: any) => m.username); const directUsers = members.filter((m: JsonData) => m.username);
const groupAssignments = members.filter(( m: any) => m.group_id); const groupAssignments = members.filter((m: JsonData) => m.group_id);
return ( return (
<> <>
{/* Direct users */} {/* Direct users */}
@@ -568,7 +569,7 @@ export default function PipelineEditor() {
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">暂无直接用户</div> <div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">暂无直接用户</div>
) : ( ) : (
<div className="divide-y divide-[var(--dg-border)]"> <div className="divide-y divide-[var(--dg-border)]">
{directUsers.map(( m: any) => ( {directUsers.map((m: JsonData) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3"> <div key={m.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 text-blue-400" /></div> <div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 text-blue-400" /></div>
@@ -597,7 +598,7 @@ export default function PipelineEditor() {
</div> </div>
) : ( ) : (
<div className="divide-y divide-[var(--dg-border)]"> <div className="divide-y divide-[var(--dg-border)]">
{groupAssignments.map(( m: any) => ( {groupAssignments.map((m: JsonData) => (
<div key={m.id} className="flex items-center justify-between px-4 py-3"> <div key={m.id} className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 text-purple-400" /></div> <div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 text-purple-400" /></div>
@@ -12,6 +12,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { pipelineApi } from '@/api'; import { pipelineApi } from '@/api';
import { FileBrowserDialog } from './FileBrowserDialog'; import { FileBrowserDialog } from './FileBrowserDialog';
import type { JsonData } from '@/types/common';
// ═══════════ 阶段定义 (与 PipelineManager 共用) ═══════════ // ═══════════ 阶段定义 (与 PipelineManager 共用) ═══════════
const STAGE_DEFS = [ const STAGE_DEFS = [
@@ -61,7 +62,7 @@ const STATUS_BADGES: Record<string, string> = {
cancelled: 'bg-amber-500/20 text-amber-400 border-amber-500/30', cancelled: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
}; };
const STATUS_ICONS: Record<string, any> = { const STATUS_ICONS: JsonData = {
success: CheckCircle2, success: CheckCircle2,
failed: XCircle, failed: XCircle,
running: Loader2, running: Loader2,
@@ -101,8 +102,8 @@ function getRunStageStats(run: PipelineRun | null): Map<string, string> {
// 如果 run.stages 有数据,直接用 // 如果 run.stages 有数据,直接用
const statuses = parseStageStatuses(run.stages); const statuses = parseStageStatuses(run.stages);
statuses.forEach(s => { statuses.forEach(s => {
if (s.name || (s as Record<string, any>).type) { if (s.name || (s as JsonData).type) {
const key = (s as Record<string, any>).type || (STAGE_DEFS.find(d => d.label === s.name)?.key); const key = (s as JsonData).type || (STAGE_DEFS.find(d => d.label === s.name)?.key);
if (key) map.set(key, s.status); if (key) map.set(key, s.status);
} }
}); });
@@ -128,7 +129,7 @@ function getStageLogSummary(logs: LogEntry[], stageKey: string): string {
export default function PipelineRunDetail() { export default function PipelineRunDetail() {
const { code: pipelineCode } = useParams<{ code: string }>(); const { code: pipelineCode } = useParams<{ code: string }>();
const [pipeline, setPipeline] = useState<any>(null); const [pipeline, setPipeline] = useState<JsonData>(null);
const [runs, setRuns] = useState<PipelineRun[]>([]); const [runs, setRuns] = useState<PipelineRun[]>([]);
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null); const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]); const [logs, setLogs] = useState<LogEntry[]>([]);
@@ -147,7 +148,7 @@ export default function PipelineRunDetail() {
// 加载流水线详情 // 加载流水线详情
const fetchPipeline = useCallback(async () => { const fetchPipeline = useCallback(async () => {
try { try {
const data: Record<string, any> = await pipelineApi.getByCode(pipelineCode); const data: JsonData = await pipelineApi.getByCode(pipelineCode);
setPipeline(data?.data || data); setPipeline(data?.data || data);
} catch { /* silent, pipeline info optional */ } } catch { /* silent, pipeline info optional */ }
}, [pipelineCode]); }, [pipelineCode]);
@@ -156,7 +157,7 @@ export default function PipelineRunDetail() {
const fetchRuns = useCallback(async () => { const fetchRuns = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const data: Record<string, any> = await pipelineApi.getRunsByCode(pipelineCode, pageSize); const data: JsonData = await pipelineApi.getRunsByCode(pipelineCode, pageSize);
const list = data?.list || []; const list = data?.list || [];
setRuns(list); setRuns(list);
if (!selectedRun && list.length > 0) { if (!selectedRun && list.length > 0) {
@@ -180,7 +181,7 @@ export default function PipelineRunDetail() {
// 加载日志 // 加载日志
const fetchLogs = useCallback(async (run: PipelineRun) => { const fetchLogs = useCallback(async (run: PipelineRun) => {
try { try {
const data: Record<string, any> = await pipelineApi.getRunLogStream(run.id); const data: JsonData = await pipelineApi.getRunLogStream(run.id);
if (data?.logs) { if (data?.logs) {
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs; const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
setLogs(Array.isArray(parsed) ? parsed : []); setLogs(Array.isArray(parsed) ? parsed : []);
@@ -11,6 +11,7 @@ import {
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Play } from 'lucide-react'; import { Play } from 'lucide-react';
import type { JsonData } from '@/types/common';
// 执行流水线弹窗(分支/Commit/环境变量) // Logan.Li // 执行流水线弹窗(分支/Commit/环境变量) // Logan.Li
export function PipelineRunDialog({ export function PipelineRunDialog({
@@ -21,7 +22,7 @@ export function PipelineRunDialog({
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
selectedPipeline: any; selectedPipeline?: JsonData;
onRun: () => void; onRun: () => void;
}) { }) {
return ( return (
@@ -12,9 +12,10 @@ import { Button } from '@/components/ui/button';
import { AlertCircle, X, Check, Loader2 } from 'lucide-react'; import { AlertCircle, X, Check, Loader2 } from 'lucide-react';
import { releaseApi } from '@/api'; import { releaseApi } from '@/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { JsonData } from '@/types/common';
interface ReleaseApproveDialogProps { interface ReleaseApproveDialogProps {
release: any | null; release: JsonData | null;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSuccess?: () => void; onSuccess?: () => void;
@@ -15,9 +15,10 @@ import { Rocket, CheckCircle, Loader2, RotateCcw } from 'lucide-react';
import { releaseApi } from '@/api'; import { releaseApi } from '@/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { PIPELINE_STEPS } from './releaseHelpers'; import { PIPELINE_STEPS } from './releaseHelpers';
import type { JsonData } from '@/types/common';
interface ReleaseDetailDialogProps { interface ReleaseDetailDialogProps {
release: any | null; release: JsonData | null;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSuccess?: () => void; onSuccess?: () => void;
@@ -6,6 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { deployApi } from '@/api'; import { deployApi } from '@/api';
import type { JsonData } from '@/types/common';
interface ToolConfig { interface ToolConfig {
id: number; name: string; display_name: string; description: string; id: number; name: string; display_name: string; description: string;
@@ -55,8 +56,8 @@ export default function ToolConfigTab() {
const fetchTools = async () => { const fetchTools = async () => {
setLoading(true); setLoading(true);
try { try {
const res: Record<string, any> = await deployApi.getTools(); const res: JsonData = await deployApi.getTools();
setTools((res as Record<string, any>)?.list || []); setTools((res as JsonData)?.list || []);
} catch { toast.error('获取工具配置失败'); } } catch { toast.error('获取工具配置失败'); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
@@ -65,8 +66,8 @@ export default function ToolConfigTab() {
const openVersionDialog = async (tool: ToolConfig) => { const openVersionDialog = async (tool: ToolConfig) => {
try { try {
const res: Record<string, any> = await deployApi.getToolVersions(tool.id); const res: JsonData = await deployApi.getToolVersions(tool.id);
const versions = (res as Record<string, any>)?.versions || []; const versions = (res as JsonData)?.versions || [];
if (versions.length === 0) { if (versions.length === 0) {
// No versions — install directly // No versions — install directly
handleInstall(tool); handleInstall(tool);
@@ -99,7 +100,7 @@ export default function ToolConfigTab() {
const handleCheckStatus = async (tool: ToolConfig) => { const handleCheckStatus = async (tool: ToolConfig) => {
try { try {
const res: Record<string, any> = await deployApi.checkTool(tool.id); const res: JsonData = await deployApi.checkTool(tool.id);
toast.success(res?.available ? `${tool.display_name} 可用` : `${tool.display_name} 不可用`); toast.success(res?.available ? `${tool.display_name} 可用` : `${tool.display_name} 不可用`);
fetchTools(); fetchTools();
} catch { toast.error('检测失败'); } } catch { toast.error('检测失败'); }
@@ -119,9 +120,9 @@ export default function ToolConfigTab() {
const start = Date.now(); const start = Date.now();
try { try {
// Simulate a quick reachability test by fetching the version URL // Simulate a quick reachability test by fetching the version URL
const res: Record<string, any> = await deployApi.getToolVersions(tool.id); const res: JsonData = await deployApi.getToolVersions(tool.id);
const elapsed = ((Date.now() - start) / 1000).toFixed(1); const elapsed = ((Date.now() - start) / 1000).toFixed(1);
const available = (res as Record<string, any>)?.versions?.length > 0; const available = (res as JsonData)?.versions?.length > 0;
if (available) { if (available) {
toast.success(`${tool.display_name} 镜像可访问 (${elapsed}s)`); toast.success(`${tool.display_name} 镜像可访问 (${elapsed}s)`);
} else { } else {
@@ -8,6 +8,7 @@ import { Label } from '@/components/ui/label';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { deployApi } from '@/api'; import { deployApi } from '@/api';
import type { JsonData } from '@/types/common';
interface Group { interface Group {
id: number; name: string; description: string; id: number; name: string; description: string;
@@ -35,7 +36,7 @@ export default function UserGroupsManager() {
const loadGroups = async () => { const loadGroups = async () => {
setLoading(true); setLoading(true);
try { const res = await deployApi.getGroups(); setGroups((res as Record<string, any>)?.list || []); } try { const res = await deployApi.getGroups(); setGroups((res as JsonData)?.list || []); }
catch { toast.error('加载失败'); } catch { toast.error('加载失败'); }
finally { setLoading(false); } finally { setLoading(false); }
}; };
@@ -12,6 +12,7 @@ import { Maximize2, Minimize2, X, FolderOpen } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import { pipelineApi } from '@/api'; import { pipelineApi } from '@/api';
import type { JsonData } from '@/types/common';
interface WorkspaceTermDialogProps { interface WorkspaceTermDialogProps {
open: boolean; open: boolean;
@@ -41,7 +42,7 @@ export function WorkspaceTermDialog({
useEffect(() => { useEffect(() => {
if (!open || !runId) return; if (!open || !runId) return;
pipelineApi.getWorkspace(runId) pipelineApi.getWorkspace(runId)
.then(( data: any) => setWorkspace(data?.workspace_path || '')) .then((data: JsonData) => setWorkspace(data?.workspace_path || ''))
.catch(() => {}); .catch(() => {});
}, [open, runId]); }, [open, runId]);
@@ -1,5 +1,6 @@
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react'; import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import type { JsonData } from '@/types/common';
// 流水线阶段定义 // Logan.Li // 流水线阶段定义 // Logan.Li
export interface PipelineStage { export interface PipelineStage {
@@ -19,7 +20,7 @@ export const pipelineTemplates = [
]; ];
// 解析 stages 字段(兼容 JSON 数组 / 逗号分隔字符串) // Logan.Li // 解析 stages 字段(兼容 JSON 数组 / 逗号分隔字符串) // Logan.Li
export function parseStages( stages: any): PipelineStage[] { export function parseStages( stages: string): PipelineStage[] {
if (Array.isArray(stages)) return stages; if (Array.isArray(stages)) return stages;
if (typeof stages === 'string') { if (typeof stages === 'string') {
try { try {
@@ -42,7 +43,7 @@ export function nameToCode(name: string): string {
} }
// 状态徽章(使用后端 last_status 字段) // Logan.Li // 状态徽章(使用后端 last_status 字段) // Logan.Li
export function getStatusBadge( status: any) { export function getStatusBadge( status: string) {
switch (status) { switch (status) {
case 'success': case 'success':
return ( return (
@@ -70,7 +70,7 @@ export function PipelineCard({
#{String(pipeline.run_count)} #{String(pipeline.run_count)}
</Badge> </Badge>
)} )}
{getStatusBadge(pipeline.last_status)} {getStatusBadge(String(pipeline.last_status))}
{environment && ( {environment && (
<Badge <Badge
style={{ backgroundColor: `${environment.color}20`, color: environment.color as string }} style={{ backgroundColor: `${environment.color}20`, color: environment.color as string }}
@@ -1,5 +1,6 @@
// 发布相关共享工具函数与常量 // Logan.Li // 发布相关共享工具函数与常量 // Logan.Li
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { JsonData } from '@/types/common';
import { import {
CheckCircle, CheckCircle,
XCircle, XCircle,
@@ -9,7 +10,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
// 发布状态徽章(列表 + 详情共用) // Logan.Li // 发布状态徽章(列表 + 详情共用) // Logan.Li
export function getStatusBadge( status: any) { export function getStatusBadge( status: string) {
switch (status) { switch (status) {
case 'success': case 'success':
return ( return (
@@ -59,7 +60,7 @@ export function getStatusBadge( status: any) {
} }
// 环境徽章 // Logan.Li // 环境徽章 // Logan.Li
export function getEnvBadge( env: any) { export function getEnvBadge( env: string) {
const colors: Record<string, string> = { const colors: Record<string, string> = {
dev: 'border-slate-500/30 text-slate-400 bg-slate-500/10', dev: 'border-slate-500/30 text-slate-400 bg-slate-500/10',
test: 'border-blue-500/30 text-blue-400 bg-blue-500/10', test: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
@@ -1,6 +1,7 @@
// 发布管理(审批流)共享工具函数与常量 // Logan.Li // 发布管理(审批流)共享工具函数与常量 // Logan.Li
// 注意:本文件专用于 ReleaseManager(含审批流,状态标签与 AppRelease 的 releaseHelpers 不同) // 注意:本文件专用于 ReleaseManager(含审批流,状态标签与 AppRelease 的 releaseHelpers 不同)
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import type { JsonData } from '@/types/common';
import { import {
Clock, Clock,
Check, Check,
@@ -19,7 +20,7 @@ export const ENVIRONMENTS: Record<string, { label: string; color: string }> = {
}; };
// 发布状态徽章(审批流:待审批/已审批/已驳回/发布中/发布成功/发布失败/已回滚) // Logan.Li // 发布状态徽章(审批流:待审批/已审批/已驳回/发布中/发布成功/发布失败/已回滚) // Logan.Li
export function getStatusBadge( status: any) { export function getStatusBadge( status: string) {
const map: Record<string, { icon: any; label: string; cls: string }> = { const map: Record<string, { icon: any; label: string; cls: string }> = {
pending: { icon: Clock, label: '待审批', cls: 'border-amber-500/30 text-amber-400 bg-amber-500/10' }, pending: { icon: Clock, label: '待审批', cls: 'border-amber-500/30 text-amber-400 bg-amber-500/10' },
approved: { icon: Check, label: '已审批', cls: 'border-purple-500/30 text-purple-400 bg-purple-500/10' }, approved: { icon: Check, label: '已审批', cls: 'border-purple-500/30 text-purple-400 bg-purple-500/10' },
@@ -41,7 +42,7 @@ export function getStatusBadge( status: any) {
} }
// 环境徽章 // Logan.Li // 环境徽章 // Logan.Li
export function getEnvironmentBadge( env: any) { export function getEnvironmentBadge( env: string) {
const cfg = ENVIRONMENTS[env]; const cfg = ENVIRONMENTS[env];
return ( return (
<Badge variant="outline" className={cfg?.color || 'border-slate-500/30 text-slate-400'}> <Badge variant="outline" className={cfg?.color || 'border-slate-500/30 text-slate-400'}>
@@ -11,19 +11,20 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { RefreshCw, ChevronRight, BarChart3, Download, Cpu, HardDrive } from 'lucide-react'; import { RefreshCw, ChevronRight, BarChart3, Download, Cpu, HardDrive } from 'lucide-react';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import type { JsonData } from '@/types/common';
export default function CapacityPage() { export default function CapacityPage() {
const { clusterCode } = useParams<{ clusterCode: string }>(); const { clusterCode } = useParams<{ clusterCode: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [capacity, setCapacity] = useState<any[]>([]); const [capacity, setCapacity] = useState<JsonData[]>([]);
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<JsonData[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]); const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNs, setSelectedNs] = useState('all'); const [selectedNs, setSelectedNs] = useState('all');
const [activeCluster, setActiveCluster] = useState(clusterCode || ''); const [activeCluster, setActiveCluster] = useState(clusterCode || '');
useEffect(() => { useEffect(() => {
kubernetesApi.getClusters().then(( res: any) => { kubernetesApi.getClusters().then((res: JsonData) => {
const list = res?.list || []; const list = res?.list || [];
setClusters(list); setClusters(list);
if (!activeCluster && list.length > 0) { if (!activeCluster && list.length > 0) {
@@ -36,7 +37,7 @@ export default function CapacityPage() {
useEffect(() => { useEffect(() => {
if (!activeCluster) return; if (!activeCluster) return;
kubernetesApi.getNamespaces(activeCluster).then(( res: any) => { kubernetesApi.getNamespaces(activeCluster).then((res: JsonData) => {
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || [])); setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
}).catch(() => {}); }).catch(() => {});
}, [activeCluster]); }, [activeCluster]);
@@ -46,7 +47,7 @@ export default function CapacityPage() {
setLoading(true); setLoading(true);
try { try {
const ns = selectedNs === 'all' ? undefined : selectedNs; const ns = selectedNs === 'all' ? undefined : selectedNs;
const res: Record<string, any> = await kubernetesApi.getCapacity(activeCluster, ns); const res: JsonData = await kubernetesApi.getCapacity(activeCluster, ns);
setCapacity(Array.isArray(res) ? res : (res?.list || [])); setCapacity(Array.isArray(res) ? res : (res?.list || []));
} catch { setCapacity([]); } finally { setLoading(false); } } catch { setCapacity([]); } finally { setLoading(false); }
}, [activeCluster, selectedNs]); }, [activeCluster, selectedNs]);
@@ -54,8 +55,8 @@ export default function CapacityPage() {
useEffect(() => { loadCapacity(); }, [loadCapacity]); useEffect(() => { loadCapacity(); }, [loadCapacity]);
const rows = useMemo(() => const rows = useMemo(() =>
capacity.flatMap(( d: any) => capacity.flatMap((d: JsonData) =>
(d.containers || []).map(( c: any) => ({ (d.containers || []).map((c: JsonData) => ({
namespace: d.namespace, namespace: d.namespace,
deployment: d.name, deployment: d.name,
replicas: d.replicas, replicas: d.replicas,
@@ -83,7 +84,7 @@ export default function CapacityPage() {
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/capacity`); }}> <Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/capacity`); }}>
<SelectTrigger className="w-40 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger> <SelectTrigger className="w-40 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700"> <SelectContent className="bg-slate-800 border-slate-700">
{clusters.map(( c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)} {clusters.map((c: JsonData) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={selectedNs} onValueChange={setSelectedNs}> <Select value={selectedNs} onValueChange={setSelectedNs}>
@@ -25,20 +25,21 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import type { JsonData } from '@/types/common';
const CHART_COLORS = ['#3B82F6', '#207060', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316']; const CHART_COLORS = ['#3B82F6', '#207060', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#14B8A6', '#F97316'];
export default function ClusterComparePage() { export default function ClusterComparePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [data, setData] = useState<any>(null); const [data, setData] = useState<JsonData>(null);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const res: Record<string, any> = await kubernetesApi.getMultiClusterComparison(); const res: JsonData = await kubernetesApi.getMultiClusterComparison();
setData(res || null); setData(res || null);
} catch (e) { } catch (e) {
setError(e?.message || '加载失败'); setError(e?.message || '加载失败');
@@ -50,11 +51,11 @@ export default function ClusterComparePage() {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const clusters: Record<string, any>[] = data?.clusters || []; const clusters: JsonData[] = data?.clusters || [];
const totals: Record<string, any> = data?.totals || {}; const totals: JsonData = data?.totals || {};
// 图表数据准备 // 图表数据准备
const resourceBarData = clusters.map(( c: any) => ({ const resourceBarData = clusters.map((c: JsonData) => ({
name: c.code || c.name, name: c.code || c.name,
节点: c.node_count || 0, 节点: c.node_count || 0,
Pod: c.pod_count || 0, Pod: c.pod_count || 0,
@@ -62,22 +63,22 @@ export default function ClusterComparePage() {
Service: c.service_count || 0, Service: c.service_count || 0,
})); }));
const usageBarData = clusters.map(( c: any) => ({ const usageBarData = clusters.map((c: JsonData) => ({
name: c.code || c.name, name: c.code || c.name,
'CPU %': Number((c.cpu_usage || 0).toFixed(1)), 'CPU %': Number((c.cpu_usage || 0).toFixed(1)),
'内存 %': Number((c.memory_usage || 0).toFixed(1)), '内存 %': Number((c.memory_usage || 0).toFixed(1)),
})); }));
const radarData = clusters.length > 0 ? [ const radarData = clusters.length > 0 ? [
{ metric: '节点数', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, c.node_count || 0])) }, { metric: '节点数', ...Object.fromEntries(clusters.map((c: JsonData) => [c.code || c.name, c.node_count || 0])) },
{ metric: 'Pod数', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, Math.min(c.pod_count || 0, 100)])) }, { metric: 'Pod数', ...Object.fromEntries(clusters.map((c: JsonData) => [c.code || c.name, Math.min(c.pod_count || 0, 100)])) },
{ metric: '部署数', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, c.deployment_count || 0])) }, { metric: '部署数', ...Object.fromEntries(clusters.map((c: JsonData) => [c.code || c.name, c.deployment_count || 0])) },
{ metric: 'CPU使用', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, Number((c.cpu_usage || 0).toFixed(1))])) }, { metric: 'CPU使用', ...Object.fromEntries(clusters.map((c: JsonData) => [c.code || c.name, Number((c.cpu_usage || 0).toFixed(1))])) },
{ metric: '内存使用', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, Number((c.memory_usage || 0).toFixed(1))])) }, { metric: '内存使用', ...Object.fromEntries(clusters.map((c: JsonData) => [c.code || c.name, Number((c.memory_usage || 0).toFixed(1))])) },
{ metric: 'CPU核数', ...Object.fromEntries(clusters.map(( c: any) => [c.code || c.name, c.total_cpu_cores || 0])) }, { metric: 'CPU核数', ...Object.fromEntries(clusters.map((c: JsonData) => [c.code || c.name, c.total_cpu_cores || 0])) },
] : []; ] : [];
const nodeRolePie = clusters.flatMap(( c: any) => [ const nodeRolePie = clusters.flatMap((c: JsonData) => [
{ name: `${c.code}-Master`, value: c.master_count || 0, fill: CHART_COLORS[1] }, { name: `${c.code}-Master`, value: c.master_count || 0, fill: CHART_COLORS[1] },
{ name: `${c.code}-Worker`, value: c.worker_count || 0, fill: CHART_COLORS[0] }, { name: `${c.code}-Worker`, value: c.worker_count || 0, fill: CHART_COLORS[0] },
]).filter(d => d.value > 0); ]).filter(d => d.value > 0);
@@ -221,7 +222,7 @@ export default function ClusterComparePage() {
<PolarGrid stroke="#1e293b" /> <PolarGrid stroke="#1e293b" />
<PolarAngleAxis dataKey="metric" tick={{ fill: '#94a3b8', fontSize: 11 }} /> <PolarAngleAxis dataKey="metric" tick={{ fill: '#94a3b8', fontSize: 11 }} />
<PolarRadiusAxis tick={{ fill: '#64748b', fontSize: 10 }} /> <PolarRadiusAxis tick={{ fill: '#64748b', fontSize: 10 }} />
{clusters.map(( c: any, i: number) => ( {clusters.map((c: JsonData, i: number) => (
<Radar key={i} name={c.code || c.name} <Radar key={i} name={c.code || c.name}
dataKey={c.code || c.name} dataKey={c.code || c.name}
stroke={CHART_COLORS[i % CHART_COLORS.length]} stroke={CHART_COLORS[i % CHART_COLORS.length]}
@@ -248,7 +249,7 @@ export default function ClusterComparePage() {
<PieChart> <PieChart>
<Pie data={nodeRolePie} cx="50%" cy="45%" innerRadius={50} outerRadius={90} <Pie data={nodeRolePie} cx="50%" cy="45%" innerRadius={50} outerRadius={90}
paddingAngle={3} dataKey="value" paddingAngle={3} dataKey="value"
label={( e: any) => `${e.name}: ${e.value}`} label={(e: JsonData) => `${e.name}: ${e.value}`}
labelLine={{ stroke: '#475569' }} labelLine={{ stroke: '#475569' }}
> >
{nodeRolePie.map((_, i) => ( {nodeRolePie.map((_, i) => (
@@ -293,7 +294,7 @@ export default function ClusterComparePage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{clusters.map(( c: any) => ( {clusters.map((c: JsonData) => (
<TableRow key={c.id} <TableRow key={c.id}
className="border-slate-800/50 hover:bg-slate-800/30 cursor-pointer" className="border-slate-800/50 hover:bg-slate-800/30 cursor-pointer"
onClick={() => navigate(`/kubernetes/${c.code}`)} onClick={() => navigate(`/kubernetes/${c.code}`)}
@@ -22,8 +22,9 @@ import {
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import type { JsonData } from '@/types/common';
const NAMESPACE_ICON: Record<string, React.ComponentType<any>> = { const NAMESPACE_ICON: Record<string, React.ComponentType<Record<string, unknown>>> = {
'kube-system': Server, 'kube-system': Server,
'kube-public': Shield, 'kube-public': Shield,
'kube-node-lease': Activity, 'kube-node-lease': Activity,
@@ -35,19 +36,19 @@ export default function ClusterOverviewPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const [, setClusters] = useState<any[]>([]); const [, setClusters] = useState<JsonData[]>([]);
const [nodes, setNodes] = useState<any[]>([]); const [nodes, setNodes] = useState<JsonData[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]); const [namespaces, setNamespaces] = useState<string[]>([]);
const [clusterInfo, setClusterInfo] = useState<any>(null); const [clusterInfo, setClusterInfo] = useState<JsonData>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// Load cluster list + current cluster info // Load cluster list + current cluster info
const loadClusterInfo = useCallback(async () => { const loadClusterInfo = useCallback(async () => {
try { try {
const res: Record<string, any> = await kubernetesApi.getClusters(); const res: JsonData = await kubernetesApi.getClusters();
const list = res?.list || []; const list = res?.list || [];
setClusters(list); setClusters(list);
const current = list.find(( c: any) => (c.code || String(c.id)) === clusterCode); const current = list.find((c: JsonData) => (c.code || String(c.id)) === clusterCode);
setClusterInfo(current || null); setClusterInfo(current || null);
if (!current && list.length > 0 && clusterCode) { if (!current && list.length > 0 && clusterCode) {
toast({ title: '集群未找到', description: `找不到集群 ${clusterCode}`, variant: 'destructive' }); toast({ title: '集群未找到', description: `找不到集群 ${clusterCode}`, variant: 'destructive' });
@@ -72,7 +73,7 @@ export default function ClusterOverviewPage() {
useEffect(() => { loadClusterInfo(); }, [loadClusterInfo]); useEffect(() => { loadClusterInfo(); }, [loadClusterInfo]);
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const getNodeRole = ( node: any) => { const getNodeRole = (node: JsonData) => {
if (node?.role === 'master' || node?.role === 'control-plane') return 'bg-blue-500/20 text-blue-400 border-blue-500/30'; if (node?.role === 'master' || node?.role === 'control-plane') return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
return 'bg-slate-500/20 text-slate-400 border-slate-500/30'; return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
}; };
@@ -157,7 +158,7 @@ export default function ClusterOverviewPage() {
) : nodes.length === 0 ? ( ) : nodes.length === 0 ? (
<TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Cpu className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无节点</TableCell></TableRow> <TableRow><TableCell colSpan={8} className="text-center text-slate-500 py-12"><Cpu className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无节点</TableCell></TableRow>
) : ( ) : (
nodes.map(( n: any) => { nodes.map((n: JsonData) => {
const cpuCores = n.cpu || 0; const cpuCores = n.cpu || 0;
const cpuUsageM = n.cpu_usage_m ? parseFloat(n.cpu_usage_m.replace('m','')) : 0; const cpuUsageM = n.cpu_usage_m ? parseFloat(n.cpu_usage_m.replace('m','')) : 0;
const cpuPct = cpuCores > 0 ? (cpuUsageM / 1000 / cpuCores) * 100 : 0; const cpuPct = cpuCores > 0 ? (cpuUsageM / 1000 / cpuCores) * 100 : 0;
@@ -15,13 +15,14 @@ import { DeploymentLogsTab } from './deployment-detail/DeploymentLogsTab';
import { DeploymentHpaTab } from './deployment-detail/DeploymentHpaTab'; import { DeploymentHpaTab } from './deployment-detail/DeploymentHpaTab';
import { DeploymentPodModals } from './deployment-detail/DeploymentPodModals'; import { DeploymentPodModals } from './deployment-detail/DeploymentPodModals';
import { YamlViewer } from './YamlViewer'; import { YamlViewer } from './YamlViewer';
import type { JsonData } from '@/types/common';
export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) { export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBack }: DeploymentDetailProps) {
const { toast } = useToast(); const { toast } = useToast();
const confirm = useConfirm(); const confirm = useConfirm();
const [deployment, setDeployment] = useState<any>(null); const [deployment, setDeployment] = useState<JsonData>(null);
const [pods, setPods] = useState<any[]>([]); const [pods, setPods] = useState<JsonData[]>([]);
const [hpas, setHpas] = useState<any[]>([]); const [hpas, setHpas] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(''); const [actionLoading, setActionLoading] = useState('');
const [, setScaleValue] = useState(0); const [, setScaleValue] = useState(0);
@@ -45,19 +46,19 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []), kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []),
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => ({ items: [] })), kubernetesApi.getHPAs(clusterCode, namespace).catch(() => ({ items: [] })),
]); ]);
const toArray = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || [])); const toArray = (v: JsonData) => (Array.isArray(v) ? v : (v?.list || v?.items || []));
const allDeps = toArray(depRes); const allDeps = toArray(depRes);
const allPods = toArray(podRes); const allPods = toArray(podRes);
const allHpas = toArray(hpaRes); const allHpas = toArray(hpaRes);
const dep = allDeps.find((d: any) => d.name === deploymentName && d.namespace === namespace); const dep = allDeps.find((d: JsonData) => d.name === deploymentName && d.namespace === namespace);
setDeployment(dep || null); setDeployment(dep || null);
if (dep) { if (dep) {
setScaleValue(dep.replicas || 0); setScaleValue(dep.replicas || 0);
setNewImage(dep.image || ''); setNewImage(dep.image || '');
} }
setPods(allPods.filter((p: any) => p.namespace === namespace && p.name.startsWith(deploymentName))); setPods(allPods.filter((p: JsonData) => p.namespace === namespace && p.name.startsWith(deploymentName)));
setHpas(allHpas.filter((h: any) => h.target_name === deploymentName && h.namespace === namespace)); setHpas(allHpas.filter((h: JsonData) => h.target_name === deploymentName && h.namespace === namespace));
} catch (e) { } catch (e) {
console.error('加载详情失败:', e); console.error('加载详情失败:', e);
} finally { } finally {
@@ -103,7 +104,7 @@ export function DeploymentDetail({ clusterCode, namespace, deploymentName, onBac
} finally { setActionLoading(''); } } finally { setActionLoading(''); }
}; };
const handleHpaToggle = async (hpa: any) => { const handleHpaToggle = async (hpa: JsonData) => {
const ok = await confirm({ const ok = await confirm({
title: '删除 HPA', title: '删除 HPA',
description: `确定删除 HPA "${hpa.name}"?`, description: `确定删除 HPA "${hpa.name}"?`,
@@ -14,13 +14,14 @@ import { kubernetesApi } from '@/api';
import { saveDeploymentManifest } from './deployment-manifest-save'; import { saveDeploymentManifest } from './deployment-manifest-save';
import { SchedulingSection, type ScheduleMode } from './deployment-edit-advanced/SchedulingSection'; import { SchedulingSection, type ScheduleMode } from './deployment-edit-advanced/SchedulingSection';
import { DnsHostSection } from './deployment-edit-advanced/DnsHostSection'; import { DnsHostSection } from './deployment-edit-advanced/DnsHostSection';
import type { JsonData } from '@/types/common';
interface Props { interface Props {
clusterCode: string; clusterCode: string;
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
manifest: any | null; manifest: JsonData | null;
onSaved: (updated?: any) => void; onSaved: (updated?: JsonData) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -36,7 +37,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
const { toast } = useToast(); const { toast } = useToast();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest); const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<any>(manifest || {}); const [rawManifest, setRawManifest] = useState<JsonData>(manifest || {});
// 更新策略 // 更新策略
const [strategyType, setStrategyType] = useState<'RollingUpdate' | 'Recreate'>('RollingUpdate'); const [strategyType, setStrategyType] = useState<'RollingUpdate' | 'Recreate'>('RollingUpdate');
@@ -119,13 +120,13 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
// 加载节点列表 // 加载节点列表
if (clusterCode) { if (clusterCode) {
setNodesLoading(true); setNodesLoading(true);
kubernetesApi.getClusterNodes(clusterCode).then((res: any) => { kubernetesApi.getClusterNodes(clusterCode).then((res: JsonData) => {
const nodes: Record<string, any>[] = Array.isArray(res) ? res : (res?.items || res?.list || []); const nodes: JsonData[] = Array.isArray(res) ? res : (res?.items || res?.list || []);
const mapped = nodes.map((n: any) => { const mapped = nodes.map((n: JsonData) => {
const meta = n.metadata || {}; const meta = n.metadata || {};
const status = n.status || {}; const status = n.status || {};
const addresses: Record<string, any>[] = Array.isArray(status.addresses) ? status.addresses : []; const addresses: JsonData[] = Array.isArray(status.addresses) ? status.addresses : [];
const internalIP = addresses.find((a: any) => a.type === 'InternalIP')?.address || ''; const internalIP = addresses.find((a: JsonData) => a.type === 'InternalIP')?.address || '';
return { name: meta.name || '', ip: internalIP, labels: meta.labels || {} }; return { name: meta.name || '', ip: internalIP, labels: meta.labels || {} };
}); });
setAvailableNodes(mapped); setAvailableNodes(mapped);
@@ -143,7 +144,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
} }
// tolerations // tolerations
const tols: Record<string, any>[] = Array.isArray(podSpec.tolerations) ? podSpec.tolerations : []; const tols: JsonData[] = Array.isArray(podSpec.tolerations) ? podSpec.tolerations : [];
setTolerations(tols.map(t => ({ setTolerations(tols.map(t => ({
key: t.key || '', key: t.key || '',
operator: t.operator || 'Equal', operator: t.operator || 'Equal',
@@ -172,7 +173,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
setShareProcessNamespace(!!podSpec.shareProcessNamespace); setShareProcessNamespace(!!podSpec.shareProcessNamespace);
// hostAliases(M8 补全) // hostAliases(M8 补全)
const ha: Record<string, any>[] = Array.isArray(podSpec.hostAliases) ? podSpec.hostAliases : []; const ha: JsonData[] = Array.isArray(podSpec.hostAliases) ? podSpec.hostAliases : [];
setHostAliases(ha.map(a => ({ setHostAliases(ha.map(a => ({
ip: a.ip || '', ip: a.ip || '',
hostnames: Array.isArray(a.hostnames) ? a.hostnames.join(', ') : '', hostnames: Array.isArray(a.hostnames) ? a.hostnames.join(', ') : '',
@@ -182,7 +183,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
const dc = podSpec.dnsConfig || {}; const dc = podSpec.dnsConfig || {};
setDnsNameservers(Array.isArray(dc.nameservers) ? dc.nameservers.join('\n') : ''); setDnsNameservers(Array.isArray(dc.nameservers) ? dc.nameservers.join('\n') : '');
setDnsSearches(Array.isArray(dc.searches) ? dc.searches.join('\n') : ''); setDnsSearches(Array.isArray(dc.searches) ? dc.searches.join('\n') : '');
const opts: Record<string, any>[] = Array.isArray(dc.options) ? dc.options : []; const opts: JsonData[] = Array.isArray(dc.options) ? dc.options : [];
setDnsOptions(opts.map(o => ({ name: o.name || '', value: o.value || '' }))); setDnsOptions(opts.map(o => ({ name: o.name || '', value: o.value || '' })));
setLoading(false); setLoading(false);
@@ -229,7 +230,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
if (podSpec.nodeName) delete podSpec.nodeName; if (podSpec.nodeName) delete podSpec.nodeName;
if (podSpec.affinity) delete podSpec.affinity.nodeAffinity; if (podSpec.affinity) delete podSpec.affinity.nodeAffinity;
} else if (scheduleMode === 'nodeAffinity') { } else if (scheduleMode === 'nodeAffinity') {
const matchExpressions: Record<string, any>[] = labelSelectorKv const matchExpressions: JsonData[] = labelSelectorKv
.filter(s => s.key.trim()) .filter(s => s.key.trim())
.map(s => ({ key: s.key.trim(), operator: 'In', values: [s.value] })); .map(s => ({ key: s.key.trim(), operator: 'In', values: [s.value] }));
if (matchExpressions.length > 0) { if (matchExpressions.length > 0) {
@@ -250,7 +251,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
const tolsK8s = tolerations const tolsK8s = tolerations
.filter(t => t.key || t.operator === 'Exists') .filter(t => t.key || t.operator === 'Exists')
.map(t => { .map(t => {
const out: Record<string, any> = {}; const out: JsonData = {};
if (t.key) out.key = t.key; if (t.key) out.key = t.key;
if (t.operator) out.operator = t.operator; if (t.operator) out.operator = t.operator;
if (t.value && t.operator !== 'Exists') out.value = t.value; if (t.value && t.operator !== 'Exists') out.value = t.value;
@@ -269,7 +270,7 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
if (serviceAccountName) podSpec.serviceAccountName = serviceAccountName; if (serviceAccountName) podSpec.serviceAccountName = serviceAccountName;
// Pod 安全上下文 // Pod 安全上下文
const sc: Record<string, any> = {}; const sc: JsonData = {};
if (podFsGroup) sc.fsGroup = Number(podFsGroup); if (podFsGroup) sc.fsGroup = Number(podFsGroup);
if (podRunAsUser) sc.runAsUser = Number(podRunAsUser); if (podRunAsUser) sc.runAsUser = Number(podRunAsUser);
if (podRunAsGroup) sc.runAsGroup = Number(podRunAsGroup); if (podRunAsGroup) sc.runAsGroup = Number(podRunAsGroup);
@@ -295,13 +296,13 @@ export function DeploymentEditAdvanced({ clusterCode, namespace, deploymentName,
else delete podSpec.hostAliases; else delete podSpec.hostAliases;
// dnsConfig(M8 补全)— 仅 dnsPolicy=None 时 dnsConfig 才有意义,但仍允许编辑 // dnsConfig(M8 补全)— 仅 dnsPolicy=None 时 dnsConfig 才有意义,但仍允许编辑
const dcObj: Record<string, any> = {}; const dcObj: JsonData = {};
const ns = dnsNameservers.split('\n').map(s => s.trim()).filter(Boolean); const ns = dnsNameservers.split('\n').map(s => s.trim()).filter(Boolean);
if (ns.length > 0) dcObj.nameservers = ns; if (ns.length > 0) dcObj.nameservers = ns;
const sr = dnsSearches.split('\n').map(s => s.trim()).filter(Boolean); const sr = dnsSearches.split('\n').map(s => s.trim()).filter(Boolean);
if (sr.length > 0) dcObj.searches = sr; if (sr.length > 0) dcObj.searches = sr;
const dkOpts = dnsOptions.filter(o => o.name.trim()).map(o => { const dkOpts = dnsOptions.filter(o => o.name.trim()).map(o => {
const out: Record<string, any> = { name: o.name.trim() }; const out: JsonData = { name: o.name.trim() };
if (o.value.trim()) out.value = o.value.trim(); if (o.value.trim()) out.value = o.value.trim();
return out; return out;
}); });
@@ -10,13 +10,14 @@ import { Badge } from '@/components/ui/badge';
import { Plus, Trash2, Save, Loader2, Tags, FileText, Hash, Lock } from 'lucide-react'; import { Plus, Trash2, Save, Loader2, Tags, FileText, Hash, Lock } from 'lucide-react';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { saveDeploymentManifest } from './deployment-manifest-save'; import { saveDeploymentManifest } from './deployment-manifest-save';
import type { JsonData } from '@/types/common';
interface DeploymentEditBasicProps { interface DeploymentEditBasicProps {
clusterCode: string; clusterCode: string;
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
manifest: any | null; manifest: JsonData | null;
onSaved: (updated?: any) => void; onSaved: (updated?: JsonData) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -40,7 +41,7 @@ export function DeploymentEditBasic({ clusterCode, namespace, deploymentName, ma
const { toast } = useToast(); const { toast } = useToast();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest); const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<any>(manifest || {}); const [rawManifest, setRawManifest] = useState<JsonData>(manifest || {});
const [replicas, setReplicas] = useState(1); const [replicas, setReplicas] = useState(1);
const [labels, setLabels] = useState<Array<{ key: string; value: string }>>([]); const [labels, setLabels] = useState<Array<{ key: string; value: string }>>([]);
const [annotations, setAnnotations] = useState<Array<{ key: string; value: string }>>([]); const [annotations, setAnnotations] = useState<Array<{ key: string; value: string }>>([]);
@@ -25,12 +25,13 @@ import {
toContainerForm, emptyContainer, containerToK8s, toContainerForm, emptyContainer, containerToK8s,
} from './deployment-edit/container-utils'; } from './deployment-edit/container-utils';
import type { ContainerForm, DeploymentEditContainersProps } from './deployment-edit/types'; import type { ContainerForm, DeploymentEditContainersProps } from './deployment-edit/types';
import type { JsonData } from '@/types/common';
export function DeploymentEditContainers({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditContainersProps) { export function DeploymentEditContainers({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: DeploymentEditContainersProps) {
const { toast } = useToast(); const { toast } = useToast();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest); const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<any>(manifest || {}); const [rawManifest, setRawManifest] = useState<JsonData>(manifest || {});
const [containers, setContainers] = useState<ContainerForm[]>([]); const [containers, setContainers] = useState<ContainerForm[]>([]);
const [initContainers, setInitContainers] = useState<ContainerForm[]>([]); const [initContainers, setInitContainers] = useState<ContainerForm[]>([]);
const [imagePullSecrets, setImagePullSecrets] = useState<string[]>([]); const [imagePullSecrets, setImagePullSecrets] = useState<string[]>([]);
@@ -41,7 +42,7 @@ export function DeploymentEditContainers({ clusterCode, namespace, deploymentNam
const podSpec = manifest?.spec?.template?.spec || {}; const podSpec = manifest?.spec?.template?.spec || {};
setContainers((Array.isArray(podSpec.containers) ? podSpec.containers : []).map(toContainerForm)); setContainers((Array.isArray(podSpec.containers) ? podSpec.containers : []).map(toContainerForm));
setInitContainers((Array.isArray(podSpec.initContainers) ? podSpec.initContainers : []).map(toContainerForm)); setInitContainers((Array.isArray(podSpec.initContainers) ? podSpec.initContainers : []).map(toContainerForm));
setImagePullSecrets((Array.isArray(podSpec.imagePullSecrets) ? podSpec.imagePullSecrets : []).map((s: any) => s.name || '')); setImagePullSecrets((Array.isArray(podSpec.imagePullSecrets) ? podSpec.imagePullSecrets : []).map((s: JsonData) => s.name || ''));
setLoading(false); setLoading(false);
}, [manifest]); }, [manifest]);
@@ -27,13 +27,14 @@ import {
emptyVolumeEntry, emptyVolumeEntry,
emptyMountRow, emptyMountRow,
} from './deployment-volume-utils'; } from './deployment-volume-utils';
import type { JsonData } from '@/types/common';
interface Props { interface Props {
clusterCode: string; clusterCode: string;
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
manifest: any | null; manifest: JsonData | null;
onSaved: (updated?: any) => void; onSaved: (updated?: JsonData) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -54,10 +55,10 @@ export function DeploymentEditStorage({ clusterCode, namespace, deploymentName,
const { toast } = useToast(); const { toast } = useToast();
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(!manifest); const [loading, setLoading] = useState(!manifest);
const [rawManifest, setRawManifest] = useState<any>(manifest || {}); const [rawManifest, setRawManifest] = useState<JsonData>(manifest || {});
const [entries, setEntries] = useState<VolumeEntry[]>([]); const [entries, setEntries] = useState<VolumeEntry[]>([]);
const [containers, setContainers] = useState<string[]>([]); const [containers, setContainers] = useState<string[]>([]);
const [passthroughVolumes, setPassthroughVolumes] = useState<Record<string, any>[]>([]); const [passthroughVolumes, setPassthroughVolumes] = useState<JsonData[]>([]);
const [resources, setResources] = useState<NamespaceResourceLists>({ configMaps: [], secrets: [], pvcs: [] }); const [resources, setResources] = useState<NamespaceResourceLists>({ configMaps: [], secrets: [], pvcs: [] });
const [resourcesLoading, setResourcesLoading] = useState(false); const [resourcesLoading, setResourcesLoading] = useState(false);
const [createResource, setCreateResource] = useState<CreateResourceKind | null>(null); const [createResource, setCreateResource] = useState<CreateResourceKind | null>(null);
@@ -24,6 +24,7 @@ import { HistoryTab } from './deployment-view/HistoryTab';
import type { DeploymentViewProps, SvcItem, IngItem, EventItem } from './deployment-view/types'; import type { DeploymentViewProps, SvcItem, IngItem, EventItem } from './deployment-view/types';
export type { DeploymentViewProps } from './deployment-view/types'; export type { DeploymentViewProps } from './deployment-view/types';
import type { JsonData } from '@/types/common';
export function DeploymentView(props: DeploymentViewProps) { export function DeploymentView(props: DeploymentViewProps) {
const { clusterCode, namespace, deployment, pods, revisions, onRefresh } = props; const { clusterCode, namespace, deployment, pods, revisions, onRefresh } = props;
@@ -44,8 +45,8 @@ export function DeploymentView(props: DeploymentViewProps) {
const [tabLoading, setTabLoading] = useState(''); const [tabLoading, setTabLoading] = useState('');
// HPA / CronHPA state // Logan.Li // HPA / CronHPA state // Logan.Li
const [hpas, setHpas] = useState<any[]>([]); const [hpas, setHpas] = useState<JsonData[]>([]);
const [cronHpas, setCronHpas] = useState<any[]>([]); const [cronHpas, setCronHpas] = useState<JsonData[]>([]);
const ready = deployment?.ready_replicas ?? deployment?.readyReplicas ?? 0; const ready = deployment?.ready_replicas ?? deployment?.readyReplicas ?? 0;
const total = deployment?.replicas ?? 0; const total = deployment?.replicas ?? 0;
@@ -59,37 +60,37 @@ export function DeploymentView(props: DeploymentViewProps) {
kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []), kubernetesApi.getNamespaceServices(clusterCode, namespace).catch(() => []),
kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []), kubernetesApi.getNamespaceIngresses(clusterCode, namespace).catch(() => []),
]).then(([svcRes, ingRes]) => { ]).then(([svcRes, ingRes]) => {
const toArr = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || [])); const toArr = (v: JsonData) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
const allSvcs = toArr(svcRes); const allSvcs = toArr(svcRes);
const allIngs = toArr(ingRes); const allIngs = toArr(ingRes);
const selector = deployment?.spec?.selector?.matchLabels || deployment?.spec?.template?.metadata?.labels || {}; const selector = deployment?.spec?.selector?.matchLabels || deployment?.spec?.template?.metadata?.labels || {};
const matchedSvcs = allSvcs.filter((s: any) => { const matchedSvcs = allSvcs.filter((s: JsonData) => {
const svcSel = s.spec?.selector || s.selector || {}; const svcSel = s.spec?.selector || s.selector || {};
if (Object.keys(svcSel).length === 0) return false; if (Object.keys(svcSel).length === 0) return false;
return Object.entries(svcSel).every(([k, v]) => selector[k] === v); return Object.entries(svcSel).every(([k, v]) => selector[k] === v);
}).map((s: any) => ({ }).map((s: JsonData) => ({
name: s.name || s.metadata?.name, name: s.name || s.metadata?.name,
type: s.spec?.type || s.type || 'ClusterIP', type: s.spec?.type || s.type || 'ClusterIP',
clusterIP: s.spec?.clusterIP || s.cluster_ip || '<none>', clusterIP: s.spec?.clusterIP || s.cluster_ip || '<none>',
ports: (s.spec?.ports || s.ports || []).map((p: any) => `${p.port}${p.targetPort ? ':' + p.targetPort : ''}/${p.protocol || 'TCP'}`), ports: (s.spec?.ports || s.ports || []).map((p: JsonData) => `${p.port}${p.targetPort ? ':' + p.targetPort : ''}/${p.protocol || 'TCP'}`),
selector: s.spec?.selector || s.selector || {}, selector: s.spec?.selector || s.selector || {},
age: s.age || '', age: s.age || '',
})); }));
const svcNames = new Set(matchedSvcs.map((s: any) => s.name)); const svcNames = new Set(matchedSvcs.map((s: JsonData) => s.name));
const matchedIngs = allIngs.filter((i: any) => { const matchedIngs = allIngs.filter((i: JsonData) => {
const rules = i.spec?.rules || i.rules || []; const rules = i.spec?.rules || i.rules || [];
return rules.some((r: any) => return rules.some((r: JsonData) =>
(r.http?.paths || r.paths || []).some((p: any) => { (r.http?.paths || r.paths || []).some((p: JsonData) => {
const backendSvc = p.backend?.service?.name || p.backendService || ''; const backendSvc = p.backend?.service?.name || p.backendService || '';
return svcNames.has(backendSvc); return svcNames.has(backendSvc);
}) })
); );
}).map((i: any) => ({ }).map((i: JsonData) => ({
name: i.name || i.metadata?.name, name: i.name || i.metadata?.name,
className: i.spec?.ingressClassName || i.ingressClass || '-', className: i.spec?.ingressClassName || i.ingressClass || '-',
hosts: (i.spec?.rules || i.rules || []).map((r: any) => r.host).filter(Boolean), hosts: (i.spec?.rules || i.rules || []).map((r: JsonData) => r.host).filter(Boolean),
rules: i.spec?.rules || i.rules || [], rules: i.spec?.rules || i.rules || [],
age: i.age || '', age: i.age || '',
})); }));
@@ -103,16 +104,16 @@ export function DeploymentView(props: DeploymentViewProps) {
useEffect(() => { useEffect(() => {
if (tab !== 'events') return; if (tab !== 'events') return;
setTabLoading('events'); setTabLoading('events');
kubernetesApi.getEvents(clusterCode, namespace, undefined).then((res: any) => { kubernetesApi.getEvents(clusterCode, namespace, undefined).then((res: JsonData) => {
const allEvents = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []); const allEvents = Array.isArray(res) ? res : (res?.list || res?.items || res?.data || []);
const depName = deployment?.name || ''; const depName = deployment?.name || '';
const depEvents = allEvents.filter((e: any) => { const depEvents = allEvents.filter((e: JsonData) => {
const obj = String(e.object || ''); const obj = String(e.object || '');
if (!depName || !obj) return false; if (!depName || !obj) return false;
if (obj.endsWith(`/${depName}`)) return true; if (obj.endsWith(`/${depName}`)) return true;
const name = obj.split('/').pop() || ''; const name = obj.split('/').pop() || '';
return name === depName || name.startsWith(`${depName}-`); return name === depName || name.startsWith(`${depName}-`);
}).map((e: any) => normalizeK8sEvent(e)); }).map((e: JsonData) => normalizeK8sEvent(e));
setEvents(depEvents); setEvents(depEvents);
}).catch(() => {}).finally(() => setTabLoading('')); }).catch(() => {}).finally(() => setTabLoading(''));
}, [tab, clusterCode, namespace, deployment?.name]); }, [tab, clusterCode, namespace, deployment?.name]);
@@ -125,16 +126,16 @@ export function DeploymentView(props: DeploymentViewProps) {
kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []), kubernetesApi.getHPAs(clusterCode, namespace).catch(() => []),
Promise.resolve([]), // CronHPA 待后端支持 Promise.resolve([]), // CronHPA 待后端支持
]).then(([hpaRes]) => { ]).then(([hpaRes]) => {
const toArr = (v: any) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || [])); const toArr = (v: JsonData) => (Array.isArray(v) ? v : (v?.list || v?.items || v?.data || []));
const allHpas = toArr(hpaRes); const allHpas = toArr(hpaRes);
const depName = deployment?.name || ''; const depName = deployment?.name || '';
setHpas(allHpas.filter((h: any) => (h.scaleTargetRef?.name || h.targetRef?.name || '') === depName)); setHpas(allHpas.filter((h: JsonData) => (h.scaleTargetRef?.name || h.targetRef?.name || '') === depName));
setCronHpas([]); setCronHpas([]);
}).catch(() => {}).finally(() => setTabLoading('')); }).catch(() => {}).finally(() => setTabLoading(''));
}, [tab, clusterCode, namespace, deployment?.name]); }, [tab, clusterCode, namespace, deployment?.name]);
// 回滚 // Logan.Li // 回滚 // Logan.Li
const handleRollback = async (rev: any) => { const handleRollback = async (rev: JsonData) => {
const revision = rev.revision ?? rev.revision_number ?? rev.number; const revision = rev.revision ?? rev.revision_number ?? rev.number;
const ok = await confirm({ const ok = await confirm({
title: '回滚版本', title: '回滚版本',
@@ -9,6 +9,7 @@ import { RefreshCw, ChevronRight, Bell, AlertTriangle, X } from 'lucide-react';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { EventTable } from './EventTable'; import { EventTable } from './EventTable';
import { normalizeK8sEvent, formatEventRelative } from './k8s-event-utils'; import { normalizeK8sEvent, formatEventRelative } from './k8s-event-utils';
import type { JsonData } from '@/types/common';
interface RankItem { name: string; count: number } interface RankItem { name: string; count: number }
@@ -76,20 +77,20 @@ export default function EventsPage() {
const { clusterCode } = useParams<{ clusterCode: string }>(); const { clusterCode } = useParams<{ clusterCode: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [events, setEvents] = useState<any[]>([]); const [events, setEvents] = useState<JsonData[]>([]);
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<JsonData[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]); const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNs, setSelectedNs] = useState('all'); const [selectedNs, setSelectedNs] = useState('all');
const [filterType, setFilterType] = useState('all'); const [filterType, setFilterType] = useState('all');
const [timeWindow, setTimeWindow] = useState('0'); const [timeWindow, setTimeWindow] = useState('0');
const [activeCluster, setActiveCluster] = useState(clusterCode || ''); const [activeCluster, setActiveCluster] = useState(clusterCode || '');
const [summary, setSummary] = useState<any>(null); const [summary, setSummary] = useState<JsonData>(null);
const [selectedReason, setSelectedReason] = useState(''); const [selectedReason, setSelectedReason] = useState('');
const [selectedKind, setSelectedKind] = useState(''); const [selectedKind, setSelectedKind] = useState('');
useEffect(() => { useEffect(() => {
kubernetesApi.getClusters().then((res: any) => { kubernetesApi.getClusters().then((res: JsonData) => {
const list = res?.list || []; const list = res?.list || [];
setClusters(list); setClusters(list);
if (!activeCluster && list.length > 0) { if (!activeCluster && list.length > 0) {
@@ -102,7 +103,7 @@ export default function EventsPage() {
useEffect(() => { useEffect(() => {
if (!activeCluster) return; if (!activeCluster) return;
kubernetesApi.getNamespaces(activeCluster).then((res: any) => { kubernetesApi.getNamespaces(activeCluster).then((res: JsonData) => {
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || [])); setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
}).catch(() => {}); }).catch(() => {});
}, [activeCluster]); }, [activeCluster]);
@@ -113,7 +114,7 @@ export default function EventsPage() {
try { try {
const ns = selectedNs === 'all' ? undefined : selectedNs; const ns = selectedNs === 'all' ? undefined : selectedNs;
const ft = filterType === 'all' ? undefined : filterType; const ft = filterType === 'all' ? undefined : filterType;
const res: Record<string, any> = await kubernetesApi.getEvents(activeCluster, ns, ft); const res: JsonData = await kubernetesApi.getEvents(activeCluster, ns, ft);
setEvents(Array.isArray(res) ? res : (res?.list || [])); setEvents(Array.isArray(res) ? res : (res?.list || []));
} catch { setEvents([]); } finally { setLoading(false); } } catch { setEvents([]); } finally { setLoading(false); }
}, [activeCluster, selectedNs, filterType]); }, [activeCluster, selectedNs, filterType]);
@@ -123,7 +124,7 @@ export default function EventsPage() {
try { try {
const ns = selectedNs === 'all' ? undefined : selectedNs; const ns = selectedNs === 'all' ? undefined : selectedNs;
const hours = parseInt(timeWindow) || undefined; const hours = parseInt(timeWindow) || undefined;
const res: Record<string, any> = await kubernetesApi.getEventsSummary(activeCluster, ns, hours); const res: JsonData = await kubernetesApi.getEventsSummary(activeCluster, ns, hours);
setSummary(res || null); setSummary(res || null);
} catch { setSummary(null); } } catch { setSummary(null); }
}, [activeCluster, selectedNs, timeWindow]); }, [activeCluster, selectedNs, timeWindow]);
@@ -151,15 +152,15 @@ export default function EventsPage() {
const normalCount = useMemo(() => events.filter((e) => e.type === 'Normal').length, [events]); const normalCount = useMemo(() => events.filter((e) => e.type === 'Normal').length, [events]);
const reasonData = useMemo<RankItem[]>(() => const reasonData = useMemo<RankItem[]>(() =>
(summary?.by_reason || []).map((r: any) => ({ name: r.reason || 'Unknown', count: r.count })), (summary?.by_reason || []).map((r: JsonData) => ({ name: r.reason || 'Unknown', count: r.count })),
[summary]); [summary]);
const nsData = useMemo<RankItem[]>(() => const nsData = useMemo<RankItem[]>(() =>
(summary?.by_namespace || []).map((n: any) => ({ name: n.namespace || '<cluster>', count: n.count })), (summary?.by_namespace || []).map((n: JsonData) => ({ name: n.namespace || '<cluster>', count: n.count })),
[summary]); [summary]);
const kindData = useMemo<RankItem[]>(() => const kindData = useMemo<RankItem[]>(() =>
(summary?.by_kind || []).map((k: any) => ({ name: k.kind || 'Unknown', count: k.count })), (summary?.by_kind || []).map((k: JsonData) => ({ name: k.kind || 'Unknown', count: k.count })),
[summary]); [summary]);
return ( return (
@@ -185,7 +186,7 @@ export default function EventsPage() {
<Select value={activeCluster} onValueChange={(v) => { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}> <Select value={activeCluster} onValueChange={(v) => { setActiveCluster(v); navigate(`/kubernetes/${v}/events`); }}>
<SelectTrigger className="w-40 h-8 text-xs"><SelectValue /></SelectTrigger> <SelectTrigger className="w-40 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{clusters.map((c: any) => ( {clusters.map((c: JsonData) => (
<SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem> <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -260,7 +261,7 @@ export default function EventsPage() {
最新告警 ({summary.recent_warnings.length}) 最新告警 ({summary.recent_warnings.length})
</h3> </h3>
<div className="space-y-1"> <div className="space-y-1">
{summary.recent_warnings.map((w: any, i: number) => ( {summary.recent_warnings.map((w: JsonData, i: number) => (
<div key={i} className="text-xs py-1.5 px-2 rounded hover:bg-[var(--dg-bg-tertiary)]/50"> <div key={i} className="text-xs py-1.5 px-2 rounded hover:bg-[var(--dg-bg-tertiary)]/50">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="shrink-0 font-mono text-[var(--dg-warning)]">{w.reason}</span> <span className="shrink-0 font-mono text-[var(--dg-warning)]">{w.reason}</span>
@@ -3,6 +3,7 @@ import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import type { JsonData } from '@/types/common';
interface Props { interface Props {
/** 集群下路径,如 events、namespace/default/deployments */ /** 集群下路径,如 events、namespace/default/deployments */
@@ -14,7 +15,7 @@ export default function K8sShortcutRedirect({ target }: Props) {
useEffect(() => { useEffect(() => {
kubernetesApi.getClusters() kubernetesApi.getClusters()
.then((res: any) => { .then((res: JsonData) => {
const list = res?.list || []; const list = res?.list || [];
if (list.length > 0) { if (list.length > 0) {
const code = list[0].code || String(list[0].id); const code = list[0].code || String(list[0].id);
@@ -14,16 +14,17 @@ import {
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
import type { JsonData } from '@/types/common';
// 资源使用趋势数据 — 从节点 metrics 实时计算(不再用硬编码 mock) // Logan.Li // 资源使用趋势数据 — 从节点 metrics 实时计算(不再用硬编码 mock) // Logan.Li
// 由于当前无 time-series 存储,用当前快照展示一个合理的汇总值 // 由于当前无 time-series 存储,用当前快照展示一个合理的汇总值
export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; onClose: () => void }) { export function KubernetesClusterDetail({ cluster, onClose }: { cluster?: JsonData; onClose: () => void }) {
const [nodes, setNodes] = useState<any[]>([]); const [nodes, setNodes] = useState<JsonData[]>([]);
const [pods, setPods] = useState<any[]>([]); const [pods, setPods] = useState<JsonData[]>([]);
const [deployments, setDeployments] = useState<any[]>([]); const [deployments, setDeployments] = useState<JsonData[]>([]);
const [services, setServices] = useState<any[]>([]); const [services, setServices] = useState<JsonData[]>([]);
const [ingresses, setIngresses] = useState<any[]>([]); const [ingresses, setIngresses] = useState<JsonData[]>([]);
// 从节点数据计算集群级 CPU/内存使用率(实时快照) // Logan.Li // 从节点数据计算集群级 CPU/内存使用率(实时快照) // Logan.Li
const clusterMetrics = useMemo(() => { const clusterMetrics = useMemo(() => {
@@ -60,7 +61,7 @@ export function KubernetesClusterDetail({ cluster, onClose }: { cluster: any; on
kubernetesApi.getServices(clusterId).catch(() => []), kubernetesApi.getServices(clusterId).catch(() => []),
kubernetesApi.getIngresses(clusterId).catch(() => []), kubernetesApi.getIngresses(clusterId).catch(() => []),
]); ]);
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || [])); const toArray = (v: JsonData) => (Array.isArray(v) ? v : (v?.list || []));
setNodes(toArray(nodesRes)); setNodes(toArray(nodesRes));
setPods(toArray(podsRes)); setPods(toArray(podsRes));
setDeployments(toArray(depRes)); setDeployments(toArray(depRes));
@@ -14,6 +14,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { JsonData } from '@/types/common';
// 名称即 code,只允许英文/数字/连字符 // 名称即 code,只允许英文/数字/连字符
const NAME_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/; const NAME_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
@@ -36,7 +37,7 @@ export function KubernetesClusterDialog({
}: { }: {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
editingCluster: any; editingCluster?: JsonData;
onSaved: () => void; onSaved: () => void;
}) { }) {
const [formData, setFormData] = useState({ ...emptyForm }); const [formData, setFormData] = useState({ ...emptyForm });
@@ -48,7 +49,7 @@ export function KubernetesClusterDialog({
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
if (editingCluster && editingCluster.id) { if (editingCluster && editingCluster.id) {
kubernetesApi.getClusterDetail(editingCluster.id).then(( res: any) => { kubernetesApi.getClusterDetail(editingCluster.id).then((res: JsonData) => {
const d = res || {}; const d = res || {};
setHasToken(!!d.has_token); setHasToken(!!d.has_token);
setHasKubeConfig(!!d.has_kube_config); setHasKubeConfig(!!d.has_kube_config);
@@ -33,29 +33,30 @@ import type { KubernetesCluster } from '@/types';
import { KubernetesClusterDetail } from './KubernetesClusterDetail'; import { KubernetesClusterDetail } from './KubernetesClusterDetail';
import { KubernetesClusterDialog } from './KubernetesClusterDialog'; import { KubernetesClusterDialog } from './KubernetesClusterDialog';
import { KubectlTerminal } from './KubectlTerminal'; import { KubectlTerminal } from './KubectlTerminal';
import type { JsonData } from '@/types/common';
export function KubernetesClusters() { export function KubernetesClusters() {
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedCluster, setSelectedCluster] = useState<string | null>(null); const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
// 创建/编辑集群弹窗 // 创建/编辑集群弹窗
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingCluster, setEditingCluster] = useState<any>(null); const [editingCluster, setEditingCluster] = useState<JsonData>(null);
// kubectl 终端 // kubectl 终端
const [kubectlCluster, setKubectlCluster] = useState<{id: number; name: string} | null>(null); const [kubectlCluster, setKubectlCluster] = useState<{id: number; name: string} | null>(null);
const [kubectlTerminalOpen, setKubectlTerminalOpen] = useState(false); const [kubectlTerminalOpen, setKubectlTerminalOpen] = useState(false);
// 导出资源弹窗 // 导出资源弹窗
const [exportCluster, setExportCluster] = useState<any>(null); const [exportCluster, setExportCluster] = useState<JsonData>(null);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const loadData = async () => { const loadData = async () => {
try { try {
setLoading(true); setLoading(true);
const res: Record<string, any> = await kubernetesApi.getClusters(); const res: JsonData = await kubernetesApi.getClusters();
setClusters(res?.list || []); setClusters(res?.list || []);
} catch (e) { } catch (e) {
console.error('加载集群列表失败:', e); console.error('加载集群列表失败:', e);
@@ -77,7 +78,7 @@ export function KubernetesClusters() {
if (!editingCluster) { if (!editingCluster) {
// 新建集群后,等列表刷新完再取最新一条同步 // 新建集群后,等列表刷新完再取最新一条同步
setTimeout(async () => { setTimeout(async () => {
const res: Record<string, any> = await kubernetesApi.getClusters(); const res: JsonData = await kubernetesApi.getClusters();
const list = res?.list || []; const list = res?.list || [];
if (list.length > 0) { if (list.length > 0) {
handleSync(list[list.length - 1].id, true); handleSync(list[list.length - 1].id, true);
@@ -99,7 +100,7 @@ export function KubernetesClusters() {
const handleSync = async (id: number | string, silent?: boolean) => { const handleSync = async (id: number | string, silent?: boolean) => {
try { try {
if (!silent) toast.info('正在同步集群资源...'); if (!silent) toast.info('正在同步集群资源...');
const res: Record<string, any> = await kubernetesApi.syncCluster(id); const res: JsonData = await kubernetesApi.syncCluster(id);
if (!silent) toast.success(res?.message || '同步完成'); if (!silent) toast.success(res?.message || '同步完成');
loadData(); loadData();
} catch (e) { } catch (e) {
@@ -11,18 +11,19 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { RefreshCw, Search, Server, Loader2 } from 'lucide-react'; import { RefreshCw, Search, Server, Loader2 } from 'lucide-react';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import type { JsonData } from '@/types/common';
export function KubernetesNodes() { export function KubernetesNodes() {
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<JsonData[]>([]);
const [selectedCluster, setSelectedCluster] = useState(''); const [selectedCluster, setSelectedCluster] = useState('');
const [nodes, setNodes] = useState<any[]>([]); const [nodes, setNodes] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('all'); const [roleFilter, setRoleFilter] = useState('all');
const loadClusters = useCallback(async () => { const loadClusters = useCallback(async () => {
try { try {
const res: Record<string, any> = await kubernetesApi.getClusters(); const res: JsonData = await kubernetesApi.getClusters();
const list = res?.list || []; const list = res?.list || [];
setClusters(list); setClusters(list);
if (list.length > 0 && !selectedCluster) setSelectedCluster(String(list[0].id)); if (list.length > 0 && !selectedCluster) setSelectedCluster(String(list[0].id));
@@ -35,7 +36,7 @@ export function KubernetesNodes() {
if (!selectedCluster) return; if (!selectedCluster) return;
setLoading(true); setLoading(true);
try { try {
const res: Record<string, any> = await kubernetesApi.getNodes(selectedCluster); const res: JsonData = await kubernetesApi.getNodes(selectedCluster);
setNodes(Array.isArray(res) ? res : (res?.list || [])); setNodes(Array.isArray(res) ? res : (res?.list || []));
} catch { setNodes([]); } finally { setLoading(false); } } catch { setNodes([]); } finally { setLoading(false); }
}, [selectedCluster]); }, [selectedCluster]);
@@ -25,15 +25,16 @@ import {
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { DeploymentDetail } from './DeploymentDetail'; import { DeploymentDetail } from './DeploymentDetail';
import type { JsonData } from '@/types/common';
export function KubernetesWorkloads() { export function KubernetesWorkloads() {
const { toast } = useToast(); const { toast } = useToast();
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<JsonData[]>([]);
const [selectedCluster, setSelectedCluster] = useState(''); const [selectedCluster, setSelectedCluster] = useState('');
const [namespaces, setNamespaces] = useState<string[]>([]); const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNamespace, setSelectedNamespace] = useState('all'); const [selectedNamespace, setSelectedNamespace] = useState('all');
const [deployments, setDeployments] = useState<any[]>([]); const [deployments, setDeployments] = useState<JsonData[]>([]);
const [pods, setPods] = useState<any[]>([]); const [pods, setPods] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState('deployments'); const [activeTab, setActiveTab] = useState('deployments');
@@ -44,7 +45,7 @@ export function KubernetesWorkloads() {
const loadClusters = useCallback(async () => { const loadClusters = useCallback(async () => {
try { try {
const res: Record<string, any> = await kubernetesApi.getClusters(); const res: JsonData = await kubernetesApi.getClusters();
const list = res?.list || []; const list = res?.list || [];
setClusters(list); setClusters(list);
if (list.length > 0 && !selectedCluster) setSelectedCluster(list[0].code || String(list[0].id)); if (list.length > 0 && !selectedCluster) setSelectedCluster(list[0].code || String(list[0].id));
@@ -57,7 +58,7 @@ export function KubernetesWorkloads() {
const loadNamespaces = useCallback(async () => { const loadNamespaces = useCallback(async () => {
if (!selectedCluster) return; if (!selectedCluster) return;
try { try {
const res: Record<string, any> = await kubernetesApi.getNamespaces(selectedCluster); const res: JsonData = await kubernetesApi.getNamespaces(selectedCluster);
const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []); const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []);
setNamespaces(nsList); setNamespaces(nsList);
} catch { setNamespaces([]); } } catch { setNamespaces([]); }
@@ -75,7 +76,7 @@ export function KubernetesWorkloads() {
kubernetesApi.getNamespaceDeployments(clusterCode, selectedNamespace).catch(() => []), kubernetesApi.getNamespaceDeployments(clusterCode, selectedNamespace).catch(() => []),
kubernetesApi.getNamespacePods(clusterCode, selectedNamespace).catch(() => []), kubernetesApi.getNamespacePods(clusterCode, selectedNamespace).catch(() => []),
]); ]);
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || [])); const toArray = (v: JsonData) => (Array.isArray(v) ? v : (v?.list || []));
setDeployments(toArray(depRes)); setDeployments(toArray(depRes));
setPods(toArray(podRes)); setPods(toArray(podRes));
} else { } else {
@@ -83,7 +84,7 @@ export function KubernetesWorkloads() {
kubernetesApi.getAllDeployments(clusterCode).catch(() => []), kubernetesApi.getAllDeployments(clusterCode).catch(() => []),
kubernetesApi.getAllPods(clusterCode).catch(() => []), kubernetesApi.getAllPods(clusterCode).catch(() => []),
]); ]);
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || [])); const toArray = (v: JsonData) => (Array.isArray(v) ? v : (v?.list || []));
setDeployments(toArray(depRes)); setDeployments(toArray(depRes));
setPods(toArray(podRes)); setPods(toArray(podRes));
} }
@@ -111,7 +112,7 @@ export function KubernetesWorkloads() {
return <Badge className={map[status] || 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{status || 'Unknown'}</Badge>; return <Badge className={map[status] || 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{status || 'Unknown'}</Badge>;
}; };
const handleQuickScale = async ( dep: any, delta: number) => { const handleQuickScale = async (dep: JsonData, delta: number) => {
const key = `scale-${dep.name}`; const key = `scale-${dep.name}`;
setActionLoading((prev) => ({ ...prev, [key]: 'loading' })); setActionLoading((prev) => ({ ...prev, [key]: 'loading' }));
try { try {
@@ -14,6 +14,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import type { JsonData } from '@/types/common';
const RESOURCE_LINKS = [ const RESOURCE_LINKS = [
{ type: 'deployments', label: 'Deployments', icon: Boxes, desc: '工作负载副本管理' }, { type: 'deployments', label: 'Deployments', icon: Boxes, desc: '工作负载副本管理' },
@@ -45,7 +46,7 @@ export default function NamespaceOverviewPage() {
kubernetesApi.getHPAs(clusterCode, namespace!), kubernetesApi.getHPAs(clusterCode, namespace!),
kubernetesApi.getCronJobs(clusterCode, namespace!), kubernetesApi.getCronJobs(clusterCode, namespace!),
]); ]);
const toArr = ( v: any) => Array.isArray(v) ? v : (v?.list || v?.data || []); const toArr = (v: JsonData) => Array.isArray(v) ? v : (v?.list || v?.data || []);
setCounts({ setCounts({
deployments: toArr(results[0].status === 'fulfilled' ? results[0].value : []).length, deployments: toArr(results[0].status === 'fulfilled' ? results[0].value : []).length,
pods: toArr(results[1].status === 'fulfilled' ? results[1].value : []).length, pods: toArr(results[1].status === 'fulfilled' ? results[1].value : []).length,
@@ -22,6 +22,7 @@ import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser'; import { PodFileBrowser } from './PodFileBrowser';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import type { JsonData } from '@/types/common';
type ResourceType = type ResourceType =
| 'deployments' | 'pods' | 'services' | 'ingresses' | 'deployments' | 'pods' | 'services' | 'ingresses'
@@ -46,9 +47,9 @@ const VisualActions = ({ name, opts }: { name: string; opts: { onYaml?: (n: stri
const RESOURCE_CONFIG: Record<ResourceType, { const RESOURCE_CONFIG: Record<ResourceType, {
title: string; title: string;
subtitle: string; subtitle: string;
icon: React.ComponentType<any>; icon: React.ComponentType<Record<string, unknown>>;
columns: string[]; columns: string[];
renderRow: ( item: any, opts: { clusterCode: string; namespace: string; onDetail?: ( item: any) => void; onYaml?: (name: string) => void; onEdit?: (name: string) => void; onTerminal?: ( item: any) => void; onFileBrowser?: ( item: any) => void }) => React.ReactNode; renderRow: (item: JsonData, opts: { clusterCode: string; namespace: string; onDetail?: (item: JsonData) => void; onYaml?: (name: string) => void; onEdit?: (name: string) => void; onTerminal?: (item: JsonData) => void; onFileBrowser?: (item: JsonData) => void }) => React.ReactNode;
}> = { }> = {
deployments: { deployments: {
title: 'Deployments', subtitle: 'Deployment 工作负载运行状态', icon: Boxes, title: 'Deployments', subtitle: 'Deployment 工作负载运行状态', icon: Boxes,
@@ -97,9 +98,9 @@ const RESOURCE_CONFIG: Record<ResourceType, {
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10" title="终端" <Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-emerald-400 hover:text-emerald-300 hover:bg-emerald-500/10" title="终端"
onClick={() => (opts as Record<string, any>).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5 mr-1" />终端</Button> onClick={() => (opts as JsonData).onTerminal?.(p)}><Terminal className="h-3.5 w-3.5 mr-1" />终端</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10" title="文件浏览器" <Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10" title="文件浏览器"
onClick={() => (opts as Record<string, any>).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器</Button> onClick={() => (opts as JsonData).onFileBrowser?.(p)}><HardDrive className="h-3.5 w-3.5 mr-1" />文件浏览器</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" title="查看YAML" <Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10" title="查看YAML"
onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button> onClick={() => opts.onYaml?.(p.name)}><FileCode className="h-3.5 w-3.5 mr-1" />YAML</Button>
</div> </div>
@@ -331,20 +332,20 @@ export default function NamespaceResourcePage() {
}, [location.pathname]); }, [location.pathname]);
const config = RESOURCE_CONFIG[type]; const config = RESOURCE_CONFIG[type];
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<JsonData[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]); const [namespaces, setNamespaces] = useState<string[]>([]);
const [data, setData] = useState<any[]>([]); const [data, setData] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [yamlItem, setYamlItem] = useState<{resource: string; name: string; editMode?: boolean} | null>(null); const [yamlItem, setYamlItem] = useState<{resource: string; name: string; editMode?: boolean} | null>(null);
const [visualItem, setVisualItem] = useState<{ resource: string; name: string; isCreate?: boolean } | null>(null); const [visualItem, setVisualItem] = useState<{ resource: string; name: string; isCreate?: boolean } | null>(null);
const [showYamlImport, setShowYamlImport] = useState(false); const [showYamlImport, setShowYamlImport] = useState(false);
const [terminalPod, setTerminalPod] = useState<any>(null); const [terminalPod, setTerminalPod] = useState<JsonData>(null);
const [fileBrowserPod, setFileBrowserPod] = useState<any>(null); const [fileBrowserPod, setFileBrowserPod] = useState<JsonData>(null);
useEffect(() => { useEffect(() => {
kubernetesApi.getClusters().then(( res: any) => { kubernetesApi.getClusters().then((res: JsonData) => {
const list = res?.list || []; const list = res?.list || [];
setClusters(list); setClusters(list);
if (!clusterCode && list.length > 0) { if (!clusterCode && list.length > 0) {
@@ -356,7 +357,7 @@ export default function NamespaceResourcePage() {
useEffect(() => { useEffect(() => {
if (!clusterCode) return; if (!clusterCode) return;
kubernetesApi.getNamespaces(clusterCode).then(( res: any) => { kubernetesApi.getNamespaces(clusterCode).then((res: JsonData) => {
const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []); const nsList: string[] = Array.isArray(res) ? res : (res?.list || res?.data || []);
setNamespaces(nsList); setNamespaces(nsList);
if (!namespace && nsList.length > 0) { if (!namespace && nsList.length > 0) {
@@ -369,7 +370,7 @@ export default function NamespaceResourcePage() {
if (!clusterCode || !namespace) return; if (!clusterCode || !namespace) return;
setLoading(true); setLoading(true);
try { try {
let res: Record<string, any> = []; let res: JsonData = [];
switch (type) { switch (type) {
case 'deployments': res = await kubernetesApi.getNamespaceDeployments(clusterCode, namespace).catch(() => []); break; case 'deployments': res = await kubernetesApi.getNamespaceDeployments(clusterCode, namespace).catch(() => []); break;
case 'pods': res = await kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []); break; case 'pods': res = await kubernetesApi.getNamespacePods(clusterCode, namespace).catch(() => []); break;
@@ -392,9 +393,9 @@ export default function NamespaceResourcePage() {
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const filtered = data.filter(( d: any) => (d.name || '').toLowerCase().includes(search.toLowerCase())); const filtered = data.filter((d: JsonData) => (d.name || '').toLowerCase().includes(search.toLowerCase()));
const handleDetail = ( item: any) => { const handleDetail = (item: JsonData) => {
if (type === 'deployments') { if (type === 'deployments') {
// Kuboard 风格双模式路由(默认 view 模式) // Kuboard 风格双模式路由(默认 view 模式)
navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${item.name}`); navigate(`/kubernetes/${clusterCode}/namespace/${namespace}/workload/view/Deployment/${item.name}`);
@@ -523,7 +524,7 @@ export default function NamespaceResourcePage() {
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 {config.title}</TableCell></TableRow> <TableRow><TableCell colSpan={config.columns.length} className="text-center text-slate-500 py-12"><config.icon className="h-8 w-8 mx-auto mb-2 opacity-50" />暂无 {config.title}</TableCell></TableRow>
) : ( ) : (
filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit, onTerminal: ( p: any) => setTerminalPod(p), onFileBrowser: ( p: any) => setFileBrowserPod(p) })) filtered.map((item) => config.renderRow(item, { clusterCode: clusterCode || '', namespace: namespace || '', onDetail: handleDetail, onYaml: handleYaml, onEdit: handleEdit, onTerminal: (p: JsonData) => setTerminalPod(p), onFileBrowser: (p: JsonData) => setFileBrowserPod(p) }))
)} )}
</TableBody> </TableBody>
</Table> </Table>
@@ -537,7 +538,7 @@ export default function NamespaceResourcePage() {
clusterId={clusters[0]?.id || clusters[0]?.code || clusterCode || ''} clusterId={clusters[0]?.id || clusters[0]?.code || clusterCode || ''}
namespace={namespace || ''} namespace={namespace || ''}
podName={terminalPod?.name || ''} podName={terminalPod?.name || ''}
containers={(terminalPod?.containers || []).map(( c: any) => c.name).filter(Boolean)} containers={(terminalPod?.containers || []).map((c: JsonData) => c.name).filter(Boolean)}
/> />
{/* ============ Pod 文件浏览器 ============ */} {/* ============ Pod 文件浏览器 ============ */}
<PodFileBrowser <PodFileBrowser
@@ -546,7 +547,7 @@ export default function NamespaceResourcePage() {
clusterCode={clusterCode || ''} clusterCode={clusterCode || ''}
namespace={namespace || ''} namespace={namespace || ''}
podName={fileBrowserPod?.name || ''} podName={fileBrowserPod?.name || ''}
containers={(fileBrowserPod?.containers || []).map(( c: any) => c.name).filter(Boolean)} containers={(fileBrowserPod?.containers || []).map((c: JsonData) => c.name).filter(Boolean)}
/> />
</div> </div>
); );
@@ -17,6 +17,7 @@ import {
ArrowLeft, ArrowLeft,
HardDrive, HardDrive,
} from 'lucide-react'; } from 'lucide-react';
import type { JsonData } from '@/types/common';
export interface PodFileBrowserProps { export interface PodFileBrowserProps {
@@ -54,10 +55,10 @@ export function PodFileBrowser({
setError(''); setError('');
setPreview(null); setPreview(null);
try { try {
const res: Record<string, any> = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, path, container || undefined); const res: JsonData = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, path, container || undefined);
// 兼容后端 {files: [...]} 和 {entries: [...]} 两种返回格式 // 兼容后端 {files: [...]} 和 {entries: [...]} 两种返回格式
const raw: Record<string, any>[] = res?.entries || res?.files || res?.data?.entries || res?.data?.files || (Array.isArray(res) ? res : []); const raw: JsonData[] = res?.entries || res?.files || res?.data?.entries || res?.data?.files || (Array.isArray(res) ? res : []);
const list: FileEntry[] = raw.map(( it: any) => ({ const list: FileEntry[] = raw.map((it: JsonData) => ({
name: it.name ?? it.Name ?? '', name: it.name ?? it.Name ?? '',
isDir: Boolean(it.isDir ?? it.is_dir ?? it.IsDir ?? it.dir ?? it.directory), isDir: Boolean(it.isDir ?? it.is_dir ?? it.IsDir ?? it.dir ?? it.directory),
size: typeof it.size === 'number' ? it.size : (it.Size ? Number(it.Size) : undefined), size: typeof it.size === 'number' ? it.size : (it.Size ? Number(it.Size) : undefined),
@@ -66,7 +67,7 @@ export function PodFileBrowser({
setEntries(list); setEntries(list);
setCurrentPath(path); setCurrentPath(path);
} catch (e) { } catch (e) {
setError((e as Record<string, any>).message || '加载文件列表失败'); setError((e as JsonData).message || '加载文件列表失败');
setEntries([]); setEntries([]);
} finally { } finally {
setLoading(false); setLoading(false);
@@ -83,7 +84,7 @@ export function PodFileBrowser({
} }
setLoading(true); setLoading(true);
try { try {
const res: Record<string, any> = await kubernetesApi.execPod(clusterCode, namespace, podName, ['pwd'], container || undefined); const res: JsonData = await kubernetesApi.execPod(clusterCode, namespace, podName, ['pwd'], container || undefined);
const stdout = (res?.stdout || res?.data?.stdout || '').trim(); const stdout = (res?.stdout || res?.data?.stdout || '').trim();
const workdir = stdout && stdout.startsWith('/') ? stdout : '/'; const workdir = stdout && stdout.startsWith('/') ? stdout : '/';
workdirDetected.current = true; workdirDetected.current = true;
@@ -116,7 +117,7 @@ export function PodFileBrowser({
const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, filePath, container || undefined); const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, filePath, container || undefined);
setPreview({ file: fileName, content: text }); setPreview({ file: fileName, content: text });
} catch (e) { } catch (e) {
setPreview({ file: fileName, content: `读取失败: ${(e as Record<string, any>).message}` }); setPreview({ file: fileName, content: `读取失败: ${(e as JsonData).message}` });
} }
}; };
@@ -134,7 +135,7 @@ export function PodFileBrowser({
await kubernetesApi.uploadPodFile(clusterCode, namespace, podName, currentPath, file, container || undefined); await kubernetesApi.uploadPodFile(clusterCode, namespace, podName, currentPath, file, container || undefined);
loadFiles(currentPath); loadFiles(currentPath);
} catch (err) { } catch (err) {
setError(`上传失败: ${(err as Record<string, any>).message}`); setError(`上传失败: ${(err as JsonData).message}`);
} finally { } finally {
setUploading(false); setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
@@ -21,6 +21,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import type { JsonData } from '@/types/common';
/** /**
* 增强版 Pod 日志查看器 — 增强 DeploymentDetail 的日志体验 * 增强版 Pod 日志查看器 — 增强 DeploymentDetail 的日志体验
@@ -153,7 +154,7 @@ export function PodLogsViewer({
linesRef.current = []; linesRef.current = [];
setLines([]); setLines([]);
try { try {
const res: Record<string, any> = await kubernetesApi.getPodLogsEnhanced(clusterCode, namespace, podName, { const res: JsonData = await kubernetesApi.getPodLogsEnhanced(clusterCode, namespace, podName, {
container: container || undefined, container: container || undefined,
tailLines: tailLines, tailLines: tailLines,
previous, previous,
@@ -11,19 +11,20 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Cpu, HardDrive, RefreshCw, ChevronRight, Activity } from 'lucide-react'; import { Cpu, HardDrive, RefreshCw, ChevronRight, Activity } from 'lucide-react';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import type { JsonData } from '@/types/common';
export default function PodMetricsPage() { export default function PodMetricsPage() {
const { clusterCode } = useParams<{ clusterCode: string }>(); const { clusterCode } = useParams<{ clusterCode: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [metrics, setMetrics] = useState<any[]>([]); const [metrics, setMetrics] = useState<JsonData[]>([]);
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<JsonData[]>([]);
const [namespaces, setNamespaces] = useState<string[]>([]); const [namespaces, setNamespaces] = useState<string[]>([]);
const [selectedNs, setSelectedNs] = useState('all'); const [selectedNs, setSelectedNs] = useState('all');
const [activeCluster, setActiveCluster] = useState(clusterCode || ''); const [activeCluster, setActiveCluster] = useState(clusterCode || '');
useEffect(() => { useEffect(() => {
kubernetesApi.getClusters().then(( res: any) => { kubernetesApi.getClusters().then((res: JsonData) => {
const list = res?.list || []; const list = res?.list || [];
setClusters(list); setClusters(list);
if (!activeCluster && list.length > 0) { if (!activeCluster && list.length > 0) {
@@ -36,7 +37,7 @@ export default function PodMetricsPage() {
useEffect(() => { useEffect(() => {
if (!activeCluster) return; if (!activeCluster) return;
kubernetesApi.getNamespaces(activeCluster).then(( res: any) => { kubernetesApi.getNamespaces(activeCluster).then((res: JsonData) => {
setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || [])); setNamespaces(Array.isArray(res) ? res : (res?.list || res?.data || []));
}).catch(() => {}); }).catch(() => {});
}, [activeCluster]); }, [activeCluster]);
@@ -46,7 +47,7 @@ export default function PodMetricsPage() {
setLoading(true); setLoading(true);
try { try {
const ns = selectedNs === 'all' ? undefined : selectedNs; const ns = selectedNs === 'all' ? undefined : selectedNs;
const res: Record<string, any> = await kubernetesApi.getPodMetrics(activeCluster, ns); const res: JsonData = await kubernetesApi.getPodMetrics(activeCluster, ns);
setMetrics(Array.isArray(res) ? res : (res?.list || [])); setMetrics(Array.isArray(res) ? res : (res?.list || []));
} catch { setMetrics([]); } finally { setLoading(false); } } catch { setMetrics([]); } finally { setLoading(false); }
}, [activeCluster, selectedNs]); }, [activeCluster, selectedNs]);
@@ -55,8 +56,8 @@ export default function PodMetricsPage() {
// Flatten for table // Flatten for table
const rows = useMemo(() => const rows = useMemo(() =>
metrics.flatMap(( pm: any) => metrics.flatMap((pm: JsonData) =>
(pm.containers || []).map(( c: any) => ({ (pm.containers || []).map((c: JsonData) => ({
pod: pm.name, pod: pm.name,
namespace: pm.namespace, namespace: pm.namespace,
container: c.name, container: c.name,
@@ -91,7 +92,7 @@ export default function PodMetricsPage() {
<Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/metrics/pods`); }}> <Select value={activeCluster} onValueChange={v => { setActiveCluster(v); navigate(`/kubernetes/${v}/metrics/pods`); }}>
<SelectTrigger className="w-44 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger> <SelectTrigger className="w-44 h-8 text-xs bg-slate-800 border-slate-700"><SelectValue /></SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700"> <SelectContent className="bg-slate-800 border-slate-700">
{clusters.map(( c: any) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)} {clusters.map((c: JsonData) => <SelectItem key={c.id} value={c.code || String(c.id)} className="text-xs">{c.name || c.code}</SelectItem>)}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={selectedNs} onValueChange={setSelectedNs}> <Select value={selectedNs} onValueChange={setSelectedNs}>
@@ -23,11 +23,12 @@ import {
buildTopology, getNodeIcon, getNodeColor, NODE_TYPE_LABELS, CUSTOM_NODE_TYPES, buildTopology, getNodeIcon, getNodeColor, NODE_TYPE_LABELS, CUSTOM_NODE_TYPES,
isCustomNode, type TopologyLayoutData, isCustomNode, type TopologyLayoutData,
} from './topology-helpers'; } from './topology-helpers';
import type { JsonData } from '@/types/common';
export function TopologyView() { export function TopologyView() {
const { clusterCode } = useParams<{ clusterCode?: string }>(); const { clusterCode } = useParams<{ clusterCode?: string }>();
const _confirm = useConfirm(); const _confirm = useConfirm();
const [clusters, setClusters] = useState<any[]>([]); const [clusters, setClusters] = useState<JsonData[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string>(''); const [selectedClusterId, setSelectedClusterId] = useState<string>('');
const [topology, setTopology] = useState<{ nodes: TopologyNode[]; edges: TopologyEdge[] }>({ nodes: [], edges: [] }); const [topology, setTopology] = useState<{ nodes: TopologyNode[]; edges: TopologyEdge[] }>({ nodes: [], edges: [] });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -52,13 +53,13 @@ export function TopologyView() {
const loadClusters = useCallback(async () => { const loadClusters = useCallback(async () => {
try { try {
const res: Record<string, any> = await kubernetesApi.getClusters(); const res: JsonData = await kubernetesApi.getClusters();
const list = res?.list || []; const list = res?.list || [];
setClusters(list); setClusters(list);
// Auto-select from URL param (clusterCode) if provided, otherwise first cluster // Auto-select from URL param (clusterCode) if provided, otherwise first cluster
if (!selectedClusterId) { if (!selectedClusterId) {
if (clusterCode) { if (clusterCode) {
const matched = list.find(( c: any) => (c.code || String(c.id)) === clusterCode); const matched = list.find((c: JsonData) => (c.code || String(c.id)) === clusterCode);
if (matched) setSelectedClusterId(String(matched.id)); if (matched) setSelectedClusterId(String(matched.id));
} else if (list.length > 0) { } else if (list.length > 0) {
setSelectedClusterId(String(list[0].id)); setSelectedClusterId(String(list[0].id));
@@ -72,8 +73,8 @@ export function TopologyView() {
// 加载已保存的布局 // Logan.Li // 加载已保存的布局 // Logan.Li
const loadSavedLayout = useCallback(async (clusterId: string) => { const loadSavedLayout = useCallback(async (clusterId: string) => {
try { try {
const res: Record<string, any> = await kubernetesApi.getTopology(clusterId); const res: JsonData = await kubernetesApi.getTopology(clusterId);
const layout = res as Record<string, any>; const layout = res as JsonData;
if (layout?.layout_data) { if (layout?.layout_data) {
const data = JSON.parse(layout.layout_data) as TopologyLayoutData; const data = JSON.parse(layout.layout_data) as TopologyLayoutData;
setSavedLayout(data); setSavedLayout(data);
@@ -94,7 +95,7 @@ export function TopologyView() {
if (!selectedClusterId) return; if (!selectedClusterId) return;
setLoading(true); setLoading(true);
try { try {
const toArray = ( v: any) => (Array.isArray(v) ? v : (v?.list || [])); const toArray = (v: JsonData) => (Array.isArray(v) ? v : (v?.list || []));
const [nodesRes, svcRes, depRes, ingRes] = await Promise.all([ const [nodesRes, svcRes, depRes, ingRes] = await Promise.all([
kubernetesApi.getNodes(selectedClusterId).catch(() => []), kubernetesApi.getNodes(selectedClusterId).catch(() => []),
kubernetesApi.getServices(selectedClusterId).catch(() => []), kubernetesApi.getServices(selectedClusterId).catch(() => []),
@@ -196,7 +197,7 @@ export function TopologyView() {
const handleEditNode = (node: TopologyNode) => { const handleEditNode = (node: TopologyNode) => {
setEditingNode(node); setEditingNode(node);
setEditForm({ name: node.name, type: node.type, note: (node.data as Record<string, any>)?.note || '' }); setEditForm({ name: node.name, type: node.type, note: (node.data as JsonData)?.note || '' });
setEditDialogOpen(true); setEditDialogOpen(true);
}; };
@@ -20,6 +20,7 @@ import { YamlViewer } from './YamlViewer';
import { PodExecTerminal } from './PodExecTerminal'; import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser'; import { PodFileBrowser } from './PodFileBrowser';
import { formatK8sTime, statusColor, STATUS_COLORS } from './k8s-yaml-utils'; import { formatK8sTime, statusColor, STATUS_COLORS } from './k8s-yaml-utils';
import type { JsonData } from '@/types/common';
type WorkloadKind = 'StatefulSet' | 'DaemonSet'; type WorkloadKind = 'StatefulSet' | 'DaemonSet';
type ViewMode = 'view' | 'yaml'; type ViewMode = 'view' | 'yaml';
@@ -67,14 +68,14 @@ export default function WorkloadViewPage() {
const kind: WorkloadKind = location.pathname.includes('/DaemonSet/') ? 'DaemonSet' : 'StatefulSet'; const kind: WorkloadKind = location.pathname.includes('/DaemonSet/') ? 'DaemonSet' : 'StatefulSet';
const mode: ViewMode = params.mode === 'yaml' ? 'yaml' : 'view'; const mode: ViewMode = params.mode === 'yaml' ? 'yaml' : 'view';
const [manifest, setManifest] = useState<any>(null); const [manifest, setManifest] = useState<JsonData>(null);
const [pods, setPods] = useState<any[]>([]); const [pods, setPods] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [scaleModal, setScaleModal] = useState(false); const [scaleModal, setScaleModal] = useState(false);
const [scaleValue, setScaleValue] = useState(1); const [scaleValue, setScaleValue] = useState(1);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
const [terminalPod, setTerminalPod] = useState<any>(null); const [terminalPod, setTerminalPod] = useState<JsonData>(null);
const [fileBrowserPod, setFileBrowserPod] = useState<any>(null); const [fileBrowserPod, setFileBrowserPod] = useState<JsonData>(null);
const [yamlModal, setYamlModal] = useState(false); const [yamlModal, setYamlModal] = useState(false);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
@@ -95,7 +96,7 @@ export default function WorkloadViewPage() {
// Filter pods by name prefix and namespace // Filter pods by name prefix and namespace
const podList = Array.isArray(podsRes) ? podsRes : (podsRes?.list || podsRes?.data || []); const podList = Array.isArray(podsRes) ? podsRes : (podsRes?.list || podsRes?.data || []);
const filtered = podList.filter(( p: any) => { const filtered = podList.filter((p: JsonData) => {
const podName = p.name || p.metadata?.name || ''; const podName = p.name || p.metadata?.name || '';
const podNs = p.namespace || p.namespace_name || ''; const podNs = p.namespace || p.namespace_name || '';
return podName.startsWith(name) && (!podNs || podNs === namespace); return podName.startsWith(name) && (!podNs || podNs === namespace);
@@ -384,7 +385,7 @@ export default function WorkloadViewPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{vcts.map(( vct: any, idx: number) => { {vcts.map((vct: JsonData, idx: number) => {
const accessModes = (vct.spec?.accessModes || []).join(', '); const accessModes = (vct.spec?.accessModes || []).join(', ');
const storageClass = vct.spec?.storageClassName || vct.spec?.storageClassName || '-'; const storageClass = vct.spec?.storageClassName || vct.spec?.storageClassName || '-';
const size = vct.spec?.resources?.requests?.storage || '-'; const size = vct.spec?.resources?.requests?.storage || '-';
@@ -415,7 +416,7 @@ export default function WorkloadViewPage() {
<div className="mb-3"> <div className="mb-3">
<p className="text-[11px] text-slate-500 mb-2 uppercase tracking-wide">Init Containers</p> <p className="text-[11px] text-slate-500 mb-2 uppercase tracking-wide">Init Containers</p>
<div className="space-y-2"> <div className="space-y-2">
{initContainers.map(( c: any, idx: number) => ( {initContainers.map((c: JsonData, idx: number) => (
<ContainerInfo key={`init-${idx}`} c={c} /> <ContainerInfo key={`init-${idx}`} c={c} />
))} ))}
</div> </div>
@@ -426,7 +427,7 @@ export default function WorkloadViewPage() {
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-[11px] text-slate-500 mb-1 uppercase tracking-wide">Containers</p> <p className="text-[11px] text-slate-500 mb-1 uppercase tracking-wide">Containers</p>
{containers.map(( c: any, idx: number) => ( {containers.map((c: JsonData, idx: number) => (
<ContainerInfo key={idx} c={c} /> <ContainerInfo key={idx} c={c} />
))} ))}
</div> </div>
@@ -456,7 +457,7 @@ export default function WorkloadViewPage() {
<TableBody> <TableBody>
{pods.length === 0 ? ( {pods.length === 0 ? (
<TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-8">暂无 Pod</TableCell></TableRow> <TableRow><TableCell colSpan={6} className="text-center text-slate-500 py-8">暂无 Pod</TableCell></TableRow>
) : pods.map(( pod: any) => ( ) : pods.map((pod: JsonData) => (
<TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30"> <TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell> <TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
<TableCell> <TableCell>
@@ -531,7 +532,7 @@ export default function WorkloadViewPage() {
clusterId={clusterCode} clusterId={clusterCode}
namespace={namespace} namespace={namespace}
podName={terminalPod.name} podName={terminalPod.name}
containers={terminalPod.containers?.map(( c: any) => c.name).filter(Boolean) || []} containers={terminalPod.containers?.map((c: JsonData) => c.name).filter(Boolean) || []}
/> />
)} )}
@@ -543,7 +544,7 @@ export default function WorkloadViewPage() {
clusterCode={clusterCode} clusterCode={clusterCode}
namespace={namespace} namespace={namespace}
podName={fileBrowserPod.name} podName={fileBrowserPod.name}
containers={fileBrowserPod.containers?.map(( c: any) => c.name).filter(Boolean) || []} containers={fileBrowserPod.containers?.map((c: JsonData) => c.name).filter(Boolean) || []}
/> />
)} )}
</div> </div>
@@ -551,7 +552,7 @@ export default function WorkloadViewPage() {
} }
// Container info sub-component // Container info sub-component
function ContainerInfo({ c }: { c: any}) { function ContainerInfo({ c }: { c: JsonData}) {
return ( return (
<div className="border border-slate-800 rounded-lg p-3 bg-slate-950/30"> <div className="border border-slate-800 rounded-lg p-3 bg-slate-950/30">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
@@ -570,7 +571,7 @@ function ContainerInfo({ c }: { c: any}) {
</div> </div>
<div className="text-slate-400"> <div className="text-slate-400">
端口: <span className="text-slate-200 font-mono"> 端口: <span className="text-slate-200 font-mono">
{(c.ports || []).map(( p: any) => p.containerPort || p.port).join(', ') || '-'} {(c.ports || []).map((p: JsonData) => p.containerPort || p.port).join(', ') || '-'}
</span> </span>
</div> </div>
</div> </div>
@@ -6,6 +6,7 @@ import { ModalOverlay } from '@/components/ui/modal-overlay';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { YamlCodeMirror } from './YamlCodeMirror'; import { YamlCodeMirror } from './YamlCodeMirror';
import type { JsonData } from '@/types/common';
interface YamlImportDialogProps { interface YamlImportDialogProps {
clusterCode: string; clusterCode: string;
@@ -30,7 +31,7 @@ export function YamlImportDialog({ clusterCode, onClose, onApplied }: YamlImport
onApplied?.(); onApplied?.();
onClose(); onClose();
} catch (e) { } catch (e) {
toast({ title: '导入失败', description: (e as Record<string, any>).message, variant: 'destructive' }); toast({ title: '导入失败', description: (e as JsonData).message, variant: 'destructive' });
} finally { setApplying(false); } } finally { setApplying(false); }
}; };
@@ -10,6 +10,7 @@ import { getYamlResourceConfig, yamlDownloadFilename } from './k8s-yaml-resource
import { YamlEditorToolbar } from './YamlEditorToolbar'; import { YamlEditorToolbar } from './YamlEditorToolbar';
import { YamlCodeMirror } from './YamlCodeMirror'; import { YamlCodeMirror } from './YamlCodeMirror';
import { downloadYamlFile } from '@/utils/export'; import { downloadYamlFile } from '@/utils/export';
import type { JsonData } from '@/types/common';
interface YamlViewerProps { interface YamlViewerProps {
clusterCode: string; clusterCode: string;
@@ -61,7 +62,7 @@ export function YamlViewer({
setRawYaml(text); setRawYaml(text);
await applyStripped(text); await applyStripped(text);
} catch (e) { } catch (e) {
toast({ title: '获取YAML失败', description: (e as Record<string, any>).message, variant: 'destructive' }); toast({ title: '获取YAML失败', description: (e as JsonData).message, variant: 'destructive' });
const err = `# Error: ${(e as Error).message}`; const err = `# Error: ${(e as Error).message}`;
setRawYaml(err); setRawYaml(err);
setDisplayYaml(err); setDisplayYaml(err);
@@ -7,14 +7,15 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { TabsContent } from '@/components/ui/tabs'; import { TabsContent } from '@/components/ui/tabs';
import { CreateHPADialog } from './CreateHPADialog'; import { CreateHPADialog } from './CreateHPADialog';
import type { JsonData } from '@/types/common';
export interface DeploymentHpaTabProps { export interface DeploymentHpaTabProps {
hpas: any[]; hpas: JsonData[];
clusterCode: string; clusterCode: string;
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
onCreated: () => void; onCreated: () => void;
onDeleteHpa: (hpa: any) => void; onDeleteHpa: (hpa: JsonData) => void;
} }
export function DeploymentHpaTab({ export function DeploymentHpaTab({
@@ -58,7 +59,7 @@ export function DeploymentHpaTab({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{hpas.map((hpa: any) => { {hpas.map((hpa: JsonData) => {
const cpu = hpa.cpu_percent || 0; const cpu = hpa.cpu_percent || 0;
const mem = hpa.mem_percent || 0; const mem = hpa.mem_percent || 0;
const metricsParts = []; const metricsParts = [];
@@ -12,9 +12,10 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { TabsContent } from '@/components/ui/tabs'; import { TabsContent } from '@/components/ui/tabs';
import { extractContainers } from './helpers'; import { extractContainers } from './helpers';
import type { JsonData } from '@/types/common';
export interface DeploymentLogsTabProps { export interface DeploymentLogsTabProps {
pods: any[]; pods: JsonData[];
logTail: number; logTail: number;
onLogTailChange: (value: number) => void; onLogTailChange: (value: number) => void;
logsTabPod: string; logsTabPod: string;
@@ -61,7 +62,7 @@ export function DeploymentLogsTab({
<SelectValue placeholder="选择 Pod…" /> <SelectValue placeholder="选择 Pod…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{pods.map((p: any) => ( {pods.map((p: JsonData) => (
<SelectItem key={p.name} value={p.name}>{p.name}</SelectItem> <SelectItem key={p.name} value={p.name}>{p.name}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -71,7 +72,7 @@ export function DeploymentLogsTab({
className="bg-blue-600 hover:bg-blue-700 h-8" className="bg-blue-600 hover:bg-blue-700 h-8"
disabled={!logsTabPod} disabled={!logsTabPod}
onClick={() => { onClick={() => {
const pod = pods.find((p: any) => p.name === logsTabPod); const pod = pods.find((p: JsonData) => p.name === logsTabPod);
onOpenLogsViewer(logsTabPod, pod ? extractContainers(pod) : []); onOpenLogsViewer(logsTabPod, pod ? extractContainers(pod) : []);
}} }}
> >
@@ -7,12 +7,13 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { CreateHPADialog } from './CreateHPADialog'; import { CreateHPADialog } from './CreateHPADialog';
import type { JsonData } from '@/types/common';
export interface DeploymentOverviewProps { export interface DeploymentOverviewProps {
deploymentName: string; deploymentName: string;
namespace: string; namespace: string;
deployment: any; deployment?: JsonData;
hpas: any[]; hpas: JsonData[];
clusterCode: string; clusterCode: string;
ready: number; ready: number;
total: number; total: number;
@@ -9,9 +9,10 @@ import {
import { TabsContent } from '@/components/ui/tabs'; import { TabsContent } from '@/components/ui/tabs';
import { extractContainers } from './helpers'; import { extractContainers } from './helpers';
import type { PodWithContainers } from './types'; import type { PodWithContainers } from './types';
import type { JsonData } from '@/types/common';
export interface DeploymentPodsTabProps { export interface DeploymentPodsTabProps {
pods: any[]; pods: JsonData[];
onOpenLogsViewer: (pod: PodWithContainers) => void; onOpenLogsViewer: (pod: PodWithContainers) => void;
onOpenExec: (pod: PodWithContainers) => void; onOpenExec: (pod: PodWithContainers) => void;
onOpenFileBrowser: (pod: PodWithContainers) => void; onOpenFileBrowser: (pod: PodWithContainers) => void;
@@ -42,7 +43,7 @@ export function DeploymentPodsTab({ pods, onOpenLogsViewer, onOpenExec, onOpenFi
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
pods.map((pod: any) => ( pods.map((pod: JsonData) => (
<TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30"> <TableRow key={pod.name} className="border-slate-800 hover:bg-slate-800/30">
<TableCell className="font-medium text-slate-200">{pod.name}</TableCell> <TableCell className="font-medium text-slate-200">{pod.name}</TableCell>
<TableCell> <TableCell>
@@ -1,10 +1,11 @@
import type { JsonData } from "@/types/common";
// helpers.ts — DeploymentDetail 工具函数 // Logan.Li // helpers.ts — DeploymentDetail 工具函数 // Logan.Li
/** 从 Pod 对象提取容器名列表(兼容多种字段) */ /** 从 Pod 对象提取容器名列表(兼容多种字段) */
export function extractContainers(pod: any): string[] { export function extractContainers(pod: JsonData): string[] {
const c = pod?.containers; const c = pod?.containers;
if (Array.isArray(c)) return c.map((x: any) => typeof x === 'string' ? x : (x?.name || x?.Name)).filter(Boolean); if (Array.isArray(c)) return c.map((x: JsonData) => typeof x === 'string' ? x : (x?.name || x?.Name)).filter(Boolean);
if (Array.isArray(pod?.container_statuses)) return pod.container_statuses.map((x: any) => x?.name).filter(Boolean); if (Array.isArray(pod?.container_statuses)) return pod.container_statuses.map((x: JsonData) => x?.name).filter(Boolean);
if (Array.isArray(pod?.containerNames)) return pod.containerNames.filter(Boolean); if (Array.isArray(pod?.containerNames)) return pod.containerNames.filter(Boolean);
return []; return [];
} }
@@ -3,10 +3,11 @@
import type { import type {
ContainerForm, ProbeDef, LifecycleHandler, ContainerForm, ProbeDef, LifecycleHandler,
} from './types'; } from './types';
import type { JsonData } from '@/types/common';
// K8s container spec → form conversion // Logan.Li // K8s container spec → form conversion // Logan.Li
export function toContainerForm(c: any): ContainerForm { export function toContainerForm(c: JsonData): ContainerForm {
const probe = (p: any): ProbeDef | null => { const probe = (p: JsonData): ProbeDef | null => {
if (!p) return null; if (!p) return null;
let type: ProbeDef['type'] = 'none'; let type: ProbeDef['type'] = 'none';
if (p.httpGet) type = 'httpGet'; if (p.httpGet) type = 'httpGet';
@@ -26,7 +27,7 @@ export function toContainerForm(c: any): ContainerForm {
failureThreshold: p.failureThreshold, failureThreshold: p.failureThreshold,
}; };
}; };
const lh = (h: any): LifecycleHandler => { const lh = (h: JsonData): LifecycleHandler => {
if (!h) return { type: 'none' }; if (!h) return { type: 'none' };
let type: LifecycleHandler['type'] = 'none'; let type: LifecycleHandler['type'] = 'none';
if (h.exec) type = 'exec'; if (h.exec) type = 'exec';
@@ -48,8 +49,8 @@ export function toContainerForm(c: any): ContainerForm {
command: Array.isArray(c.command) ? c.command : [], command: Array.isArray(c.command) ? c.command : [],
args: Array.isArray(c.args) ? c.args : [], args: Array.isArray(c.args) ? c.args : [],
workingDir: c.workingDir || '', workingDir: c.workingDir || '',
env: (Array.isArray(c.env) ? c.env : []).map((e: any) => ({ name: e.name || '', value: e.value ?? '' })), env: (Array.isArray(c.env) ? c.env : []).map((e: JsonData) => ({ name: e.name || '', value: e.value ?? '' })),
ports: (Array.isArray(c.ports) ? c.ports : []).map((p: any) => ({ name: p.name || '', containerPort: p.containerPort ?? '', protocol: p.protocol || 'TCP' })), ports: (Array.isArray(c.ports) ? c.ports : []).map((p: JsonData) => ({ name: p.name || '', containerPort: p.containerPort ?? '', protocol: p.protocol || 'TCP' })),
resources: { resources: {
requests: c.resources?.requests || {}, requests: c.resources?.requests || {},
limits: c.resources?.limits || {}, limits: c.resources?.limits || {},
@@ -71,7 +72,7 @@ export function toContainerForm(c: any): ContainerForm {
capabilitiesAdd: sc.capabilities?.add || [], capabilitiesAdd: sc.capabilities?.add || [],
capabilitiesDrop: sc.capabilities?.drop || [], capabilitiesDrop: sc.capabilities?.drop || [],
} : null, } : null,
volumeMounts: (Array.isArray(c.volumeMounts) ? c.volumeMounts : []).map((m: any) => ({ volumeMounts: (Array.isArray(c.volumeMounts) ? c.volumeMounts : []).map((m: JsonData) => ({
name: m.name || '', mountPath: m.mountPath || '', subPath: m.subPath || '', readOnly: m.readOnly || false, name: m.name || '', mountPath: m.mountPath || '', subPath: m.subPath || '', readOnly: m.readOnly || false,
})), })),
}; };
@@ -88,9 +89,9 @@ export function emptyContainer(name = ''): ContainerForm {
}; };
} }
export function probeToK8s(p: ProbeDef | null | undefined): any { export function probeToK8s(p: ProbeDef | null | undefined): JsonData {
if (!p || p.type === 'none' || !p.type) return undefined; if (!p || p.type === 'none' || !p.type) return undefined;
const out: Record<string, any> = {}; const out: JsonData = {};
if (p.type === 'httpGet') { if (p.type === 'httpGet') {
out.httpGet = {}; out.httpGet = {};
if (p.httpPath) out.httpGet.path = p.httpPath; if (p.httpPath) out.httpGet.path = p.httpPath;
@@ -111,28 +112,28 @@ export function probeToK8s(p: ProbeDef | null | undefined): any {
return Object.keys(out).length > 0 ? out : undefined; return Object.keys(out).length > 0 ? out : undefined;
} }
export function handlerToK8s(h: LifecycleHandler | undefined): any { export function handlerToK8s(h: LifecycleHandler | undefined): JsonData {
if (!h || h.type === 'none' || !h.type) return undefined; if (!h || h.type === 'none' || !h.type) return undefined;
if (h.type === 'exec') { if (h.type === 'exec') {
const cmds = (h.execCommand || '').split('\n').map(s => s.trim()).filter(Boolean); const cmds = (h.execCommand || '').split('\n').map(s => s.trim()).filter(Boolean);
return cmds.length ? { exec: { command: cmds } } : undefined; return cmds.length ? { exec: { command: cmds } } : undefined;
} }
if (h.type === 'httpGet') { if (h.type === 'httpGet') {
const o: Record<string, any> = { httpGet: {} }; const o: JsonData = { httpGet: {} };
if (h.httpPath) o.httpGet.path = h.httpPath; if (h.httpPath) o.httpGet.path = h.httpPath;
if (h.httpPort !== '' && h.httpPort != null) o.httpGet.port = Number(h.httpPort); if (h.httpPort !== '' && h.httpPort != null) o.httpGet.port = Number(h.httpPort);
return o; return o;
} }
if (h.type === 'tcpSocket') { if (h.type === 'tcpSocket') {
const o: Record<string, any> = { tcpSocket: {} }; const o: JsonData = { tcpSocket: {} };
if (h.tcpPort !== '' && h.tcpPort != null) o.tcpSocket.port = Number(h.tcpPort); if (h.tcpPort !== '' && h.tcpPort != null) o.tcpSocket.port = Number(h.tcpPort);
return o; return o;
} }
return undefined; return undefined;
} }
export function containerToK8s(c: ContainerForm): any { export function containerToK8s(c: ContainerForm): JsonData {
const out: Record<string, any> = { name: c.name }; const out: JsonData = { name: c.name };
if (c.image) out.image = c.image; if (c.image) out.image = c.image;
if (c.imagePullPolicy) out.imagePullPolicy = c.imagePullPolicy; if (c.imagePullPolicy) out.imagePullPolicy = c.imagePullPolicy;
if (c.workingDir) out.workingDir = c.workingDir; if (c.workingDir) out.workingDir = c.workingDir;
@@ -140,14 +141,14 @@ export function containerToK8s(c: ContainerForm): any {
if (c.args.length > 0) out.args = c.args; if (c.args.length > 0) out.args = c.args;
if (c.env.length > 0) { if (c.env.length > 0) {
out.env = c.env.filter(e => e.name.trim()).map(e => { out.env = c.env.filter(e => e.name.trim()).map(e => {
const o: Record<string, any> = { name: e.name }; const o: JsonData = { name: e.name };
if (e.value != null) o.value = e.value; if (e.value != null) o.value = e.value;
return o; return o;
}); });
} }
if (c.ports.length > 0) { if (c.ports.length > 0) {
out.ports = c.ports.filter(p => p.containerPort !== '').map(p => { out.ports = c.ports.filter(p => p.containerPort !== '').map(p => {
const o: Record<string, any> = { containerPort: typeof p.containerPort === 'number' ? p.containerPort : Number(p.containerPort) }; const o: JsonData = { containerPort: typeof p.containerPort === 'number' ? p.containerPort : Number(p.containerPort) };
if (p.name) o.name = p.name; if (p.name) o.name = p.name;
if (p.protocol) o.protocol = p.protocol; if (p.protocol) o.protocol = p.protocol;
return o; return o;
@@ -174,14 +175,14 @@ export function containerToK8s(c: ContainerForm): any {
const rp = probeToK8s(c.readinessProbe); if (rp) out.readinessProbe = rp; const rp = probeToK8s(c.readinessProbe); if (rp) out.readinessProbe = rp;
const sp = probeToK8s(c.startupProbe); if (sp) out.startupProbe = sp; const sp = probeToK8s(c.startupProbe); if (sp) out.startupProbe = sp;
if (c.lifecycle) { if (c.lifecycle) {
const lc: Record<string, any> = {}; const lc: JsonData = {};
const ps = handlerToK8s(c.lifecycle.postStart); if (ps) lc.postStart = ps; const ps = handlerToK8s(c.lifecycle.postStart); if (ps) lc.postStart = ps;
const pst = handlerToK8s(c.lifecycle.preStop); if (pst) lc.preStop = pst; const pst = handlerToK8s(c.lifecycle.preStop); if (pst) lc.preStop = pst;
if (Object.keys(lc).length > 0) out.lifecycle = lc; if (Object.keys(lc).length > 0) out.lifecycle = lc;
} }
if (c.securityContext) { if (c.securityContext) {
const s = c.securityContext; const s = c.securityContext;
const sc: Record<string, any> = {}; const sc: JsonData = {};
if (s.runAsUser !== '' && s.runAsUser != null) sc.runAsUser = Number(s.runAsUser); if (s.runAsUser !== '' && s.runAsUser != null) sc.runAsUser = Number(s.runAsUser);
if (s.runAsGroup !== '' && s.runAsGroup != null) sc.runAsGroup = Number(s.runAsGroup); if (s.runAsGroup !== '' && s.runAsGroup != null) sc.runAsGroup = Number(s.runAsGroup);
if (s.runAsNonRoot != null) sc.runAsNonRoot = s.runAsNonRoot; if (s.runAsNonRoot != null) sc.runAsNonRoot = s.runAsNonRoot;
@@ -199,7 +200,7 @@ export function containerToK8s(c: ContainerForm): any {
} }
if (c.volumeMounts.length > 0) { if (c.volumeMounts.length > 0) {
out.volumeMounts = c.volumeMounts.filter(m => m.name.trim() && m.mountPath.trim()).map(m => { out.volumeMounts = c.volumeMounts.filter(m => m.name.trim() && m.mountPath.trim()).map(m => {
const o: Record<string, any> = { name: m.name, mountPath: m.mountPath }; const o: JsonData = { name: m.name, mountPath: m.mountPath };
if (m.subPath) o.subPath = m.subPath; if (m.subPath) o.subPath = m.subPath;
if (m.readOnly) o.readOnly = true; if (m.readOnly) o.readOnly = true;
return o; return o;
@@ -1,3 +1,4 @@
import type { JsonData } from "@/types/common";
// deployment-edit/types.ts — 容器编辑器类型定义 // deployment-edit/types.ts — 容器编辑器类型定义
// 作者: Logan <admin@attacker.club> // 作者: Logan <admin@attacker.club>
@@ -64,8 +65,8 @@ export interface DeploymentEditContainersProps {
clusterCode: string; clusterCode: string;
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
manifest: any | null; manifest: JsonData | null;
onSaved: (updated?: any) => void; onSaved: (updated?: JsonData) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -1,12 +1,13 @@
// Deployment manifest 统一保存 // Logan.Li // Deployment manifest 统一保存 // Logan.Li
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { prepareManifestForSave } from './k8s-manifest-utils'; import { prepareManifestForSave } from './k8s-manifest-utils';
import type { JsonData } from '@/types/common';
export async function saveDeploymentManifest( export async function saveDeploymentManifest(
clusterCode: string, clusterCode: string,
namespace: string, namespace: string,
deploymentName: string, deploymentName: string,
manifest: Record<string, any>, manifest: JsonData,
): Promise<void> { ): Promise<void> {
const yaml = await prepareManifestForSave(manifest); const yaml = await prepareManifestForSave(manifest);
await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, yaml); await kubernetesApi.updateResourceYaml(clusterCode, namespace, 'deployments', deploymentName, yaml);
@@ -6,8 +6,9 @@ import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Activity, Clock, Loader2 } from 'lucide-react'; import { Activity, Clock, Loader2 } from 'lucide-react';
import type { JsonData } from '@/types/common';
export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any[]; cronHpas: any[]; loading: string; replicas: number }) { export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: JsonData[]; cronHpas: JsonData[]; loading: string; replicas: number }) {
if (loading === 'scale') { if (loading === 'scale') {
return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>; return <div className="flex items-center justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-[var(--dg-text-muted)]" /></div>;
} }
@@ -42,20 +43,20 @@ export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{hpas.map((hpa: any, i: number) => { {hpas.map((hpa: JsonData, i: number) => {
const minR = hpa.spec?.minReplicas ?? hpa.minReplicas ?? 1; const minR = hpa.spec?.minReplicas ?? hpa.minReplicas ?? 1;
const maxR = hpa.spec?.maxReplicas ?? hpa.maxReplicas ?? 10; const maxR = hpa.spec?.maxReplicas ?? hpa.maxReplicas ?? 10;
const metrics = hpa.spec?.metrics || hpa.metrics || []; const metrics = hpa.spec?.metrics || hpa.metrics || [];
const curR = hpa.status?.currentReplicas ?? hpa.currentReplicas ?? 0; const curR = hpa.status?.currentReplicas ?? hpa.currentReplicas ?? 0;
const conditions = hpa.status?.conditions || hpa.conditions || []; const conditions = hpa.status?.conditions || hpa.conditions || [];
const ableToScale = conditions.find((c: any) => c.type === 'AbleToScale'); const ableToScale = conditions.find((c: JsonData) => c.type === 'AbleToScale');
const ok = ableToScale?.status === 'True'; const ok = ableToScale?.status === 'True';
return ( return (
<TableRow key={hpa.name || i} className="border-[var(--dg-border)]"> <TableRow key={hpa.name || i} className="border-[var(--dg-border)]">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{hpa.name || hpa.metadata?.name}</TableCell> <TableCell className="font-medium text-[var(--dg-text-primary)]">{hpa.name || hpa.metadata?.name}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)] font-mono">{minR} → {maxR}</TableCell> <TableCell className="text-sm text-[var(--dg-text-secondary)] font-mono">{minR} → {maxR}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]"> <TableCell className="text-xs text-[var(--dg-text-muted)]">
{metrics.map((m: any, j: number) => { {metrics.map((m: JsonData, j: number) => {
const resource = m.resource || m.object?.metric?.name || ''; const resource = m.resource || m.object?.metric?.name || '';
const target = m.resource?.target?.averageUtilization || m.resource?.target?.averageValue || m.resource?.target?.type || '-'; const target = m.resource?.target?.averageUtilization || m.resource?.target?.averageValue || m.resource?.target?.type || '-';
return <div key={j}>{resource?.name || resource}: {target}{m.resource?.target?.averageUtilization ? '%' : ''}</div>; return <div key={j}>{resource?.name || resource}: {target}{m.resource?.target?.averageUtilization ? '%' : ''}</div>;
@@ -98,16 +99,16 @@ export function HPACronHPATab({ hpas, cronHpas, loading, replicas }: { hpas: any
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{cronHpas.map((cron: any, i: number) => { {cronHpas.map((cron: JsonData, i: number) => {
const jobs = cron.spec?.jobs || cron.jobs || []; const jobs = cron.spec?.jobs || cron.jobs || [];
const tz = cron.spec?.timezone || 'UTC'; const tz = cron.spec?.timezone || 'UTC';
return ( return (
<TableRow key={cron.name || i} className="border-[var(--dg-border)]"> <TableRow key={cron.name || i} className="border-[var(--dg-border)]">
<TableCell className="font-medium text-[var(--dg-text-primary)]">{cron.name || cron.metadata?.name}</TableCell> <TableCell className="font-medium text-[var(--dg-text-primary)]">{cron.name || cron.metadata?.name}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-secondary)]">{ <TableCell className="text-sm text-[var(--dg-text-secondary)]">{
jobs.map((j: any) => j.targetSize || j.targetReplicas || '?').join(', ') jobs.map((j: JsonData) => j.targetSize || j.targetReplicas || '?').join(', ')
}</TableCell> }</TableCell>
<TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">{jobs.map((j: any) => j.schedule || '?').join(', ')}</TableCell> <TableCell className="text-xs font-mono text-[var(--dg-text-muted)]">{jobs.map((j: JsonData) => j.schedule || '?').join(', ')}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]">{tz}</TableCell> <TableCell className="text-xs text-[var(--dg-text-muted)]">{tz}</TableCell>
<TableCell> <TableCell>
<Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)]">已启用</Badge> <Badge className="bg-[var(--dg-green)]/20 text-[var(--dg-green)]">已启用</Badge>
@@ -6,8 +6,9 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { RotateCcw, Loader2 } from 'lucide-react'; import { RotateCcw, Loader2 } from 'lucide-react';
import { formatK8sTime } from '../k8s-yaml-utils'; import { formatK8sTime } from '../k8s-yaml-utils';
import type { JsonData } from '@/types/common';
export function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: any[]; rollbackLoading: number | null; onRollback: (rev: any) => void }) { export function HistoryTab({ revisions, rollbackLoading, onRollback }: { revisions: JsonData[]; rollbackLoading: number | null; onRollback: (rev: JsonData) => void }) {
if (revisions.length === 0) { if (revisions.length === 0) {
return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无历史版本</div>; return <div className="text-center text-[var(--dg-text-muted)] py-12">暂无历史版本</div>;
} }
@@ -8,6 +8,7 @@ import {
import { formatK8sTime, statusColor } from '../k8s-yaml-utils'; import { formatK8sTime, statusColor } from '../k8s-yaml-utils';
import { getPodTimeline, PodConditionsRow } from './helpers'; import { getPodTimeline, PodConditionsRow } from './helpers';
import type { DeploymentViewProps } from './types'; import type { DeploymentViewProps } from './types';
import type { JsonData } from '@/types/common';
export function PodsTab({ export function PodsTab({
clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser, clusterCode, pods, onShowPodLogs, onShowPodTerminal, onShowPodYaml, onShowPodFileBrowser,
@@ -61,9 +62,9 @@ export function PodsTab({
<div className="mt-3 pt-3 border-t border-[var(--dg-border)]"> <div className="mt-3 pt-3 border-t border-[var(--dg-border)]">
<PodConditionsRow pod={pod} /> <PodConditionsRow pod={pod} />
{/* Container details */} {/* Container details */}
{(pod as Record<string, any>).containers && ((pod as Record<string, any>).containers as any[]).length > 0 && ( {(pod as JsonData).containers && ((pod as JsonData).containers as JsonData[]).length > 0 && (
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2">
{((pod as Record<string, any>).containers as any[]).map((c: any, idx: number) => ( {((pod as JsonData).containers as JsonData[]).map((c: JsonData, idx: number) => (
<div key={idx} className="p-2 rounded bg-[var(--dg-bg-tertiary)] text-xs"> <div key={idx} className="p-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span> <span className="font-medium text-[var(--dg-text-primary)]">{c.name}</span>
@@ -5,6 +5,7 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Network, ExternalLink, Loader2 } from 'lucide-react'; import { Network, ExternalLink, Loader2 } from 'lucide-react';
import type { SvcItem, IngItem } from './types'; import type { SvcItem, IngItem } from './types';
import type { JsonData } from '@/types/common';
export function ServicesTab({ services, ingresses, loading }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) { export function ServicesTab({ services, ingresses, loading }: { services: SvcItem[]; ingresses: IngItem[]; loading: string; namespace: string }) {
if (loading === 'services') { if (loading === 'services') {
@@ -76,7 +77,7 @@ export function ServicesTab({ services, ingresses, loading }: { services: SvcIte
<TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.className}</TableCell> <TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.className}</TableCell>
<TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.hosts.join(', ')}</TableCell> <TableCell className="text-sm text-[var(--dg-text-muted)]">{ing.hosts.join(', ')}</TableCell>
<TableCell className="text-xs text-[var(--dg-text-muted)]"> <TableCell className="text-xs text-[var(--dg-text-muted)]">
{ing.rules.flatMap((r: any) => (r.http?.paths || r.paths || []).map((p: any) => p.path || '/')).join(', ')} {ing.rules.flatMap((r: JsonData) => (r.http?.paths || r.paths || []).map((p: JsonData) => p.path || '/')).join(', ')}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -4,18 +4,19 @@ import {
formatK8sTime, formatDuration, elapsedSeconds, STATUS_COLORS, formatK8sTime, formatDuration, elapsedSeconds, STATUS_COLORS,
} from '../k8s-yaml-utils'; } from '../k8s-yaml-utils';
import type { PodItem } from './types'; import type { PodItem } from './types';
import type { JsonData } from '@/types/common';
// Pod 条件时间线提取 // Logan.Li // Pod 条件时间线提取 // Logan.Li
export function getPodTimeline(pod: PodItem) { export function getPodTimeline(pod: PodItem) {
const cond: Record<string, any>[] = (pod as Record<string, any>).conditions || (pod as Record<string, any>).pod_conditions || []; const cond: JsonData[] = (pod as JsonData).conditions || (pod as JsonData).pod_conditions || [];
const find = (t: string) => cond.find((c: any) => (c.type || c.Type) === t); const find = (t: string) => cond.find((c: JsonData) => (c.type || c.Type) === t);
const ts = (c: any) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time; const ts = (c: JsonData) => c?.lastTransitionTime || c?.LastTransitionTime || c?.last_transition_time;
return { return {
scheduled: ts(find('PodScheduled') || {}), scheduled: ts(find('PodScheduled') || {}),
initialized: ts(find('Initialized') || {}), initialized: ts(find('Initialized') || {}),
containersReady: ts(find('ContainersReady') || {}), containersReady: ts(find('ContainersReady') || {}),
ready: ts(find('Ready') || {}), ready: ts(find('Ready') || {}),
startTime: pod.start_time || (pod as Record<string, any>).startTime, startTime: pod.start_time || (pod as JsonData).startTime,
}; };
} }
@@ -1,9 +1,10 @@
import type { JsonData } from "@/types/common";
// deployment-view/types.ts — Deployment 详情看板类型定义 // Logan.Li // deployment-view/types.ts — Deployment 详情看板类型定义 // Logan.Li
export interface PodItem { export interface PodItem {
name: string; namespace?: string; status?: string; node_name?: string; node?: string; name: string; namespace?: string; status?: string; node_name?: string; node?: string;
ip?: string; pod_ip?: string; restart_count?: number; age?: string; created_at?: string; ip?: string; pod_ip?: string; restart_count?: number; age?: string; created_at?: string;
start_time?: string; containers?: any[]; image?: string; images?: string[]; start_time?: string; containers?: JsonData[]; image?: string; images?: string[];
} }
export interface ReplicaSetItem { export interface ReplicaSetItem {
@@ -12,18 +13,18 @@ export interface ReplicaSetItem {
} }
export interface SvcItem { export interface SvcItem {
name: string; type: string; clusterIP: string; ports: any[]; selector: Record<string, string>; age: string; name: string; type: string; clusterIP: string; ports: JsonData[]; selector: Record<string, string>; age: string;
} }
export interface IngItem { export interface IngItem {
name: string; className: string; hosts: string[]; rules: any[]; age: string; name: string; className: string; hosts: string[]; rules: JsonData[]; age: string;
} }
export type EventItem = import('../k8s-event-utils').NormalizedEvent; export type EventItem = import('../k8s-event-utils').NormalizedEvent;
export interface DeploymentViewProps { export interface DeploymentViewProps {
clusterCode: string; namespace: string; deployment: any; clusterCode: string; namespace: string; deployment?: JsonData;
pods: PodItem[]; replicaSets: ReplicaSetItem[]; revisions: any[]; pods: PodItem[]; replicaSets: ReplicaSetItem[]; revisions: JsonData[];
onRefresh: () => void; onRefresh: () => void;
onShowPodLogs: (pod: PodItem) => void; onShowPodLogs: (pod: PodItem) => void;
onShowPodTerminal: (pod: PodItem) => void; onShowPodTerminal: (pod: PodItem) => void;
@@ -1,3 +1,4 @@
import type { JsonData } from "@/types/common";
// Deployment Pod 数据卷 ↔ K8s YAML 双向映射 // Logan.Li // Deployment Pod 数据卷 ↔ K8s YAML 双向映射 // Logan.Li
export type VolumeType = export type VolumeType =
@@ -26,7 +27,7 @@ export interface VolumeForm {
secretName?: string; secretName?: string;
secItems?: string; secItems?: string;
/** 未识别卷类型时保留原始对象,避免保存丢失 // Logan.Li */ /** 未识别卷类型时保留原始对象,避免保存丢失 // Logan.Li */
_raw?: Record<string, any>; _raw?: JsonData;
} }
export interface MountForm { export interface MountForm {
@@ -65,7 +66,7 @@ export function emptyVolumeEntry(defaultContainer = ''): VolumeEntry {
const SYSTEM_VOLUME_PREFIXES = ['kube-api-access-', 'default-token-', 'kube-']; const SYSTEM_VOLUME_PREFIXES = ['kube-api-access-', 'default-token-', 'kube-'];
/** 是否系统/不可编辑卷(保存时保留)// Logan.Li */ /** 是否系统/不可编辑卷(保存时保留)// Logan.Li */
export function isPassthroughVolume(v: Record<string, any>): boolean { export function isPassthroughVolume(v: JsonData): boolean {
if (!v || typeof v !== 'object') return false; if (!v || typeof v !== 'object') return false;
const name = String(v.name || ''); const name = String(v.name || '');
if (SYSTEM_VOLUME_PREFIXES.some((p) => name.startsWith(p))) return true; if (SYSTEM_VOLUME_PREFIXES.some((p) => name.startsWith(p))) return true;
@@ -91,7 +92,7 @@ export function parseKeyToPathItems(text?: string): Array<{ key: string; path: s
} }
/** K8s volume → 表单 // Logan.Li */ /** K8s volume → 表单 // Logan.Li */
export function volumeToForm(v: Record<string, any>): VolumeForm { export function volumeToForm(v: JsonData): VolumeForm {
if (!v || typeof v !== 'object') return { name: '', type: 'emptyDir' }; if (!v || typeof v !== 'object') return { name: '', type: 'emptyDir' };
const name = v.name || ''; const name = v.name || '';
if (v.persistentVolumeClaim) { if (v.persistentVolumeClaim) {
@@ -150,22 +151,22 @@ export function volumeToForm(v: Record<string, any>): VolumeForm {
} }
/** 表单 → K8s volume // Logan.Li */ /** 表单 → K8s volume // Logan.Li */
export function formToVolume(f: VolumeForm): Record<string, any> | null { export function formToVolume(f: VolumeForm): JsonData | null {
if (f.type === 'custom' && f._raw) return JSON.parse(JSON.stringify(f._raw)); if (f.type === 'custom' && f._raw) return JSON.parse(JSON.stringify(f._raw));
const volName = f.name?.trim(); const volName = f.name?.trim();
if (!volName) return null; if (!volName) return null;
const v: Record<string, any> = { name: volName }; const v: JsonData = { name: volName };
switch (f.type) { switch (f.type) {
case 'emptyDir': { case 'emptyDir': {
const ed: Record<string, any> = {}; const ed: JsonData = {};
if (f.medium?.trim()) ed.medium = f.medium.trim(); if (f.medium?.trim()) ed.medium = f.medium.trim();
if (f.sizeLimit?.trim()) ed.sizeLimit = f.sizeLimit.trim(); if (f.sizeLimit?.trim()) ed.sizeLimit = f.sizeLimit.trim();
v.emptyDir = ed; v.emptyDir = ed;
break; break;
} }
case 'hostPath': { case 'hostPath': {
const hp: Record<string, any> = { path: f.path?.trim() || '/' }; const hp: JsonData = { path: f.path?.trim() || '/' };
if (f.pathType?.trim()) hp.type = f.pathType.trim(); if (f.pathType?.trim()) hp.type = f.pathType.trim();
v.hostPath = hp; v.hostPath = hp;
break; break;
@@ -173,7 +174,7 @@ export function formToVolume(f: VolumeForm): Record<string, any> | null {
case 'configMap': { case 'configMap': {
const cmName = f.configMapName?.trim(); const cmName = f.configMapName?.trim();
if (!cmName) return null; if (!cmName) return null;
const cm: Record<string, any> = { name: cmName }; const cm: JsonData = { name: cmName };
const items = parseKeyToPathItems(f.cmItems); const items = parseKeyToPathItems(f.cmItems);
if (items) cm.items = items; if (items) cm.items = items;
v.configMap = cm; v.configMap = cm;
@@ -182,7 +183,7 @@ export function formToVolume(f: VolumeForm): Record<string, any> | null {
case 'secret': { case 'secret': {
const secName = f.secretName?.trim(); const secName = f.secretName?.trim();
if (!secName) return null; if (!secName) return null;
const sec: Record<string, any> = { secretName: secName }; const sec: JsonData = { secretName: secName };
const items = parseKeyToPathItems(f.secItems); const items = parseKeyToPathItems(f.secItems);
if (items) sec.items = items; if (items) sec.items = items;
v.secret = sec; v.secret = sec;
@@ -191,7 +192,7 @@ export function formToVolume(f: VolumeForm): Record<string, any> | null {
case 'persistentVolumeClaim': { case 'persistentVolumeClaim': {
const claim = f.pvcName?.trim(); const claim = f.pvcName?.trim();
if (!claim) return null; if (!claim) return null;
const pvc: Record<string, any> = { claimName: claim }; const pvc: JsonData = { claimName: claim };
if (f.pvcReadOnly) pvc.readOnly = true; if (f.pvcReadOnly) pvc.readOnly = true;
v.persistentVolumeClaim = pvc; v.persistentVolumeClaim = pvc;
break; break;
@@ -200,7 +201,7 @@ export function formToVolume(f: VolumeForm): Record<string, any> | null {
const server = f.nfsServer?.trim(); const server = f.nfsServer?.trim();
const path = f.nfsPath?.trim(); const path = f.nfsPath?.trim();
if (!server || !path) return null; if (!server || !path) return null;
const nfs: Record<string, any> = { server, path }; const nfs: JsonData = { server, path };
if (f.nfsReadOnly) nfs.readOnly = true; if (f.nfsReadOnly) nfs.readOnly = true;
v.nfs = nfs; v.nfs = nfs;
break; break;
@@ -211,7 +212,7 @@ export function formToVolume(f: VolumeForm): Record<string, any> | null {
return v; return v;
} }
export function mountToForm(containerName: string, vm: Record<string, any>): MountForm { export function mountToForm(containerName: string, vm: JsonData): MountForm {
return { return {
containerName, containerName,
volumeName: vm.name || '', volumeName: vm.name || '',
@@ -221,9 +222,9 @@ export function mountToForm(containerName: string, vm: Record<string, any>): Mou
}; };
} }
export function formToMount(m: MountForm): Record<string, any> | null { export function formToMount(m: MountForm): JsonData | null {
if (!m.volumeName?.trim() || !m.mountPath?.trim()) return null; if (!m.volumeName?.trim() || !m.mountPath?.trim()) return null;
const vm: Record<string, any> = { name: m.volumeName.trim(), mountPath: m.mountPath.trim() }; const vm: JsonData = { name: m.volumeName.trim(), mountPath: m.mountPath.trim() };
if (m.subPath?.trim()) vm.subPath = m.subPath.trim(); if (m.subPath?.trim()) vm.subPath = m.subPath.trim();
if (m.readOnly) vm.readOnly = true; if (m.readOnly) vm.readOnly = true;
return vm; return vm;
@@ -231,7 +232,7 @@ export function formToMount(m: MountForm): Record<string, any> | null {
const SYSTEM_MOUNT_PREFIXES = ['/var/run/secrets/kubernetes.io/serviceaccount']; const SYSTEM_MOUNT_PREFIXES = ['/var/run/secrets/kubernetes.io/serviceaccount'];
export function isPassthroughMount(vm: Record<string, any>, passthroughVolumeNames: Set<string>): boolean { export function isPassthroughMount(vm: JsonData, passthroughVolumeNames: Set<string>): boolean {
if (!vm) return false; if (!vm) return false;
if (passthroughVolumeNames.has(vm.name)) return true; if (passthroughVolumeNames.has(vm.name)) return true;
const mp = vm.mountPath || ''; const mp = vm.mountPath || '';
@@ -239,14 +240,14 @@ export function isPassthroughMount(vm: Record<string, any>, passthroughVolumeNam
} }
/** 从 manifest 解析卷与挂载表单 // Logan.Li */ /** 从 manifest 解析卷与挂载表单 // Logan.Li */
export function parseStorageFromManifest(manifest: Record<string, any>): { export function parseStorageFromManifest(manifest: JsonData): {
volumes: VolumeForm[]; volumes: VolumeForm[];
mounts: MountForm[]; mounts: MountForm[];
containers: string[]; containers: string[];
passthroughVolumes: Record<string, any>[]; passthroughVolumes: JsonData[];
} { } {
const podSpec = manifest?.spec?.template?.spec || {}; const podSpec = manifest?.spec?.template?.spec || {};
const rawVols: Record<string, any>[] = Array.isArray(podSpec.volumes) ? podSpec.volumes : []; const rawVols: JsonData[] = Array.isArray(podSpec.volumes) ? podSpec.volumes : [];
const passthroughVolumes = rawVols.filter(isPassthroughVolume); const passthroughVolumes = rawVols.filter(isPassthroughVolume);
const editableVols = rawVols.filter((v) => !isPassthroughVolume(v)); const editableVols = rawVols.filter((v) => !isPassthroughVolume(v));
@@ -260,7 +261,7 @@ export function parseStorageFromManifest(manifest: Record<string, any>): {
const mounts: MountForm[] = []; const mounts: MountForm[] = [];
for (const c of allContainers) { for (const c of allContainers) {
const cName = c.name || '(unnamed)'; const cName = c.name || '(unnamed)';
const vms: Record<string, any>[] = Array.isArray(c.volumeMounts) ? c.volumeMounts : []; const vms: JsonData[] = Array.isArray(c.volumeMounts) ? c.volumeMounts : [];
for (const vm of vms) { for (const vm of vms) {
if (isPassthroughMount(vm, passthroughNames)) continue; if (isPassthroughMount(vm, passthroughNames)) continue;
mounts.push(mountToForm(cName, vm)); mounts.push(mountToForm(cName, vm));
@@ -276,10 +277,10 @@ export function parseStorageFromManifest(manifest: Record<string, any>): {
} }
/** 解析为 Kuboard 一卷一卡条目(支持一卷多容器挂载)// Logan.Li */ /** 解析为 Kuboard 一卷一卡条目(支持一卷多容器挂载)// Logan.Li */
export function parseVolumeEntries(manifest: Record<string, any>): { export function parseVolumeEntries(manifest: JsonData): {
entries: VolumeEntry[]; entries: VolumeEntry[];
containers: string[]; containers: string[];
passthroughVolumes: Record<string, any>[]; passthroughVolumes: JsonData[];
} { } {
const { volumes, mounts, containers, passthroughVolumes } = parseStorageFromManifest(manifest); const { volumes, mounts, containers, passthroughVolumes } = parseStorageFromManifest(manifest);
const defaultContainer = containers.find((c) => c !== '(unnamed)') || containers[0] || ''; const defaultContainer = containers.find((c) => c !== '(unnamed)') || containers[0] || '';
@@ -326,32 +327,32 @@ export function volumeEntriesToStorage(entries: VolumeEntry[]): {
/** 将表单写回 manifest.spec.template.spec // Logan.Li */ /** 将表单写回 manifest.spec.template.spec // Logan.Li */
export function applyStorageToManifest( export function applyStorageToManifest(
manifest: Record<string, any>, manifest: JsonData,
volumes: VolumeForm[], volumes: VolumeForm[],
mounts: MountForm[], mounts: MountForm[],
passthroughVolumes: Record<string, any>[], passthroughVolumes: JsonData[],
): Record<string, any> { ): JsonData {
const base = JSON.parse(JSON.stringify(manifest)); const base = JSON.parse(JSON.stringify(manifest));
base.spec = base.spec || {}; base.spec = base.spec || {};
base.spec.template = base.spec.template || {}; base.spec.template = base.spec.template || {};
base.spec.template.spec = base.spec.template.spec || {}; base.spec.template.spec = base.spec.template.spec || {};
const podSpec = base.spec.template.spec; const podSpec = base.spec.template.spec;
const userVols = volumes.map(formToVolume).filter(Boolean) as Record<string, any>[]; const userVols = volumes.map(formToVolume).filter(Boolean) as JsonData[];
const mergedVols = [...passthroughVolumes, ...userVols]; const mergedVols = [...passthroughVolumes, ...userVols];
if (mergedVols.length > 0) podSpec.volumes = mergedVols; if (mergedVols.length > 0) podSpec.volumes = mergedVols;
else delete podSpec.volumes; else delete podSpec.volumes;
const passthroughNames = new Set(passthroughVolumes.map((v) => v.name)); const passthroughNames = new Set(passthroughVolumes.map((v) => v.name));
const applyMounts = (c: Record<string, any>) => { const applyMounts = (c: JsonData) => {
const cName = c.name || '(unnamed)'; const cName = c.name || '(unnamed)';
const existing: Record<string, any>[] = Array.isArray(c.volumeMounts) ? c.volumeMounts : []; const existing: JsonData[] = Array.isArray(c.volumeMounts) ? c.volumeMounts : [];
const kept = existing.filter((vm) => isPassthroughMount(vm, passthroughNames)); const kept = existing.filter((vm) => isPassthroughMount(vm, passthroughNames));
const user = mounts const user = mounts
.filter((m) => m.containerName === cName) .filter((m) => m.containerName === cName)
.map(formToMount) .map(formToMount)
.filter(Boolean) as Record<string, any>[]; .filter(Boolean) as JsonData[];
const merged = [...kept, ...user]; const merged = [...kept, ...user];
if (merged.length > 0) c.volumeMounts = merged; if (merged.length > 0) c.volumeMounts = merged;
else delete c.volumeMounts; else delete c.volumeMounts;
@@ -6,10 +6,11 @@ import { Label } from '@/components/ui/label';
import { AlertTriangle, Loader2, Minus, Plus, Trash2 } from 'lucide-react'; import { AlertTriangle, Loader2, Minus, Plus, Trash2 } from 'lucide-react';
import { ModalShell } from './ModalShell'; import { ModalShell } from './ModalShell';
import type { ActionModal } from './types'; import type { ActionModal } from './types';
import type { JsonData } from '@/types/common';
export interface ActionModalsProps { export interface ActionModalsProps {
modal: ActionModal; modal: ActionModal;
deployment: any; deployment?: JsonData;
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
newImage: string; newImage: string;
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import type { JsonData } from '@/types/common';
export interface CICDWebhookContentProps { export interface CICDWebhookContentProps {
clusterCode: string; clusterCode: string;
@@ -13,7 +14,7 @@ export interface CICDWebhookContentProps {
export function CICDWebhookContent({ clusterCode, namespace, deploymentName }: CICDWebhookContentProps) { export function CICDWebhookContent({ clusterCode, namespace, deploymentName }: CICDWebhookContentProps) {
const { toast } = useToast(); const { toast } = useToast();
const token = useUserStore(( s: any) => s.token) || localStorage.getItem('token') || ''; const token = useUserStore((s: JsonData) => s.token) || localStorage.getItem('token') || '';
const webhookUrl = `${window.location.origin}/api/v1/clusters/${clusterCode}/namespaces/${namespace}/deployments/${deploymentName}/update-image`; const webhookUrl = `${window.location.origin}/api/v1/clusters/${clusterCode}/namespaces/${namespace}/deployments/${deploymentName}/update-image`;
const curlCmd = `curl -X POST '${webhookUrl}' \\ const curlCmd = `curl -X POST '${webhookUrl}' \\
-H 'Authorization: Bearer ${token ? token.substring(0, 16) + '...' : 'YOUR_TOKEN'}' \\ -H 'Authorization: Bearer ${token ? token.substring(0, 16) + '...' : 'YOUR_TOKEN'}' \\
@@ -6,12 +6,13 @@ import { DeploymentEditContainers } from '../DeploymentEditContainers';
import { DeploymentEditStorage } from '../DeploymentEditStorage'; import { DeploymentEditStorage } from '../DeploymentEditStorage';
import { DeploymentEditAdvanced } from '../DeploymentEditAdvanced'; import { DeploymentEditAdvanced } from '../DeploymentEditAdvanced';
import { DeploymentEditService } from '../DeploymentEditService'; import { DeploymentEditService } from '../DeploymentEditService';
import type { JsonData } from '@/types/common';
export interface EditModeContainerProps { export interface EditModeContainerProps {
clusterCode: string; clusterCode: string;
namespace: string; namespace: string;
deploymentName: string; deploymentName: string;
manifest: any | null; manifest: JsonData | null;
onSaved: () => void; onSaved: () => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -19,13 +20,13 @@ export interface EditModeContainerProps {
export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: EditModeContainerProps) { export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: EditModeContainerProps) {
type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service'; type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service';
const [activeTab, setActiveTab] = useState<EditTab>('basic'); const [activeTab, setActiveTab] = useState<EditTab>('basic');
const [editManifest, setEditManifest] = useState<any>(manifest); const [editManifest, setEditManifest] = useState<JsonData>(manifest);
useEffect(() => { useEffect(() => {
if (manifest) setEditManifest(manifest); if (manifest) setEditManifest(manifest);
}, [manifest]); }, [manifest]);
const handleSaved = (updated?: any) => { const handleSaved = (updated?: JsonData) => {
if (updated) setEditManifest(updated); if (updated) setEditManifest(updated);
onSaved(); onSaved();
}; };
@@ -8,6 +8,7 @@ import { FileText, RefreshCw, Loader2, X } from 'lucide-react';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import type { JsonData } from '@/types/common';
export interface PodLogsModalProps { export interface PodLogsModalProps {
clusterCode: string; clusterCode: string;
@@ -28,7 +29,7 @@ export function PodLogsModal({ clusterCode, namespace, podName, containers, onCl
const loadLogs = async () => { const loadLogs = async () => {
setLoading(true); setLoading(true);
try { try {
const res: Record<string, any> = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, tail, container || undefined); const res: JsonData = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, tail, container || undefined);
setLogs(res?.data?.logs || res?.logs || res || '无日志'); setLogs(res?.data?.logs || res?.logs || res || '无日志');
} catch { } catch {
setLogs('获取日志失败'); setLogs('获取日志失败');
@@ -1,5 +1,6 @@
// k8s-event-utils — K8s Event 字段归一化与展示 // Logan.Li // k8s-event-utils — K8s Event 字段归一化与展示 // Logan.Li
import { elapsedSeconds, formatDuration, formatK8sTime } from './k8s-yaml-utils'; import { elapsedSeconds, formatDuration, formatK8sTime } from './k8s-yaml-utils';
import type { JsonData } from '@/types/common';
export interface NormalizedEvent { export interface NormalizedEvent {
name: string; name: string;
@@ -18,7 +19,7 @@ export interface NormalizedEvent {
} }
/** 后端/API 原始事件 → 统一结构 // Logan.Li */ /** 后端/API 原始事件 → 统一结构 // Logan.Li */
export function normalizeK8sEvent(raw: Record<string, any>): NormalizedEvent { export function normalizeK8sEvent(raw: JsonData): NormalizedEvent {
const objectKind = raw.object_kind || raw.objectKind || ''; const objectKind = raw.object_kind || raw.objectKind || '';
const objectName = raw.object_name || raw.objectName || ''; const objectName = raw.object_name || raw.objectName || '';
const object = raw.object const object = raw.object
@@ -1,5 +1,6 @@
// K8s manifest 解析/序列化/精简共用工具 // Logan.Li // K8s manifest 解析/序列化/精简共用工具 // Logan.Li
import { cleanYAML } from './k8s-yaml-utils'; import { cleanYAML } from './k8s-yaml-utils';
import type { JsonData } from '@/types/common';
/** 主流方案:编辑/展示时应排除的 metadata 注解 // Logan.Li */ /** 主流方案:编辑/展示时应排除的 metadata 注解 // Logan.Li */
const STRIP_ANNOTATIONS = new Set([ const STRIP_ANNOTATIONS = new Set([
@@ -91,7 +92,7 @@ function matchesDefault(val: unknown, def: unknown): boolean {
return false; return false;
} }
function stripMetadataBlock(meta: Record<string, any> | undefined): void { function stripMetadataBlock(meta: JsonData | undefined): void {
if (!meta || typeof meta !== 'object') return; if (!meta || typeof meta !== 'object') return;
for (const k of STRIP_METADATA_KEYS) delete meta[k]; for (const k of STRIP_METADATA_KEYS) delete meta[k];
if (meta.annotations) { if (meta.annotations) {
@@ -124,7 +125,7 @@ function pruneEmpty(node: unknown): void {
} }
} }
function stripObjectDefaults(obj: Record<string, any> | undefined, defaults: Record<string, unknown>): void { function stripObjectDefaults(obj: JsonData | undefined, defaults: Record<string, unknown>): void {
if (!obj || typeof obj !== 'object') return; if (!obj || typeof obj !== 'object') return;
for (const [key, def] of Object.entries(defaults)) { for (const [key, def] of Object.entries(defaults)) {
if (key in obj && matchesDefault(obj[key], def)) delete obj[key]; if (key in obj && matchesDefault(obj[key], def)) delete obj[key];
@@ -140,7 +141,7 @@ function isSystemMountPath(path: string): boolean {
} }
/** 移除系统卷及对应 volumeMount // Logan.Li */ /** 移除系统卷及对应 volumeMount // Logan.Li */
function stripSystemVolumes(spec: Record<string, any> | undefined): void { function stripSystemVolumes(spec: JsonData | undefined): void {
if (!spec || typeof spec !== 'object') return; if (!spec || typeof spec !== 'object') return;
const volumes: Array<{ name?: string }> = spec.volumes; const volumes: Array<{ name?: string }> = spec.volumes;
if (Array.isArray(volumes)) { if (Array.isArray(volumes)) {
@@ -150,7 +151,7 @@ function stripSystemVolumes(spec: Record<string, any> | undefined): void {
spec.volumes = volumes.filter((v) => !v?.name || !isSystemVolumeName(v.name)); spec.volumes = volumes.filter((v) => !v?.name || !isSystemVolumeName(v.name));
if (spec.volumes.length === 0) delete spec.volumes; if (spec.volumes.length === 0) delete spec.volumes;
const stripMounts = (containers: Array<Record<string, any>> | undefined) => { const stripMounts = (containers: Array<JsonData> | undefined) => {
if (!Array.isArray(containers)) return; if (!Array.isArray(containers)) return;
for (const c of containers) { for (const c of containers) {
if (!Array.isArray(c.volumeMounts)) continue; if (!Array.isArray(c.volumeMounts)) continue;
@@ -168,7 +169,7 @@ function stripSystemVolumes(spec: Record<string, any> | undefined): void {
} }
} }
function stripContainerDefaults(container: Record<string, any>): void { function stripContainerDefaults(container: JsonData): void {
if (!container || typeof container !== 'object') return; if (!container || typeof container !== 'object') return;
stripObjectDefaults(container, CONTAINER_DEFAULTS); stripObjectDefaults(container, CONTAINER_DEFAULTS);
if (container.imagePullPolicy) { if (container.imagePullPolicy) {
@@ -181,7 +182,7 @@ function stripContainerDefaults(container: Record<string, any>): void {
if (container.securityContext && isEmptyCollection(container.securityContext)) delete container.securityContext; if (container.securityContext && isEmptyCollection(container.securityContext)) delete container.securityContext;
} }
function stripPodSpecDefaults(spec: Record<string, any> | undefined): void { function stripPodSpecDefaults(spec: JsonData | undefined): void {
if (!spec || typeof spec !== 'object') return; if (!spec || typeof spec !== 'object') return;
stripObjectDefaults(spec, POD_SPEC_DEFAULTS); stripObjectDefaults(spec, POD_SPEC_DEFAULTS);
if ('serviceAccount' in spec) delete spec.serviceAccount; if ('serviceAccount' in spec) delete spec.serviceAccount;
@@ -195,7 +196,7 @@ function stripPodSpecDefaults(spec: Record<string, any> | undefined): void {
} }
/** Deployment 默认滚动更新策略 // Logan.Li */ /** Deployment 默认滚动更新策略 // Logan.Li */
function stripDeploymentStrategy(spec: Record<string, any> | undefined): void { function stripDeploymentStrategy(spec: JsonData | undefined): void {
if (!spec?.strategy || spec.strategy.type !== 'RollingUpdate') return; if (!spec?.strategy || spec.strategy.type !== 'RollingUpdate') return;
const ru = spec.strategy.rollingUpdate; const ru = spec.strategy.rollingUpdate;
if (!ru || typeof ru !== 'object') return; if (!ru || typeof ru !== 'object') return;
@@ -208,7 +209,7 @@ function stripDeploymentStrategy(spec: Record<string, any> | undefined): void {
} }
} }
function stripServiceSpec(spec: Record<string, any> | undefined): void { function stripServiceSpec(spec: JsonData | undefined): void {
if (!spec || typeof spec !== 'object') return; if (!spec || typeof spec !== 'object') return;
for (const k of SERVICE_RUNTIME_KEYS) delete spec[k]; for (const k of SERVICE_RUNTIME_KEYS) delete spec[k];
stripObjectDefaults(spec, SERVICE_SPEC_DEFAULTS); stripObjectDefaults(spec, SERVICE_SPEC_DEFAULTS);
@@ -223,19 +224,19 @@ function stripServiceSpec(spec: Record<string, any> | undefined): void {
} }
/** Ingress 精简(对齐集群导出 cleanResourceYAML)// Logan.Li */ /** Ingress 精简(对齐集群导出 cleanResourceYAML)// Logan.Li */
function stripIngressSpec(spec: Record<string, any> | undefined): void { function stripIngressSpec(spec: JsonData | undefined): void {
if (!spec || typeof spec !== 'object') return; if (!spec || typeof spec !== 'object') return;
if (!spec.ingressClassName) delete spec.ingressClassName; if (!spec.ingressClassName) delete spec.ingressClassName;
} }
function stripWorkloadTemplate(spec: Record<string, any> | undefined): void { function stripWorkloadTemplate(spec: JsonData | undefined): void {
if (!spec?.template || typeof spec.template !== 'object') return; if (!spec?.template || typeof spec.template !== 'object') return;
if (spec.template.metadata) stripMetadataBlock(spec.template.metadata); if (spec.template.metadata) stripMetadataBlock(spec.template.metadata);
const tplSpec = spec.template.spec; const tplSpec = spec.template.spec;
if (tplSpec) stripPodSpecDefaults(tplSpec); if (tplSpec) stripPodSpecDefaults(tplSpec);
} }
function stripKindDefaults(manifest: Record<string, any>): void { function stripKindDefaults(manifest: JsonData): void {
const kind = String(manifest.kind || '').toLowerCase(); const kind = String(manifest.kind || '').toLowerCase();
if (!manifest.spec) return; if (!manifest.spec) return;
if (kind === 'deployment' || kind === 'statefulset' || kind === 'daemonset') { if (kind === 'deployment' || kind === 'statefulset' || kind === 'daemonset') {
@@ -249,7 +250,7 @@ function stripKindDefaults(manifest: Record<string, any>): void {
} }
/** 对象级剥离运行时字段 // Logan.Li */ /** 对象级剥离运行时字段 // Logan.Li */
export function stripRuntimeFields(manifest: Record<string, any>): Record<string, any> { export function stripRuntimeFields(manifest: JsonData): JsonData {
const cloned = JSON.parse(JSON.stringify(manifest)); const cloned = JSON.parse(JSON.stringify(manifest));
delete cloned.status; delete cloned.status;
stripMetadataBlock(cloned.metadata); stripMetadataBlock(cloned.metadata);
@@ -260,23 +261,23 @@ export function stripRuntimeFields(manifest: Record<string, any>): Record<string
return cloned; return cloned;
} }
export async function parseManifest(text: string): Promise<Record<string, any> | null> { export async function parseManifest(text: string): Promise<JsonData | null> {
if (!text?.trim()) return null; if (!text?.trim()) return null;
try { return JSON.parse(text); } catch { /* fallthrough */ } try { return JSON.parse(text); } catch { /* fallthrough */ }
try { try {
const jsyaml: Record<string, any> = await import('js-yaml'); const jsyaml: JsonData = await import('js-yaml');
const parsed = jsyaml.load(text); const parsed = jsyaml.load(text);
return parsed && typeof parsed === 'object' ? parsed : null; return parsed && typeof parsed === 'object' ? parsed : null;
} catch { return null; } } catch { return null; }
} }
export async function dumpManifest(manifest: Record<string, any>): Promise<string> { export async function dumpManifest(manifest: JsonData): Promise<string> {
const jsyaml: Record<string, any> = await import('js-yaml'); const jsyaml: JsonData = await import('js-yaml');
return jsyaml.dump(manifest, { lineWidth: 120, noRefs: true, sortKeys: false }); return jsyaml.dump(manifest, { lineWidth: 120, noRefs: true, sortKeys: false });
} }
/** manifest → 精简 YAML(对象剥离 + 文本级默认值清理)// Logan.Li */ /** manifest → 精简 YAML(对象剥离 + 文本级默认值清理)// Logan.Li */
export async function manifestToEditableYaml(manifest: Record<string, any>): Promise<string> { export async function manifestToEditableYaml(manifest: JsonData): Promise<string> {
const stripped = stripRuntimeFields(manifest); const stripped = stripRuntimeFields(manifest);
const dumped = await dumpManifest(stripped); const dumped = await dumpManifest(stripped);
return cleanYAML(dumped); return cleanYAML(dumped);
@@ -284,7 +285,7 @@ export async function manifestToEditableYaml(manifest: Record<string, any>): Pro
/** 原始 YAML → 可编辑 manifest + 精简 YAML // Logan.Li */ /** 原始 YAML → 可编辑 manifest + 精简 YAML // Logan.Li */
export async function parseEditableManifest(text: string): Promise<{ export async function parseEditableManifest(text: string): Promise<{
manifest: Record<string, any> | null; manifest: JsonData | null;
yaml: string; yaml: string;
}> { }> {
const parsed = await parseManifest(text); const parsed = await parseManifest(text);
@@ -295,7 +296,7 @@ export async function parseEditableManifest(text: string): Promise<{
} }
/** 保存前:剥离运行时字段 + 清理默认值 // Logan.Li */ /** 保存前:剥离运行时字段 + 清理默认值 // Logan.Li */
export async function prepareManifestForSave(manifest: Record<string, any>): Promise<string> { export async function prepareManifestForSave(manifest: JsonData): Promise<string> {
return manifestToEditableYaml(manifest); return manifestToEditableYaml(manifest);
} }
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more