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:
shirainbown
2026-04-15 01:34:06 +08:00
parent 80d21f68e4
commit 1a5f5ee3b5
13 changed files with 638 additions and 150 deletions

Binary file not shown.

View File

@@ -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_tgt ON relationships(target_machine_id)`,
`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 {
if _, err := DB.Exec(s); err != nil {

View File

@@ -38,7 +38,7 @@ func (h *MachineHandler) List(c *gin.Context) {
osFilter := c.Query("os_type")
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{}{}
if osFilter != "" {
query += ` AND os_type = ?`
@@ -66,12 +66,14 @@ func (h *MachineHandler) List(c *gin.Context) {
for rows.Next() {
var m models.Machine
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 {
continue
}
m.LastPingAt = lp
m.SSHSyncedAt = ss
m.LastOfflineAt = lo
m.SSHPassword = models.NullString{} // never return password
if !isAdmin {
sanitizeMachine(&m)
@@ -113,8 +115,10 @@ func (h *MachineHandler) Get(c *gin.Context) {
var m models.Machine
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).
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 = 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 {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
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) {
var m models.Machine
if err := c.ShouldBindJSON(&m); err != nil {

View File

@@ -77,6 +77,7 @@ func main() {
admin.DELETE("/machines/:id", mh.Delete)
admin.POST("/machines/:id/ssh-info", mh.SSHInfo)
admin.POST("/machines/:id/sync-ssh", mh.SyncSSH)
admin.GET("/machines/:id/offline-logs", mh.OfflineLogs)
sh := handlers.NewServiceHandler()
admin.GET("/machines/:id/services", sh.ListByMachine)

View File

@@ -54,6 +54,19 @@ type Machine struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
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 {

View File

@@ -6,39 +6,74 @@ import (
"fmt"
"lan-manager/server/db"
"lan-manager/server/utils"
"net"
"os"
"os/exec"
"runtime"
"strings"
"time"
"net"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
)
func PingHost(ip string) bool {
// Try system ping first (no special privileges needed on Linux)
if ok := pingWithSystem(ip); ok {
return true
// PingResult holds connectivity status and failure reason
type PingResult struct {
Online bool
Reason string // empty if online, otherwise human-readable failure reason
}
// Fallback to raw ICMP
return pingWithICMP(ip)
func PingHost(ip string, port int) PingResult {
if ok := pingWithSystem(ip); ok {
return PingResult{Online: true}
}
if ok := pingWithICMP(ip); ok {
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 {
var cmd *exec.Cmd
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" {
// macOS ping -W is in milliseconds
cmd = exec.Command("ping", "-c", "1", "-W", "3000", ip)
cmd = exec.Command("ping", "-c", "1", "-W", "5000", ip)
} else {
// Linux ping -W is in seconds
cmd = exec.Command("ping", "-c", "1", "-W", "3", ip)
cmd = exec.Command("ping", "-c", "1", "-W", "5", ip)
}
err := cmd.Run()
return err == nil
out, err := cmd.CombinedOutput()
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 {
@@ -48,10 +83,11 @@ func pingWithICMP(ip string) bool {
}
defer c.Close()
id := os.Getpid() & 0xffff
m := &icmp.Message{
Type: ipv4.ICMPTypeEcho,
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)
if err != nil {
@@ -62,7 +98,7 @@ func pingWithICMP(ip string) bool {
}
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)
if err != nil {
return false
@@ -74,6 +110,19 @@ func pingWithICMP(ip string) bool {
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 {
parts := strings.Split(ip, ".")
if len(parts) != 4 {
@@ -88,40 +137,58 @@ func parseIP(ip string) []byte {
return res
}
func StartPingService(interval int) {
ticker := time.NewTicker(time.Duration(interval) * time.Second)
go func() {
for range ticker.C {
rows, err := db.DB.Query(`SELECT id, ip, ssh_port, ssh_username, ssh_password FROM machines`)
if err != nil {
fmt.Printf("[Ping] query error: %v\n", err)
continue
}
type machinePing struct {
id int64
ip string
sshPort int
sshUsername string
sshPassword string
wasOnline bool
}
list := []machinePing{}
for rows.Next() {
var m machinePing
if err := rows.Scan(&m.id, &m.ip, &m.sshPort, &m.sshUsername, &m.sshPassword); err == nil {
list = append(list, m)
} else {
fmt.Printf("[Ping] scan error: %v\n", err)
}
}
rows.Close()
fmt.Printf("[Ping] tick processed %d machines\n", len(list))
for _, m := range list {
online := PingHost(m.ip)
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)
@@ -129,8 +196,7 @@ func StartPingService(interval int) {
fmt.Printf("[Ping] %s -> online=%v\n", m.ip, online)
}
// Auto fetch SSH info if credentials are saved
if m.sshUsername != "" && m.sshPassword != "" {
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 {
@@ -160,6 +226,39 @@ func StartPingService(interval int) {
}(m.id, m.ip, m.sshPort, m.sshUsername, m.sshPassword)
}
}
func StartPingService(interval int) {
const step = 30 * time.Second
go func() {
time.Sleep(2 * time.Second)
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 {
fmt.Printf("[Ping] query error: %v\n", err)
time.Sleep(step)
continue
}
list := []machinePing{}
for rows.Next() {
var m machinePing
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)
}
}
rows.Close()
fmt.Printf("[Ping] start round, %d machines, interval %v each\n", len(list), step)
for i := range list {
res := PingHostRepeated(list[i].ip, list[i].sshPort, 3)
handlePingResult(&list[i], res)
time.Sleep(step)
}
}
}()
}
@@ -176,6 +275,7 @@ func CleanupLogs(ctx context.Context, retentionDays int) {
return
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 offline_logs WHERE started_at < datetime('now', '-` + fmt.Sprintf("%d", retentionDays) + ` days')`)
}
}
}()

View File

@@ -3,24 +3,64 @@
</template>
<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>
<style>
/* CSS Variables */
/* Light mode (default) */
:root {
--bg: #f6f7f9;
--bg: #f8fafc;
--surface: #ffffff;
--text: #111827;
--text-secondary: #4b5563;
--text-muted: #9ca3af;
--border: #e5e7eb;
--surface-hover: #f8fafc;
--text: #0f172a;
--text-secondary: #475569;
--text-muted: #94a3b8;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--primary: #3b82f6;
--primary-weak: #eff6ff;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--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; }
@@ -33,6 +73,7 @@ html, body, #app {
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
transition: background .2s ease, color .2s ease;
}
.page {
@@ -48,6 +89,7 @@ html, body, #app {
box-shadow: var(--shadow);
border: 1px solid var(--border);
margin-bottom: 16px;
transition: background .2s ease, border-color .2s ease;
}
.card-title {
@@ -60,7 +102,7 @@ html, body, #app {
justify-content: space-between;
}
/* Element Plus overrides for a softer look */
/* Element Plus overrides */
.el-button {
border-radius: 8px;
font-weight: 500;
@@ -71,6 +113,12 @@ html, body, #app {
--el-button-hover-bg-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 {
border-radius: 10px;
}
@@ -88,8 +136,8 @@ html, body, #app {
padding: 10px 20px 18px;
}
.el-table {
--el-table-header-bg-color: #f9fafb;
--el-table-row-hover-bg-color: #f3f4f6;
--el-table-header-bg-color: var(--surface-hover);
--el-table-row-hover-bg-color: var(--surface-hover);
--el-table-border-color: var(--border);
border-radius: 10px;
overflow: hidden;
@@ -111,9 +159,12 @@ html, body, #app {
height: 8px;
}
::-webkit-scrollbar-thumb {
background: rgba(0,0,0,0.12);
background: rgba(128,128,128,0.25);
border-radius: 4px;
}
html.dark ::-webkit-scrollbar-thumb {
background: rgba(148,163,184,0.25);
}
::-webkit-scrollbar-track {
background: transparent;
}

View File

@@ -43,6 +43,7 @@ export const updateMachine = (id, data) => api.put(`/machines/${id}`, data)
export const deleteMachine = (id) => api.delete(`/machines/${id}`)
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 fetchOfflineLogs = (id) => api.get(`/machines/${id}/offline-logs`)
export const fetchServices = (machineId) => api.get(`/machines/${machineId}/services`)
export const fetchAllServices = () => api.get('/services')

View File

@@ -19,6 +19,10 @@
<span>操作日志</span>
</router-link>
</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="user-info">
<el-icon class="user-icon"><User /></el-icon>
@@ -40,15 +44,26 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref, onMounted } from 'vue'
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 { logout as apiLogout } from '@/api'
import { ElMessage } from 'element-plus'
const router = useRouter()
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() {
try {
@@ -182,6 +197,26 @@ async function logout() {
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 {
margin-left: 0;
}

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'

View File

@@ -138,6 +138,34 @@
<el-empty v-else description="暂无服务" :image-size="80" />
</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 -->
<div class="card" v-if="isAdmin">
<div class="card-title">
@@ -278,7 +306,7 @@ import {
fetchMachine, updateMachine, deleteMachine,
fetchServices, createService, updateService, deleteService,
fetchRelationships, createRelationship, updateRelationship, deleteRelationship,
sshInfo, syncSSH, fetchMachines,
sshInfo, syncSSH, fetchMachines, fetchOfflineLogs,
checkAuth, uiRefreshInterval,
} from '@/api'
import { getAuth } from '@/router'
@@ -293,6 +321,7 @@ const machine = ref(null)
const services = ref([])
const relationships = ref([])
const allMachines = ref([])
const offlineLogs = ref([])
const dialogVisible = ref(false)
const editing = ref({})
@@ -343,6 +372,10 @@ onUnmounted(() => {
async function loadMachine() {
const res = await fetchMachine(machineId)
machine.value = res.data.machine
if (isAdmin) {
const logsRes = await fetchOfflineLogs(machineId)
offlineLogs.value = logsRes.data
}
// 如果已有同步过的 SSH 信息,直接展示上次结果
if (machine.value && (machine.value.cpu_info || machine.value.memory_info || machine.value.disk_info || machine.value.uptime)) {
sshResult.value = {
@@ -544,6 +577,20 @@ function formatTime(t) {
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())}`
}
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>
<style scoped>
@@ -572,8 +619,10 @@ function formatTime(t) {
border-radius: 999px;
font-weight: 600;
}
.status-badge.online { background: #dcfce7; color: #166534; }
.status-badge.offline { background: #fee2e2; color: #991b1b; }
.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; }
.host-subtitle {
margin-top: 4px;
font-size: 13px;
@@ -610,9 +659,10 @@ function formatTime(t) {
justify-content: space-between;
align-items: center;
padding: 8px 10px;
background: #f9fafb;
background: var(--surface-hover);
border-radius: 8px;
font-size: 13px;
transition: background .2s ease;
}
.info-label { color: var(--text-secondary); }
.info-value { font-weight: 500; color: var(--text); }
@@ -628,8 +678,9 @@ function formatTime(t) {
align-items: center;
gap: 12px;
padding: 12px;
background: #f9fafb;
background: var(--surface-hover);
border-radius: 10px;
transition: background .2s ease;
}
.sp-icon {
width: 36px; height: 36px; border-radius: 8px;
@@ -642,7 +693,7 @@ function formatTime(t) {
.sp-icon.disk { background: linear-gradient(135deg, #f59e0b, #fbbf24); }
.sp-info { flex: 1; min-width: 0; }
.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-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 {
height: 5px;
background: #e5e7eb;
background: var(--border-strong);
border-radius: 3px;
overflow: hidden;
}
@@ -689,8 +740,9 @@ function formatTime(t) {
font-size: 13px;
color: var(--text-secondary);
padding: 8px 10px;
background: #f9fafb;
background: var(--surface-hover);
border-radius: 8px;
transition: background .2s ease;
}
.sync-actions {
@@ -700,9 +752,10 @@ function formatTime(t) {
margin-bottom: 12px;
}
.ssh-result {
background: #f9fafb;
background: var(--surface-hover);
border-radius: 10px;
padding: 12px;
transition: background .2s ease;
}
.result-grid {
display: grid;
@@ -730,9 +783,10 @@ function formatTime(t) {
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #f9fafb;
background: var(--surface-hover);
border-radius: 10px;
flex-wrap: wrap;
transition: background .2s ease;
}
.service-main {
display: flex;
@@ -746,7 +800,7 @@ function formatTime(t) {
.service-port {
font-size: 12px;
color: var(--text-muted);
background: #fff;
background: var(--surface);
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--border);
@@ -777,9 +831,10 @@ function formatTime(t) {
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #f9fafb;
background: var(--surface-hover);
border-radius: 10px;
flex-wrap: wrap;
transition: background .2s ease;
}
.rel-arrow {
display: flex;
@@ -797,7 +852,7 @@ function formatTime(t) {
font-size: 12px;
}
.rel-port {
background: #fff;
background: var(--surface);
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--border);
@@ -810,4 +865,71 @@ function formatTime(t) {
display: flex;
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>

View File

@@ -20,11 +20,12 @@
<!-- Cards -->
<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="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="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>
</div>
<div class="meta-row">
@@ -343,25 +344,15 @@ function formatTime(t) {
padding: 18px;
box-shadow: var(--shadow);
border: 1px solid var(--border);
border-left-width: 4px;
border-top-width: 3px;
border-top-color: var(--border-strong);
cursor: pointer;
transition: transform .12s ease, box-shadow .2s ease, opacity .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);
transition: transform .12s ease, box-shadow .2s ease, opacity .2s ease, border-color .2s ease;
}
.server-card:hover {
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 {
cursor: default;
@@ -369,6 +360,15 @@ function formatTime(t) {
.server-card.guest-card:hover {
transform: none;
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 {
@@ -381,11 +381,42 @@ function formatTime(t) {
align-items: center;
gap: 10px;
}
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
.os-dot {
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); }
.status-dot.offline { background: var(--danger); box-shadow: 0 0 0 3px rgba(239,68,68,.18); }
.os-dot.os-linux { background: #3b82f6; }
.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 {
font-weight: 700;
font-size: 15px;
@@ -410,7 +441,8 @@ function formatTime(t) {
.meta-ip {
color: var(--text);
font-weight: 500;
background: #f3f4f6;
background: var(--surface-hover);
border: 1px solid var(--border);
padding: 1px 6px;
border-radius: 4px;
}
@@ -429,24 +461,26 @@ function formatTime(t) {
gap: 10px;
}
.stat-pill {
background: linear-gradient(180deg, rgba(17,24,39,0.72), rgba(17,24,39,0.62));
border-radius: 12px;
padding: 10px;
color: #fff;
background: var(--pill-bg);
border: 1px solid var(--pill-border);
border-radius: 10px;
padding: 9px 10px;
color: var(--pill-text);
display: flex;
align-items: center;
gap: 8px;
transition: background .2s ease, border-color .2s ease;
}
.pill-icon {
width: 24px; height: 24px; border-radius: 6px;
width: 22px; height: 22px; border-radius: 5px;
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: 800;
background: rgba(255,255,255,0.12);
background: rgba(255,255,255,0.10);
flex-shrink: 0;
}
.pill-icon.cpu { color: #a5f3fc; }
.pill-icon.mem { color: #bbf7d0; }
.pill-icon.disk { color: #bfdbfe; }
.pill-icon.cpu { color: #7dd3fc; }
.pill-icon.mem { color: #86efac; }
.pill-icon.disk { color: #93c5fd; }
.pill-body {
flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0;
}

View File

@@ -21,7 +21,7 @@
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { onMounted, ref, onUnmounted } from 'vue'
import { Graph } from '@antv/g6'
import { Plus } from '@element-plus/icons-vue'
import { fetchMachines, fetchAllServices as fetchServicesAll, fetchRelationships } from '@/api'
@@ -29,6 +29,70 @@ import { getAuth } from '@/router'
const isAdmin = getAuth().is_admin
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 () => {
const [machinesRes, servicesRes, relsRes] = await Promise.all([
@@ -44,28 +108,32 @@ onMounted(async () => {
const machineMap = {}
machines.forEach(m => machineMap[m.id] = m)
const isDark = getIsDark()
const fill = themeFill()
const text = themeText()
const nodes = machines.map(m => ({
id: String(m.id),
label: m.hostname,
online: m.is_online,
os: m.os_type,
style: {
fill: '#ffffff',
fill,
stroke: osColor(m.os_type),
lineWidth: 2,
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,
shadowOffsetY: 2
},
labelCfg: {
style: { fill: '#111827', fontSize: 13, fontWeight: 600 }
style: { fill: text, fontSize: 13, fontWeight: 600 }
},
badgeCfg: {
position: 'topRight',
style: {
fill: m.is_online ? '#22c55e' : '#ef4444',
stroke: '#fff',
stroke: fill,
lineWidth: 2
}
}
@@ -87,7 +155,7 @@ onMounted(async () => {
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#f97316' }
},
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' }
},
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({
container: 'topo',
width: document.getElementById('topo').clientWidth,
height: document.getElementById('topo').clientHeight,
width: container.clientWidth,
height: container.clientHeight,
layout: {
type: 'force',
preventOverlap: true,
@@ -144,12 +215,26 @@ onMounted(async () => {
})
window.addEventListener('resize', () => {
const container = document.getElementById('topo')
if (container && graph) {
graph.changeSize(container.clientWidth, container.clientHeight)
const c = document.getElementById('topo')
if (c && graph) {
graph.changeSize(c.clientWidth, c.clientHeight)
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) {
@@ -222,21 +307,22 @@ function relLabel(r) {
height: 100%;
border-radius: 10px;
overflow: hidden;
background: #fafafa;
transition: background .2s ease;
}
.legend {
position: absolute;
left: 16px;
bottom: 16px;
background: rgba(255,255,255,0.95);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
display: flex;
gap: 14px;
box-shadow: 0 6px 20px rgba(0,0,0,0.05);
box-shadow: var(--shadow);
backdrop-filter: blur(6px);
transition: background .2s ease, border-color .2s ease;
}
.legend-item {
display: flex;