feat: 机器离线统计/日志、连通性重试检测与暗黑模式支持
后端更新: - 在 machines 表新增 offline_count、total_offline_seconds、last_offline_at、last_offline_reason 字段,用于记录机器离线统计信息。 - 新增 offline_logs 表,记录每次离线的开始时间、结束时间、持续时长及离线原因。 - 重写 ping 服务为状态机模式: – 单台机器依次使用 system ping / ICMP / TCP(SSH端口) 三种方式检测连通性; – 任意一次成功即视为在线; – 若一次失败,则会连续重试 3 次(间隔 2 秒),3 次均失败才判定为离线,避免网络抖动导致误判。 – 仅在状态发生 online→offline 或 offline→online 变化时更新 offline_logs 与累计时长。 – 机器按顺序逐个检测,每台间隔 30 秒。 - 新增 API:GET /admin/machines/:id/offline-logs,用于查询最近 50 条离线记录。 - CleanupLogs 增加对 offline_logs 的过期清理。 前端更新: - 机器详情页(MachineDetail.vue)新增离线统计卡片,展示离线次数、累计离线时长、上次离线原因及最近 5 条离线记录。 - 全局支持暗黑模式: – App.vue 引入 light/dark CSS 变量,并加载 Element Plus 深色主题(dark/css-vars.css)。 – MainLayout.vue 侧边栏新增主题切换按钮,支持深浅色切换并持久化到 localStorage。 – Topology.vue 监听主题变化,动态重绘 G6 节点/边的颜色、背景与阴影。 – MachineList.vue / MachineDetail.vue 全面适配深色变量(卡片、表格、标签、进度条等)。 - 机器列表卡片 UI 调整:改为顶部 OS 色点 + 在线/离线状态胶囊标签,离线卡片降低透明度并去色。 构建与部署: - 重新构建前端并打包静态资源。 - 交叉编译 Linux amd64 二进制并更新 deploy/lan-manager-debian12.tar.gz 部署包。
This commit is contained in:
Binary file not shown.
@@ -93,6 +93,20 @@ func migrate() error {
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_rel_src ON relationships(source_machine_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_rel_src ON relationships(source_machine_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_rel_tgt ON relationships(target_machine_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_rel_tgt ON relationships(target_machine_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at)`,
|
||||||
|
`ALTER TABLE machines ADD COLUMN offline_count INTEGER DEFAULT 0`,
|
||||||
|
`ALTER TABLE machines ADD COLUMN total_offline_seconds INTEGER DEFAULT 0`,
|
||||||
|
`ALTER TABLE machines ADD COLUMN last_offline_at DATETIME`,
|
||||||
|
`ALTER TABLE machines ADD COLUMN last_offline_reason TEXT`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS offline_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE,
|
||||||
|
reason TEXT,
|
||||||
|
started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ended_at DATETIME,
|
||||||
|
duration_seconds INTEGER
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_offline_logs_machine ON offline_logs(machine_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_offline_logs_started ON offline_logs(started_at)`,
|
||||||
}
|
}
|
||||||
for _, s := range stmts {
|
for _, s := range stmts {
|
||||||
if _, err := DB.Exec(s); err != nil {
|
if _, err := DB.Exec(s); err != nil {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (h *MachineHandler) List(c *gin.Context) {
|
|||||||
osFilter := c.Query("os_type")
|
osFilter := c.Query("os_type")
|
||||||
search := c.Query("search")
|
search := c.Query("search")
|
||||||
|
|
||||||
query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE 1=1`
|
query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at FROM machines WHERE 1=1`
|
||||||
args := []interface{}{}
|
args := []interface{}{}
|
||||||
if osFilter != "" {
|
if osFilter != "" {
|
||||||
query += ` AND os_type = ?`
|
query += ` AND os_type = ?`
|
||||||
@@ -66,12 +66,14 @@ func (h *MachineHandler) List(c *gin.Context) {
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m models.Machine
|
var m models.Machine
|
||||||
var lp, ss *time.Time
|
var lp, ss *time.Time
|
||||||
err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt)
|
var lo *time.Time
|
||||||
|
err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m.LastPingAt = lp
|
m.LastPingAt = lp
|
||||||
m.SSHSyncedAt = ss
|
m.SSHSyncedAt = ss
|
||||||
|
m.LastOfflineAt = lo
|
||||||
m.SSHPassword = models.NullString{} // never return password
|
m.SSHPassword = models.NullString{} // never return password
|
||||||
if !isAdmin {
|
if !isAdmin {
|
||||||
sanitizeMachine(&m)
|
sanitizeMachine(&m)
|
||||||
@@ -113,8 +115,10 @@ func (h *MachineHandler) Get(c *gin.Context) {
|
|||||||
|
|
||||||
var m models.Machine
|
var m models.Machine
|
||||||
var lp, ss *time.Time
|
var lp, ss *time.Time
|
||||||
err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE id = ?`, id).
|
var lo *time.Time
|
||||||
Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt)
|
err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, offline_count, total_offline_seconds, last_offline_at, last_offline_reason, created_at, updated_at FROM machines WHERE id = ?`, id).
|
||||||
|
Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.OfflineCount, &m.TotalOfflineSeconds, &lo, &m.LastOfflineReason, &m.CreatedAt, &m.UpdatedAt)
|
||||||
|
m.LastOfflineAt = lo
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
@@ -165,6 +169,32 @@ func (h *MachineHandler) Get(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *MachineHandler) OfflineLogs(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, err := db.DB.Query(`SELECT id, machine_id, reason, started_at, ended_at, duration_seconds FROM offline_logs WHERE machine_id = ? ORDER BY started_at DESC LIMIT 50`, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
logs := []models.OfflineLog{}
|
||||||
|
for rows.Next() {
|
||||||
|
var l models.OfflineLog
|
||||||
|
var endedAt *time.Time
|
||||||
|
var dur *int
|
||||||
|
if err := rows.Scan(&l.ID, &l.MachineID, &l.Reason, &l.StartedAt, &endedAt, &dur); err == nil {
|
||||||
|
l.EndedAt = endedAt
|
||||||
|
l.DurationSeconds = dur
|
||||||
|
logs = append(logs, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, logs)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MachineHandler) Create(c *gin.Context) {
|
func (h *MachineHandler) Create(c *gin.Context) {
|
||||||
var m models.Machine
|
var m models.Machine
|
||||||
if err := c.ShouldBindJSON(&m); err != nil {
|
if err := c.ShouldBindJSON(&m); err != nil {
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func main() {
|
|||||||
admin.DELETE("/machines/:id", mh.Delete)
|
admin.DELETE("/machines/:id", mh.Delete)
|
||||||
admin.POST("/machines/:id/ssh-info", mh.SSHInfo)
|
admin.POST("/machines/:id/ssh-info", mh.SSHInfo)
|
||||||
admin.POST("/machines/:id/sync-ssh", mh.SyncSSH)
|
admin.POST("/machines/:id/sync-ssh", mh.SyncSSH)
|
||||||
|
admin.GET("/machines/:id/offline-logs", mh.OfflineLogs)
|
||||||
|
|
||||||
sh := handlers.NewServiceHandler()
|
sh := handlers.NewServiceHandler()
|
||||||
admin.GET("/machines/:id/services", sh.ListByMachine)
|
admin.GET("/machines/:id/services", sh.ListByMachine)
|
||||||
|
|||||||
@@ -53,7 +53,20 @@ type Machine struct {
|
|||||||
SSHSyncedAt *time.Time `json:"ssh_synced_at"`
|
SSHSyncedAt *time.Time `json:"ssh_synced_at"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
ServiceCount int `json:"service_count"`
|
ServiceCount int `json:"service_count"`
|
||||||
|
OfflineCount int `json:"offline_count"`
|
||||||
|
TotalOfflineSeconds int `json:"total_offline_seconds"`
|
||||||
|
LastOfflineAt *time.Time `json:"last_offline_at"`
|
||||||
|
LastOfflineReason NullString `json:"last_offline_reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OfflineLog struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
MachineID int64 `json:"machine_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
EndedAt *time.Time `json:"ended_at"`
|
||||||
|
DurationSeconds *int `json:"duration_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
|||||||
@@ -6,39 +6,74 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"lan-manager/server/db"
|
"lan-manager/server/db"
|
||||||
"lan-manager/server/utils"
|
"lan-manager/server/utils"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"net"
|
|
||||||
|
|
||||||
"golang.org/x/net/icmp"
|
"golang.org/x/net/icmp"
|
||||||
"golang.org/x/net/ipv4"
|
"golang.org/x/net/ipv4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func PingHost(ip string) bool {
|
// PingResult holds connectivity status and failure reason
|
||||||
// Try system ping first (no special privileges needed on Linux)
|
type PingResult struct {
|
||||||
|
Online bool
|
||||||
|
Reason string // empty if online, otherwise human-readable failure reason
|
||||||
|
}
|
||||||
|
|
||||||
|
func PingHost(ip string, port int) PingResult {
|
||||||
if ok := pingWithSystem(ip); ok {
|
if ok := pingWithSystem(ip); ok {
|
||||||
return true
|
return PingResult{Online: true}
|
||||||
}
|
}
|
||||||
// Fallback to raw ICMP
|
if ok := pingWithICMP(ip); ok {
|
||||||
return pingWithICMP(ip)
|
return PingResult{Online: true}
|
||||||
|
}
|
||||||
|
if ok := pingWithTCP(ip, port); ok {
|
||||||
|
return PingResult{Online: true}
|
||||||
|
}
|
||||||
|
return PingResult{Online: false, Reason: "unreachable (ping/icmp/tcp all failed)"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PingHostRepeated pings the host up to retries times.
|
||||||
|
// If any attempt succeeds, it returns online immediately.
|
||||||
|
// Only if all retries fail does it return offline.
|
||||||
|
func PingHostRepeated(ip string, port int, retries int) PingResult {
|
||||||
|
if retries <= 0 {
|
||||||
|
retries = 1
|
||||||
|
}
|
||||||
|
var lastReason string
|
||||||
|
for i := 0; i < retries; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
res := PingHost(ip, port)
|
||||||
|
if res.Online {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
lastReason = res.Reason
|
||||||
|
}
|
||||||
|
return PingResult{Online: false, Reason: fmt.Sprintf("unreachable after %d retries (%s)", retries, lastReason)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func pingWithSystem(ip string) bool {
|
func pingWithSystem(ip string) bool {
|
||||||
var cmd *exec.Cmd
|
var cmd *exec.Cmd
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
cmd = exec.Command("ping", "-n", "1", "-w", "3000", ip)
|
cmd = exec.Command("ping", "-n", "1", "-w", "5000", ip)
|
||||||
} else if runtime.GOOS == "darwin" {
|
} else if runtime.GOOS == "darwin" {
|
||||||
// macOS ping -W is in milliseconds
|
cmd = exec.Command("ping", "-c", "1", "-W", "5000", ip)
|
||||||
cmd = exec.Command("ping", "-c", "1", "-W", "3000", ip)
|
|
||||||
} else {
|
} else {
|
||||||
// Linux ping -W is in seconds
|
cmd = exec.Command("ping", "-c", "1", "-W", "5", ip)
|
||||||
cmd = exec.Command("ping", "-c", "1", "-W", "3", ip)
|
|
||||||
}
|
}
|
||||||
err := cmd.Run()
|
out, err := cmd.CombinedOutput()
|
||||||
return err == nil
|
if err != nil {
|
||||||
|
if _, ok := err.(*exec.ExitError); !ok {
|
||||||
|
fmt.Printf("[Ping] system ping error for %s: %v, output: %s\n", ip, err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func pingWithICMP(ip string) bool {
|
func pingWithICMP(ip string) bool {
|
||||||
@@ -48,10 +83,11 @@ func pingWithICMP(ip string) bool {
|
|||||||
}
|
}
|
||||||
defer c.Close()
|
defer c.Close()
|
||||||
|
|
||||||
|
id := os.Getpid() & 0xffff
|
||||||
m := &icmp.Message{
|
m := &icmp.Message{
|
||||||
Type: ipv4.ICMPTypeEcho,
|
Type: ipv4.ICMPTypeEcho,
|
||||||
Code: 0,
|
Code: 0,
|
||||||
Body: &icmp.Echo{ID: 1, Seq: 1, Data: []byte("lan-manager")},
|
Body: &icmp.Echo{ID: id, Seq: 1, Data: []byte("lan-manager")},
|
||||||
}
|
}
|
||||||
wb, err := m.Marshal(nil)
|
wb, err := m.Marshal(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -62,7 +98,7 @@ func pingWithICMP(ip string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rb := make([]byte, 1500)
|
rb := make([]byte, 1500)
|
||||||
_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
|
_ = c.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
n, _, err := c.ReadFrom(rb)
|
n, _, err := c.ReadFrom(rb)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -74,6 +110,19 @@ func pingWithICMP(ip string) bool {
|
|||||||
return rm.Type == ipv4.ICMPTypeEchoReply
|
return rm.Type == ipv4.ICMPTypeEchoReply
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func pingWithTCP(ip string, port int) bool {
|
||||||
|
if port <= 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
addr := fmt.Sprintf("%s:%d", ip, port)
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_ = conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func parseIP(ip string) []byte {
|
func parseIP(ip string) []byte {
|
||||||
parts := strings.Split(ip, ".")
|
parts := strings.Split(ip, ".")
|
||||||
if len(parts) != 4 {
|
if len(parts) != 4 {
|
||||||
@@ -88,77 +137,127 @@ func parseIP(ip string) []byte {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type machinePing struct {
|
||||||
|
id int64
|
||||||
|
ip string
|
||||||
|
sshPort int
|
||||||
|
sshUsername string
|
||||||
|
sshPassword string
|
||||||
|
wasOnline bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePingResult(m *machinePing, res PingResult) {
|
||||||
|
online := res.Online
|
||||||
|
reason := res.Reason
|
||||||
|
var onlineInt int
|
||||||
|
if online {
|
||||||
|
onlineInt = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// State transition: online -> offline
|
||||||
|
if m.wasOnline && !online {
|
||||||
|
_, dbErr := db.DB.Exec(`UPDATE machines SET is_online=0, last_ping_at=CURRENT_TIMESTAMP, offline_count=offline_count+1, last_offline_at=CURRENT_TIMESTAMP, last_offline_reason=? WHERE id=?`,
|
||||||
|
reason, m.id)
|
||||||
|
if dbErr != nil {
|
||||||
|
fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Ping] %s -> online=false, reason=%s\n", m.ip, reason)
|
||||||
|
}
|
||||||
|
_, _ = db.DB.Exec(`INSERT INTO offline_logs (machine_id, reason, started_at) VALUES (?, ?, CURRENT_TIMESTAMP)`, m.id, reason)
|
||||||
|
m.wasOnline = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// State transition: offline -> online
|
||||||
|
if !m.wasOnline && online {
|
||||||
|
_, dbErr := db.DB.Exec(`UPDATE machines SET is_online=1, last_ping_at=CURRENT_TIMESTAMP, last_offline_reason='' WHERE id=?`, m.id)
|
||||||
|
if dbErr != nil {
|
||||||
|
fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Ping] %s -> online=true\n", m.ip)
|
||||||
|
}
|
||||||
|
var logID int64
|
||||||
|
var startedAt time.Time
|
||||||
|
row := db.DB.QueryRow(`SELECT id, started_at FROM offline_logs WHERE machine_id=? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1`, m.id)
|
||||||
|
if err := row.Scan(&logID, &startedAt); err == nil {
|
||||||
|
duration := int(time.Since(startedAt).Seconds())
|
||||||
|
_, _ = db.DB.Exec(`UPDATE offline_logs SET ended_at=CURRENT_TIMESTAMP, duration_seconds=? WHERE id=?`, duration, logID)
|
||||||
|
_, _ = db.DB.Exec(`UPDATE machines SET total_offline_seconds=total_offline_seconds+? WHERE id=?`, duration, m.id)
|
||||||
|
}
|
||||||
|
m.wasOnline = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// No state change
|
||||||
|
_, dbErr := db.DB.Exec(`UPDATE machines SET is_online=?, last_ping_at=CURRENT_TIMESTAMP WHERE id=?`, onlineInt, m.id)
|
||||||
|
if dbErr != nil {
|
||||||
|
fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[Ping] %s -> online=%v\n", m.ip, online)
|
||||||
|
}
|
||||||
|
|
||||||
|
if online && m.sshUsername != "" && m.sshPassword != "" {
|
||||||
|
go func(mid int64, mip string, mport int, muser, mpass string) {
|
||||||
|
plainPass, decryptErr := utils.Decrypt(mpass)
|
||||||
|
if decryptErr != nil {
|
||||||
|
fmt.Printf("[SSH] decrypt failed for %s:%d: %v\n", mip, mport, decryptErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := GetSSHInfo(mip, mport, muser, plainPass)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[SSH] auto-fetch failed for %s:%d: %v\n", mip, mport, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
portsStr := ""
|
||||||
|
if len(result.ListenPorts) > 0 {
|
||||||
|
b, _ := json.Marshal(result.ListenPorts)
|
||||||
|
portsStr = string(b)
|
||||||
|
if len(portsStr) > 2 {
|
||||||
|
portsStr = portsStr[1 : len(portsStr)-1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, dbErr := db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
||||||
|
result.RawCPUInfo, result.RawMemoryInfo, result.RawDiskInfo, result.Uptime, portsStr, mid)
|
||||||
|
if dbErr != nil {
|
||||||
|
fmt.Printf("[SSH] auto-fetch db error for %s:%d: %v\n", mip, mport, dbErr)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[SSH] auto-fetch success for %s:%d\n", mip, mport)
|
||||||
|
}
|
||||||
|
}(m.id, m.ip, m.sshPort, m.sshUsername, m.sshPassword)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func StartPingService(interval int) {
|
func StartPingService(interval int) {
|
||||||
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
const step = 30 * time.Second
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for range ticker.C {
|
time.Sleep(2 * time.Second)
|
||||||
rows, err := db.DB.Query(`SELECT id, ip, ssh_port, ssh_username, ssh_password FROM machines`)
|
|
||||||
|
for {
|
||||||
|
rows, err := db.DB.Query(`SELECT id, ip, ssh_port, ssh_username, ssh_password, is_online FROM machines ORDER BY id ASC`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("[Ping] query error: %v\n", err)
|
fmt.Printf("[Ping] query error: %v\n", err)
|
||||||
|
time.Sleep(step)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
type machinePing struct {
|
|
||||||
id int64
|
|
||||||
ip string
|
|
||||||
sshPort int
|
|
||||||
sshUsername string
|
|
||||||
sshPassword string
|
|
||||||
}
|
|
||||||
list := []machinePing{}
|
list := []machinePing{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var m machinePing
|
var m machinePing
|
||||||
if err := rows.Scan(&m.id, &m.ip, &m.sshPort, &m.sshUsername, &m.sshPassword); err == nil {
|
var isOnline int
|
||||||
|
if err := rows.Scan(&m.id, &m.ip, &m.sshPort, &m.sshUsername, &m.sshPassword, &isOnline); err == nil {
|
||||||
|
m.wasOnline = isOnline == 1
|
||||||
list = append(list, m)
|
list = append(list, m)
|
||||||
} else {
|
|
||||||
fmt.Printf("[Ping] scan error: %v\n", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rows.Close()
|
rows.Close()
|
||||||
fmt.Printf("[Ping] tick processed %d machines\n", len(list))
|
|
||||||
|
|
||||||
for _, m := range list {
|
fmt.Printf("[Ping] start round, %d machines, interval %v each\n", len(list), step)
|
||||||
online := PingHost(m.ip)
|
|
||||||
var onlineInt int
|
|
||||||
if online {
|
|
||||||
onlineInt = 1
|
|
||||||
}
|
|
||||||
_, dbErr := db.DB.Exec(`UPDATE machines SET is_online = ?, last_ping_at = CURRENT_TIMESTAMP WHERE id = ?`, onlineInt, m.id)
|
|
||||||
if dbErr != nil {
|
|
||||||
fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[Ping] %s -> online=%v\n", m.ip, online)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto fetch SSH info if credentials are saved
|
for i := range list {
|
||||||
if m.sshUsername != "" && m.sshPassword != "" {
|
res := PingHostRepeated(list[i].ip, list[i].sshPort, 3)
|
||||||
go func(mid int64, mip string, mport int, muser, mpass string) {
|
handlePingResult(&list[i], res)
|
||||||
plainPass, decryptErr := utils.Decrypt(mpass)
|
time.Sleep(step)
|
||||||
if decryptErr != nil {
|
|
||||||
fmt.Printf("[SSH] decrypt failed for %s:%d: %v\n", mip, mport, decryptErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
result, err := GetSSHInfo(mip, mport, muser, plainPass)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("[SSH] auto-fetch failed for %s:%d: %v\n", mip, mport, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
portsStr := ""
|
|
||||||
if len(result.ListenPorts) > 0 {
|
|
||||||
b, _ := json.Marshal(result.ListenPorts)
|
|
||||||
portsStr = string(b)
|
|
||||||
if len(portsStr) > 2 {
|
|
||||||
portsStr = portsStr[1 : len(portsStr)-1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_, dbErr := db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
|
||||||
result.RawCPUInfo, result.RawMemoryInfo, result.RawDiskInfo, result.Uptime, portsStr, mid)
|
|
||||||
if dbErr != nil {
|
|
||||||
fmt.Printf("[SSH] auto-fetch db error for %s:%d: %v\n", mip, mport, dbErr)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("[SSH] auto-fetch success for %s:%d\n", mip, mport)
|
|
||||||
}
|
|
||||||
}(m.id, m.ip, m.sshPort, m.sshUsername, m.sshPassword)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
@@ -175,7 +274,8 @@ func CleanupLogs(ctx context.Context, retentionDays int) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
_, _ = db.DB.Exec(`DELETE FROM operation_logs WHERE created_at < datetime('now', '-`+fmt.Sprintf("%d", retentionDays)+` days')`)
|
_, _ = db.DB.Exec(`DELETE FROM operation_logs WHERE created_at < datetime('now', '-` + fmt.Sprintf("%d", retentionDays) + ` days')`)
|
||||||
|
_, _ = db.DB.Exec(`DELETE FROM offline_logs WHERE started_at < datetime('now', '-` + fmt.Sprintf("%d", retentionDays) + ` days')`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -3,24 +3,64 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const saved = localStorage.getItem('theme')
|
||||||
|
if (saved) {
|
||||||
|
document.documentElement.classList.toggle('dark', saved === 'dark')
|
||||||
|
} else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* CSS Variables */
|
/* Light mode (default) */
|
||||||
:root {
|
:root {
|
||||||
--bg: #f6f7f9;
|
--bg: #f8fafc;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
--text: #111827;
|
--surface-hover: #f8fafc;
|
||||||
--text-secondary: #4b5563;
|
--text: #0f172a;
|
||||||
--text-muted: #9ca3af;
|
--text-secondary: #475569;
|
||||||
--border: #e5e7eb;
|
--text-muted: #94a3b8;
|
||||||
|
--border: #e2e8f0;
|
||||||
|
--border-strong: #cbd5e1;
|
||||||
--primary: #3b82f6;
|
--primary: #3b82f6;
|
||||||
--primary-weak: #eff6ff;
|
--primary-weak: #eff6ff;
|
||||||
--success: #22c55e;
|
--success: #22c55e;
|
||||||
--warning: #f59e0b;
|
--warning: #f59e0b;
|
||||||
--danger: #ef4444;
|
--danger: #ef4444;
|
||||||
--radius: 14px;
|
--radius: 14px;
|
||||||
--shadow: 0 6px 24px rgba(0,0,0,0.04);
|
--shadow: 0 1px 3px rgba(2, 8, 23, 0.05), 0 1px 2px rgba(2, 8, 23, 0.03);
|
||||||
|
--shadow-lg: 0 10px 30px rgba(2, 8, 23, 0.08);
|
||||||
|
|
||||||
|
--pill-bg: #0f172a;
|
||||||
|
--pill-text: #f8fafc;
|
||||||
|
--pill-border: rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode */
|
||||||
|
html.dark {
|
||||||
|
--bg: #020617;
|
||||||
|
--surface: #0f172a;
|
||||||
|
--surface-hover: #1e293b;
|
||||||
|
--text: #f8fafc;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--border: #1e293b;
|
||||||
|
--border-strong: #334155;
|
||||||
|
--primary: #60a5fa;
|
||||||
|
--primary-weak: rgba(59,130,246,0.15);
|
||||||
|
--success: #34d399;
|
||||||
|
--warning: #fbbf24;
|
||||||
|
--danger: #f87171;
|
||||||
|
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
--pill-bg: #1e293b;
|
||||||
|
--pill-text: #f8fafc;
|
||||||
|
--pill-border: #334155;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
@@ -33,6 +73,7 @@ html, body, #app {
|
|||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
transition: background .2s ease, color .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
@@ -48,6 +89,7 @@ html, body, #app {
|
|||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
transition: background .2s ease, border-color .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-title {
|
.card-title {
|
||||||
@@ -60,7 +102,7 @@ html, body, #app {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Element Plus overrides for a softer look */
|
/* Element Plus overrides */
|
||||||
.el-button {
|
.el-button {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -71,6 +113,12 @@ html, body, #app {
|
|||||||
--el-button-hover-bg-color: #2563eb;
|
--el-button-hover-bg-color: #2563eb;
|
||||||
--el-button-hover-border-color: #2563eb;
|
--el-button-hover-border-color: #2563eb;
|
||||||
}
|
}
|
||||||
|
html.dark .el-button--primary {
|
||||||
|
--el-button-bg-color: #3b82f6;
|
||||||
|
--el-button-border-color: #3b82f6;
|
||||||
|
--el-button-hover-bg-color: #2563eb;
|
||||||
|
--el-button-hover-border-color: #2563eb;
|
||||||
|
}
|
||||||
.el-input__wrapper {
|
.el-input__wrapper {
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
@@ -88,8 +136,8 @@ html, body, #app {
|
|||||||
padding: 10px 20px 18px;
|
padding: 10px 20px 18px;
|
||||||
}
|
}
|
||||||
.el-table {
|
.el-table {
|
||||||
--el-table-header-bg-color: #f9fafb;
|
--el-table-header-bg-color: var(--surface-hover);
|
||||||
--el-table-row-hover-bg-color: #f3f4f6;
|
--el-table-row-hover-bg-color: var(--surface-hover);
|
||||||
--el-table-border-color: var(--border);
|
--el-table-border-color: var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -111,9 +159,12 @@ html, body, #app {
|
|||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: rgba(0,0,0,0.12);
|
background: rgba(128,128,128,0.25);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
html.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(148,163,184,0.25);
|
||||||
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const updateMachine = (id, data) => api.put(`/machines/${id}`, data)
|
|||||||
export const deleteMachine = (id) => api.delete(`/machines/${id}`)
|
export const deleteMachine = (id) => api.delete(`/machines/${id}`)
|
||||||
export const sshInfo = (id, data) => api.post(`/machines/${id}/ssh-info`, data)
|
export const sshInfo = (id, data) => api.post(`/machines/${id}/ssh-info`, data)
|
||||||
export const syncSSH = (id, data) => api.post(`/machines/${id}/sync-ssh`, data)
|
export const syncSSH = (id, data) => api.post(`/machines/${id}/sync-ssh`, data)
|
||||||
|
export const fetchOfflineLogs = (id) => api.get(`/machines/${id}/offline-logs`)
|
||||||
|
|
||||||
export const fetchServices = (machineId) => api.get(`/machines/${machineId}/services`)
|
export const fetchServices = (machineId) => api.get(`/machines/${machineId}/services`)
|
||||||
export const fetchAllServices = () => api.get('/services')
|
export const fetchAllServices = () => api.get('/services')
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
<span>操作日志</span>
|
<span>操作日志</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
|
<div class="theme-toggle" @click="toggleTheme">
|
||||||
|
<el-icon class="theme-icon"><component :is="isDark ? Sunny : Moon" /></el-icon>
|
||||||
|
<span>{{ isDark ? '浅色模式' : '深色模式' }}</span>
|
||||||
|
</div>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<el-icon class="user-icon"><User /></el-icon>
|
<el-icon class="user-icon"><User /></el-icon>
|
||||||
@@ -40,15 +44,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { Monitor, Share, Document, User, SwitchButton } from '@element-plus/icons-vue'
|
import { Monitor, Share, Document, User, SwitchButton, Sunny, Moon } from '@element-plus/icons-vue'
|
||||||
import { getAuth, refreshAuth } from '@/router'
|
import { getAuth, refreshAuth } from '@/router'
|
||||||
import { logout as apiLogout } from '@/api'
|
import { logout as apiLogout } from '@/api'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isAdmin = computed(() => getAuth().is_admin)
|
const isAdmin = computed(() => getAuth().is_admin)
|
||||||
|
const isDark = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isDark.value = document.documentElement.classList.contains('dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
isDark.value = !isDark.value
|
||||||
|
document.documentElement.classList.toggle('dark', isDark.value)
|
||||||
|
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||||
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
@@ -182,6 +197,26 @@ async function logout() {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
margin: 0 10px 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: rgba(255,255,255,0.55);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background .15s ease, color .15s ease;
|
||||||
|
}
|
||||||
|
.theme-toggle:hover {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
}
|
||||||
|
.theme-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.main.no-sidebar {
|
.main.no-sidebar {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import ElementPlus from 'element-plus'
|
import ElementPlus from 'element-plus'
|
||||||
import 'element-plus/dist/index.css'
|
import 'element-plus/dist/index.css'
|
||||||
|
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|||||||
@@ -138,6 +138,34 @@
|
|||||||
<el-empty v-else description="暂无服务" :image-size="80" />
|
<el-empty v-else description="暂无服务" :image-size="80" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Offline Stats & Logs -->
|
||||||
|
<div class="card" v-if="isAdmin">
|
||||||
|
<div class="card-title">离线统计</div>
|
||||||
|
<div class="offline-stats">
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-num">{{ machine.offline_count || 0 }}</div>
|
||||||
|
<div class="stat-label">离线次数</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box">
|
||||||
|
<div class="stat-num">{{ formatDuration(machine.total_offline_seconds) }}</div>
|
||||||
|
<div class="stat-label">累计离线时长</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-box" v-if="machine.last_offline_reason">
|
||||||
|
<div class="stat-reason">{{ machine.last_offline_reason }}</div>
|
||||||
|
<div class="stat-label">上次离线原因</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="offlineLogs.length" class="offline-logs">
|
||||||
|
<div class="log-title">最近离线记录</div>
|
||||||
|
<div v-for="log in offlineLogs.slice(0, 5)" :key="log.id" class="log-row">
|
||||||
|
<span class="log-time">{{ formatTime(log.started_at) }}</span>
|
||||||
|
<span class="log-reason">{{ log.reason || '未知原因' }}</span>
|
||||||
|
<span class="log-dur" v-if="log.duration_seconds !== null && log.duration_seconds !== undefined">持续 {{ formatDuration(log.duration_seconds) }}</span>
|
||||||
|
<span class="log-dur pending" v-else>未恢复</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Relationships -->
|
<!-- Relationships -->
|
||||||
<div class="card" v-if="isAdmin">
|
<div class="card" v-if="isAdmin">
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
@@ -278,7 +306,7 @@ import {
|
|||||||
fetchMachine, updateMachine, deleteMachine,
|
fetchMachine, updateMachine, deleteMachine,
|
||||||
fetchServices, createService, updateService, deleteService,
|
fetchServices, createService, updateService, deleteService,
|
||||||
fetchRelationships, createRelationship, updateRelationship, deleteRelationship,
|
fetchRelationships, createRelationship, updateRelationship, deleteRelationship,
|
||||||
sshInfo, syncSSH, fetchMachines,
|
sshInfo, syncSSH, fetchMachines, fetchOfflineLogs,
|
||||||
checkAuth, uiRefreshInterval,
|
checkAuth, uiRefreshInterval,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import { getAuth } from '@/router'
|
import { getAuth } from '@/router'
|
||||||
@@ -293,6 +321,7 @@ const machine = ref(null)
|
|||||||
const services = ref([])
|
const services = ref([])
|
||||||
const relationships = ref([])
|
const relationships = ref([])
|
||||||
const allMachines = ref([])
|
const allMachines = ref([])
|
||||||
|
const offlineLogs = ref([])
|
||||||
|
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const editing = ref({})
|
const editing = ref({})
|
||||||
@@ -343,6 +372,10 @@ onUnmounted(() => {
|
|||||||
async function loadMachine() {
|
async function loadMachine() {
|
||||||
const res = await fetchMachine(machineId)
|
const res = await fetchMachine(machineId)
|
||||||
machine.value = res.data.machine
|
machine.value = res.data.machine
|
||||||
|
if (isAdmin) {
|
||||||
|
const logsRes = await fetchOfflineLogs(machineId)
|
||||||
|
offlineLogs.value = logsRes.data
|
||||||
|
}
|
||||||
// 如果已有同步过的 SSH 信息,直接展示上次结果
|
// 如果已有同步过的 SSH 信息,直接展示上次结果
|
||||||
if (machine.value && (machine.value.cpu_info || machine.value.memory_info || machine.value.disk_info || machine.value.uptime)) {
|
if (machine.value && (machine.value.cpu_info || machine.value.memory_info || machine.value.disk_info || machine.value.uptime)) {
|
||||||
sshResult.value = {
|
sshResult.value = {
|
||||||
@@ -544,6 +577,20 @@ function formatTime(t) {
|
|||||||
const pad = n => String(n).padStart(2, '0')
|
const pad = n => String(n).padStart(2, '0')
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!seconds && seconds !== 0) return '-'
|
||||||
|
const s = Number(seconds)
|
||||||
|
if (s < 60) return `${s}秒`
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)}分${s % 60}秒`
|
||||||
|
const h = Math.floor(s / 3600)
|
||||||
|
const m = Math.floor((s % 3600) / 60)
|
||||||
|
const sec = s % 60
|
||||||
|
if (h < 24) return `${h}时${m}分`
|
||||||
|
const d = Math.floor(h / 24)
|
||||||
|
const hr = h % 24
|
||||||
|
return `${d}天${hr}时${m}分`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -572,8 +619,10 @@ function formatTime(t) {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.status-badge.online { background: #dcfce7; color: #166534; }
|
.status-badge.online { background: rgba(34,197,94,0.12); color: #15803d; }
|
||||||
.status-badge.offline { background: #fee2e2; color: #991b1b; }
|
.status-badge.offline { background: rgba(239,68,68,0.10); color: #b91c1c; }
|
||||||
|
html.dark .status-badge.online { background: rgba(52,211,153,0.15); color: #34d399; }
|
||||||
|
html.dark .status-badge.offline { background: rgba(248,113,113,0.15); color: #f87171; }
|
||||||
.host-subtitle {
|
.host-subtitle {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -610,9 +659,10 @@ function formatTime(t) {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
background: #f9fafb;
|
background: var(--surface-hover);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
transition: background .2s ease;
|
||||||
}
|
}
|
||||||
.info-label { color: var(--text-secondary); }
|
.info-label { color: var(--text-secondary); }
|
||||||
.info-value { font-weight: 500; color: var(--text); }
|
.info-value { font-weight: 500; color: var(--text); }
|
||||||
@@ -628,8 +678,9 @@ function formatTime(t) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: #f9fafb;
|
background: var(--surface-hover);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
transition: background .2s ease;
|
||||||
}
|
}
|
||||||
.sp-icon {
|
.sp-icon {
|
||||||
width: 36px; height: 36px; border-radius: 8px;
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
@@ -642,7 +693,7 @@ function formatTime(t) {
|
|||||||
.sp-icon.disk { background: linear-gradient(135deg, #f59e0b, #fbbf24); }
|
.sp-icon.disk { background: linear-gradient(135deg, #f59e0b, #fbbf24); }
|
||||||
.sp-info { flex: 1; min-width: 0; }
|
.sp-info { flex: 1; min-width: 0; }
|
||||||
.sp-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
.sp-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
||||||
.sp-bar { height: 5px; background: #e5e7eb; border-radius: 3px; overflow: hidden; }
|
.sp-bar { height: 5px; background: var(--border-strong); border-radius: 3px; overflow: hidden; }
|
||||||
.sp-fill { height: 100%; border-radius: 3px; background: #3b82f6; transition: width .3s ease; }
|
.sp-fill { height: 100%; border-radius: 3px; background: #3b82f6; transition: width .3s ease; }
|
||||||
.sp-num { font-size: 14px; font-weight: 700; color: var(--text); min-width: 36px; text-align: right; }
|
.sp-num { font-size: 14px; font-weight: 700; color: var(--text); min-width: 36px; text-align: right; }
|
||||||
|
|
||||||
@@ -673,7 +724,7 @@ function formatTime(t) {
|
|||||||
}
|
}
|
||||||
.dd-bar {
|
.dd-bar {
|
||||||
height: 5px;
|
height: 5px;
|
||||||
background: #e5e7eb;
|
background: var(--border-strong);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -689,8 +740,9 @@ function formatTime(t) {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
background: #f9fafb;
|
background: var(--surface-hover);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
transition: background .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-actions {
|
.sync-actions {
|
||||||
@@ -700,9 +752,10 @@ function formatTime(t) {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
.ssh-result {
|
.ssh-result {
|
||||||
background: #f9fafb;
|
background: var(--surface-hover);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
transition: background .2s ease;
|
||||||
}
|
}
|
||||||
.result-grid {
|
.result-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -730,9 +783,10 @@ function formatTime(t) {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: #f9fafb;
|
background: var(--surface-hover);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
transition: background .2s ease;
|
||||||
}
|
}
|
||||||
.service-main {
|
.service-main {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -746,7 +800,7 @@ function formatTime(t) {
|
|||||||
.service-port {
|
.service-port {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -777,9 +831,10 @@ function formatTime(t) {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
background: #f9fafb;
|
background: var(--surface-hover);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
transition: background .2s ease;
|
||||||
}
|
}
|
||||||
.rel-arrow {
|
.rel-arrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -797,7 +852,7 @@ function formatTime(t) {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.rel-port {
|
.rel-port {
|
||||||
background: #fff;
|
background: var(--surface);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -810,4 +865,71 @@ function formatTime(t) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.offline-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.stat-box {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.stat-num {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.stat-reason {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--danger);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-logs {
|
||||||
|
background: var(--surface-hover);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.log-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.log-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.log-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.log-time {
|
||||||
|
color: var(--text-muted);
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
.log-reason {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.log-dur {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.log-dur.pending {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,11 +20,12 @@
|
|||||||
|
|
||||||
<!-- Cards -->
|
<!-- Cards -->
|
||||||
<div class="cards-grid">
|
<div class="cards-grid">
|
||||||
<div v-for="m in machines" :key="m.id" class="server-card" :class="[osClass(m.os_type), { 'guest-card': !isAdmin, 'offline-card': !m.is_online }]" @click="isAdmin && goDetail(m.id)">
|
<div v-for="m in machines" :key="m.id" class="server-card" :class="[{ 'guest-card': !isAdmin, 'offline-card': !m.is_online }]" @click="isAdmin && goDetail(m.id)">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="title-row">
|
<div class="title-row">
|
||||||
<span class="status-dot" :class="m.is_online ? 'online' : 'offline'"></span>
|
<span class="os-dot" :class="osClass(m.os_type)" :title="m.os_type"></span>
|
||||||
<span class="hostname">{{ m.hostname }}</span>
|
<span class="hostname">{{ m.hostname }}</span>
|
||||||
|
<span class="status-badge" :class="m.is_online ? 'online' : 'offline'">{{ m.is_online ? '在线' : '离线' }}</span>
|
||||||
<el-tag v-if="m.service_count" size="small" effect="plain" class="svc-tag" round>服务 {{ m.service_count }}</el-tag>
|
<el-tag v-if="m.service_count" size="small" effect="plain" class="svc-tag" round>服务 {{ m.service_count }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
<div class="meta-row">
|
<div class="meta-row">
|
||||||
@@ -343,25 +344,15 @@ function formatTime(t) {
|
|||||||
padding: 18px;
|
padding: 18px;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-left-width: 4px;
|
border-top-width: 3px;
|
||||||
|
border-top-color: var(--border-strong);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform .12s ease, box-shadow .2s ease, opacity .2s ease;
|
transition: transform .12s ease, box-shadow .2s ease, opacity .2s ease, border-color .2s ease;
|
||||||
}
|
|
||||||
.server-card.os-linux { border-left-color: #3b82f6; background: linear-gradient(90deg, rgba(59,130,246,0.04) 0%, var(--surface) 20px); }
|
|
||||||
.server-card.os-windows { border-left-color: #22c55e; background: linear-gradient(90deg, rgba(34,197,94,0.04) 0%, var(--surface) 20px); }
|
|
||||||
.server-card.os-macos { border-left-color: #a855f7; background: linear-gradient(90deg, rgba(168,85,247,0.04) 0%, var(--surface) 20px); }
|
|
||||||
.server-card.os-other { border-left-color: #6b7280; background: linear-gradient(90deg, rgba(107,114,128,0.04) 0%, var(--surface) 20px); }
|
|
||||||
|
|
||||||
.server-card.offline-card {
|
|
||||||
opacity: 0.72;
|
|
||||||
filter: grayscale(0.45);
|
|
||||||
}
|
|
||||||
.server-card.offline-card .hostname {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
}
|
||||||
.server-card:hover {
|
.server-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 30px rgba(0,0,0,0.06);
|
box-shadow: var(--shadow-lg);
|
||||||
|
border-color: var(--border-strong);
|
||||||
}
|
}
|
||||||
.server-card.guest-card {
|
.server-card.guest-card {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
@@ -369,6 +360,15 @@ function formatTime(t) {
|
|||||||
.server-card.guest-card:hover {
|
.server-card.guest-card:hover {
|
||||||
transform: none;
|
transform: none;
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.server-card.offline-card {
|
||||||
|
opacity: 0.65;
|
||||||
|
border-top-color: var(--border);
|
||||||
|
}
|
||||||
|
.server-card.offline-card .hostname {
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-header {
|
.card-header {
|
||||||
@@ -381,11 +381,42 @@ function formatTime(t) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.status-dot {
|
.os-dot {
|
||||||
width: 8px; height: 8px; border-radius: 50%;
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
|
||||||
}
|
}
|
||||||
.status-dot.online { background: var(--success); box-shadow: 0 0 0 3px rgba(34,197,94,.18); }
|
.os-dot.os-linux { background: #3b82f6; }
|
||||||
.status-dot.offline { background: var(--danger); box-shadow: 0 0 0 3px rgba(239,68,68,.18); }
|
.os-dot.os-windows { background: #22c55e; }
|
||||||
|
.os-dot.os-macos { background: #a855f7; }
|
||||||
|
.os-dot.os-other { background: #64748b; }
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.status-badge.online {
|
||||||
|
background: rgba(34,197,94,0.12);
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
.status-badge.offline {
|
||||||
|
background: rgba(239,68,68,0.10);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
html.dark .status-badge.online {
|
||||||
|
background: rgba(52,211,153,0.15);
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
html.dark .status-badge.offline {
|
||||||
|
background: rgba(248,113,113,0.15);
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
|
||||||
.hostname {
|
.hostname {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
@@ -410,7 +441,8 @@ function formatTime(t) {
|
|||||||
.meta-ip {
|
.meta-ip {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
background: #f3f4f6;
|
background: var(--surface-hover);
|
||||||
|
border: 1px solid var(--border);
|
||||||
padding: 1px 6px;
|
padding: 1px 6px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -429,24 +461,26 @@ function formatTime(t) {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.stat-pill {
|
.stat-pill {
|
||||||
background: linear-gradient(180deg, rgba(17,24,39,0.72), rgba(17,24,39,0.62));
|
background: var(--pill-bg);
|
||||||
border-radius: 12px;
|
border: 1px solid var(--pill-border);
|
||||||
padding: 10px;
|
border-radius: 10px;
|
||||||
color: #fff;
|
padding: 9px 10px;
|
||||||
|
color: var(--pill-text);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
transition: background .2s ease, border-color .2s ease;
|
||||||
}
|
}
|
||||||
.pill-icon {
|
.pill-icon {
|
||||||
width: 24px; height: 24px; border-radius: 6px;
|
width: 22px; height: 22px; border-radius: 5px;
|
||||||
display: flex; align-items: center; justify-content: center;
|
display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 10px; font-weight: 800;
|
font-size: 10px; font-weight: 800;
|
||||||
background: rgba(255,255,255,0.12);
|
background: rgba(255,255,255,0.10);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.pill-icon.cpu { color: #a5f3fc; }
|
.pill-icon.cpu { color: #7dd3fc; }
|
||||||
.pill-icon.mem { color: #bbf7d0; }
|
.pill-icon.mem { color: #86efac; }
|
||||||
.pill-icon.disk { color: #bfdbfe; }
|
.pill-icon.disk { color: #93c5fd; }
|
||||||
.pill-body {
|
.pill-body {
|
||||||
flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref, onUnmounted } from 'vue'
|
||||||
import { Graph } from '@antv/g6'
|
import { Graph } from '@antv/g6'
|
||||||
import { Plus } from '@element-plus/icons-vue'
|
import { Plus } from '@element-plus/icons-vue'
|
||||||
import { fetchMachines, fetchAllServices as fetchServicesAll, fetchRelationships } from '@/api'
|
import { fetchMachines, fetchAllServices as fetchServicesAll, fetchRelationships } from '@/api'
|
||||||
@@ -29,6 +29,70 @@ import { getAuth } from '@/router'
|
|||||||
|
|
||||||
const isAdmin = getAuth().is_admin
|
const isAdmin = getAuth().is_admin
|
||||||
let graph = null
|
let graph = null
|
||||||
|
let observer = null
|
||||||
|
|
||||||
|
function getIsDark() {
|
||||||
|
return document.documentElement.classList.contains('dark')
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeFill() {
|
||||||
|
return getIsDark() ? '#0f172a' : '#ffffff'
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeText() {
|
||||||
|
return getIsDark() ? '#f8fafc' : '#111827'
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeBg() {
|
||||||
|
return getIsDark() ? '#020617' : '#fafafa'
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGraph() {
|
||||||
|
const container = document.getElementById('topo')
|
||||||
|
if (!container || !graph) return
|
||||||
|
container.style.background = themeBg()
|
||||||
|
graph.getNodes().forEach(node => {
|
||||||
|
const model = node.getModel()
|
||||||
|
graph.updateItem(node, {
|
||||||
|
style: {
|
||||||
|
fill: themeFill(),
|
||||||
|
stroke: osColor(model.os),
|
||||||
|
lineWidth: 2,
|
||||||
|
radius: 8,
|
||||||
|
shadowColor: getIsDark() ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.06)',
|
||||||
|
shadowBlur: 8,
|
||||||
|
shadowOffsetY: 2
|
||||||
|
},
|
||||||
|
labelCfg: {
|
||||||
|
style: { fill: themeText(), fontSize: 13, fontWeight: 600 }
|
||||||
|
},
|
||||||
|
badgeCfg: {
|
||||||
|
position: 'topRight',
|
||||||
|
style: {
|
||||||
|
fill: model.online ? '#22c55e' : '#ef4444',
|
||||||
|
stroke: themeFill(),
|
||||||
|
lineWidth: 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
graph.getEdges().forEach(edge => {
|
||||||
|
const model = edge.getModel()
|
||||||
|
const isRel = String(model.id).startsWith('rel-')
|
||||||
|
const color = isRel ? '#f97316' : '#3b82f6'
|
||||||
|
graph.updateItem(edge, {
|
||||||
|
labelCfg: {
|
||||||
|
style: {
|
||||||
|
fill: color,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
background: { fill: themeFill(), padding: [2, 4], radius: 4 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
graph.paint()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const [machinesRes, servicesRes, relsRes] = await Promise.all([
|
const [machinesRes, servicesRes, relsRes] = await Promise.all([
|
||||||
@@ -44,28 +108,32 @@ onMounted(async () => {
|
|||||||
const machineMap = {}
|
const machineMap = {}
|
||||||
machines.forEach(m => machineMap[m.id] = m)
|
machines.forEach(m => machineMap[m.id] = m)
|
||||||
|
|
||||||
|
const isDark = getIsDark()
|
||||||
|
const fill = themeFill()
|
||||||
|
const text = themeText()
|
||||||
|
|
||||||
const nodes = machines.map(m => ({
|
const nodes = machines.map(m => ({
|
||||||
id: String(m.id),
|
id: String(m.id),
|
||||||
label: m.hostname,
|
label: m.hostname,
|
||||||
online: m.is_online,
|
online: m.is_online,
|
||||||
os: m.os_type,
|
os: m.os_type,
|
||||||
style: {
|
style: {
|
||||||
fill: '#ffffff',
|
fill,
|
||||||
stroke: osColor(m.os_type),
|
stroke: osColor(m.os_type),
|
||||||
lineWidth: 2,
|
lineWidth: 2,
|
||||||
radius: 8,
|
radius: 8,
|
||||||
shadowColor: 'rgba(0,0,0,0.06)',
|
shadowColor: isDark ? 'rgba(0,0,0,0.5)' : 'rgba(0,0,0,0.06)',
|
||||||
shadowBlur: 8,
|
shadowBlur: 8,
|
||||||
shadowOffsetY: 2
|
shadowOffsetY: 2
|
||||||
},
|
},
|
||||||
labelCfg: {
|
labelCfg: {
|
||||||
style: { fill: '#111827', fontSize: 13, fontWeight: 600 }
|
style: { fill: text, fontSize: 13, fontWeight: 600 }
|
||||||
},
|
},
|
||||||
badgeCfg: {
|
badgeCfg: {
|
||||||
position: 'topRight',
|
position: 'topRight',
|
||||||
style: {
|
style: {
|
||||||
fill: m.is_online ? '#22c55e' : '#ef4444',
|
fill: m.is_online ? '#22c55e' : '#ef4444',
|
||||||
stroke: '#fff',
|
stroke: fill,
|
||||||
lineWidth: 2
|
lineWidth: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,7 +155,7 @@ onMounted(async () => {
|
|||||||
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#f97316' }
|
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#f97316' }
|
||||||
},
|
},
|
||||||
labelCfg: {
|
labelCfg: {
|
||||||
style: { fill: '#f97316', fontSize: 11, fontWeight: 500, background: { fill: '#fff', padding: [2, 4], radius: 4 } }
|
style: { fill: '#f97316', fontSize: 11, fontWeight: 500, background: { fill, padding: [2, 4], radius: 4 } }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -105,16 +173,19 @@ onMounted(async () => {
|
|||||||
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#3b82f6' }
|
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#3b82f6' }
|
||||||
},
|
},
|
||||||
labelCfg: {
|
labelCfg: {
|
||||||
style: { fill: '#3b82f6', fontSize: 11, fontWeight: 500, background: { fill: '#fff', padding: [2, 4], radius: 4 } }
|
style: { fill: '#3b82f6', fontSize: 11, fontWeight: 500, background: { fill, padding: [2, 4], radius: 4 } }
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const container = document.getElementById('topo')
|
||||||
|
container.style.background = themeBg()
|
||||||
|
|
||||||
graph = new Graph({
|
graph = new Graph({
|
||||||
container: 'topo',
|
container: 'topo',
|
||||||
width: document.getElementById('topo').clientWidth,
|
width: container.clientWidth,
|
||||||
height: document.getElementById('topo').clientHeight,
|
height: container.clientHeight,
|
||||||
layout: {
|
layout: {
|
||||||
type: 'force',
|
type: 'force',
|
||||||
preventOverlap: true,
|
preventOverlap: true,
|
||||||
@@ -144,12 +215,26 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('resize', () => {
|
window.addEventListener('resize', () => {
|
||||||
const container = document.getElementById('topo')
|
const c = document.getElementById('topo')
|
||||||
if (container && graph) {
|
if (c && graph) {
|
||||||
graph.changeSize(container.clientWidth, container.clientHeight)
|
graph.changeSize(c.clientWidth, c.clientHeight)
|
||||||
graph.fitView()
|
graph.fitView()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Watch for theme changes
|
||||||
|
observer = new MutationObserver(() => {
|
||||||
|
renderGraph()
|
||||||
|
})
|
||||||
|
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (observer) observer.disconnect()
|
||||||
|
if (graph) {
|
||||||
|
graph.destroy()
|
||||||
|
graph = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function highlightNode(nodeId) {
|
function highlightNode(nodeId) {
|
||||||
@@ -222,21 +307,22 @@ function relLabel(r) {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #fafafa;
|
transition: background .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend {
|
.legend {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
bottom: 16px;
|
bottom: 16px;
|
||||||
background: rgba(255,255,255,0.95);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
box-shadow: 0 6px 20px rgba(0,0,0,0.05);
|
box-shadow: var(--shadow);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
|
transition: background .2s ease, border-color .2s ease;
|
||||||
}
|
}
|
||||||
.legend-item {
|
.legend-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user