mirror of
https://github.com/uprightbass360/AzerothCore-RealmMaster.git
synced 2026-01-13 00:58:34 +00:00
598 lines
15 KiB
Go
598 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
ui "github.com/gizak/termui/v3"
|
|
"github.com/gizak/termui/v3/widgets"
|
|
)
|
|
|
|
type Service struct {
|
|
Name string `json:"name"`
|
|
Label string `json:"label"`
|
|
Status string `json:"status"`
|
|
Health string `json:"health"`
|
|
StartedAt string `json:"started_at"`
|
|
Image string `json:"image"`
|
|
ExitCode string `json:"exit_code"`
|
|
}
|
|
|
|
type ContainerStats struct {
|
|
CPU float64 `json:"cpu"`
|
|
Memory string `json:"memory"`
|
|
MemoryPercent float64 `json:"memory_percent"`
|
|
}
|
|
|
|
type Port struct {
|
|
Name string `json:"name"`
|
|
Port string `json:"port"`
|
|
Reachable bool `json:"reachable"`
|
|
}
|
|
|
|
type DirInfo struct {
|
|
Path string `json:"path"`
|
|
Exists bool `json:"exists"`
|
|
Size string `json:"size"`
|
|
}
|
|
|
|
type VolumeInfo struct {
|
|
Name string `json:"name"`
|
|
Exists bool `json:"exists"`
|
|
Mountpoint string `json:"mountpoint"`
|
|
}
|
|
|
|
type UserStats struct {
|
|
Accounts int `json:"accounts"`
|
|
Online int `json:"online"`
|
|
Characters int `json:"characters"`
|
|
Active7d int `json:"active7d"`
|
|
}
|
|
|
|
type Module struct {
|
|
Name string `json:"name"`
|
|
Key string `json:"key"`
|
|
Description string `json:"description"`
|
|
Category string `json:"category"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
type BuildInfo struct {
|
|
Variant string `json:"variant"`
|
|
Repo string `json:"repo"`
|
|
Branch string `json:"branch"`
|
|
Image string `json:"image"`
|
|
Commit string `json:"commit"`
|
|
CommitDate string `json:"commit_date"`
|
|
CommitSource string `json:"commit_source"`
|
|
SourcePath string `json:"source_path"`
|
|
}
|
|
|
|
type Snapshot struct {
|
|
Timestamp string `json:"timestamp"`
|
|
Project string `json:"project"`
|
|
Network string `json:"network"`
|
|
Services []Service `json:"services"`
|
|
Ports []Port `json:"ports"`
|
|
Modules []Module `json:"modules"`
|
|
Storage map[string]DirInfo `json:"storage"`
|
|
Volumes map[string]VolumeInfo `json:"volumes"`
|
|
Users UserStats `json:"users"`
|
|
Stats map[string]ContainerStats `json:"stats"`
|
|
Build BuildInfo `json:"build"`
|
|
}
|
|
|
|
var persistentServiceOrder = []string{
|
|
"ac-mysql",
|
|
"ac-db-guard",
|
|
"ac-authserver",
|
|
"ac-worldserver",
|
|
"ac-phpmyadmin",
|
|
"ac-keira3",
|
|
"ac-backup",
|
|
}
|
|
|
|
func humanDuration(d time.Duration) string {
|
|
if d < time.Minute {
|
|
return "<1m"
|
|
}
|
|
days := d / (24 * time.Hour)
|
|
d -= days * 24 * time.Hour
|
|
hours := d / time.Hour
|
|
d -= hours * time.Hour
|
|
mins := d / time.Minute
|
|
|
|
switch {
|
|
case days > 0:
|
|
return fmt.Sprintf("%dd %dh", days, hours)
|
|
case hours > 0:
|
|
return fmt.Sprintf("%dh %dm", hours, mins)
|
|
default:
|
|
return fmt.Sprintf("%dm", mins)
|
|
}
|
|
}
|
|
|
|
func formatUptime(startedAt string) string {
|
|
if startedAt == "" {
|
|
return "-"
|
|
}
|
|
parsed, err := time.Parse(time.RFC3339Nano, startedAt)
|
|
if err != nil {
|
|
parsed, err = time.Parse(time.RFC3339, startedAt)
|
|
if err != nil {
|
|
return "-"
|
|
}
|
|
}
|
|
if parsed.IsZero() {
|
|
return "-"
|
|
}
|
|
uptime := time.Since(parsed)
|
|
if uptime < 0 {
|
|
uptime = 0
|
|
}
|
|
return humanDuration(uptime)
|
|
}
|
|
|
|
func primaryIPv4() string {
|
|
ifaces, err := net.Interfaces()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
for _, iface := range ifaces {
|
|
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
|
continue
|
|
}
|
|
addrs, err := iface.Addrs()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, addr := range addrs {
|
|
var ip net.IP
|
|
switch v := addr.(type) {
|
|
case *net.IPNet:
|
|
ip = v.IP
|
|
case *net.IPAddr:
|
|
ip = v.IP
|
|
}
|
|
if ip == nil || ip.IsLoopback() {
|
|
continue
|
|
}
|
|
ip = ip.To4()
|
|
if ip == nil {
|
|
continue
|
|
}
|
|
return ip.String()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func runSnapshot() (*Snapshot, error) {
|
|
cmd := exec.Command("./scripts/bash/statusjson.sh")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
snap := &Snapshot{}
|
|
if err := json.Unmarshal(output, snap); err != nil {
|
|
return nil, err
|
|
}
|
|
return snap, nil
|
|
}
|
|
|
|
func partitionServices(all []Service) ([]Service, []Service) {
|
|
byName := make(map[string]Service)
|
|
for _, svc := range all {
|
|
byName[svc.Name] = svc
|
|
}
|
|
|
|
seen := make(map[string]bool)
|
|
persistent := make([]Service, 0, len(persistentServiceOrder))
|
|
for _, name := range persistentServiceOrder {
|
|
if svc, ok := byName[name]; ok {
|
|
persistent = append(persistent, svc)
|
|
seen[name] = true
|
|
}
|
|
}
|
|
|
|
setups := make([]Service, 0, len(all))
|
|
for _, svc := range all {
|
|
if seen[svc.Name] {
|
|
continue
|
|
}
|
|
setups = append(setups, svc)
|
|
}
|
|
return persistent, setups
|
|
}
|
|
|
|
func buildServicesTable(s *Snapshot) *TableNoCol {
|
|
runningServices, setupServices := partitionServices(s.Services)
|
|
|
|
table := NewTableNoCol()
|
|
rows := [][]string{{"Service", "Status", "Health", "Uptime", "CPU%", "Memory"}}
|
|
appendRows := func(services []Service) {
|
|
for _, svc := range services {
|
|
cpu := "-"
|
|
mem := "-"
|
|
if svcStats, ok := s.Stats[svc.Name]; ok {
|
|
cpu = fmt.Sprintf("%.1f", svcStats.CPU)
|
|
mem = strings.Split(svcStats.Memory, " / ")[0] // Just show used, not total
|
|
}
|
|
health := svc.Health
|
|
if svc.Status != "running" && svc.ExitCode != "0" && svc.ExitCode != "" {
|
|
health = fmt.Sprintf("%s (%s)", svc.Health, svc.ExitCode)
|
|
}
|
|
rows = append(rows, []string{svc.Label, svc.Status, health, formatUptime(svc.StartedAt), cpu, mem})
|
|
}
|
|
}
|
|
|
|
appendRows(runningServices)
|
|
appendRows(setupServices)
|
|
|
|
table.Rows = rows
|
|
table.RowSeparator = false
|
|
table.Border = true
|
|
table.Title = "Services"
|
|
|
|
for i := 1; i < len(table.Rows); i++ {
|
|
if table.RowStyles == nil {
|
|
table.RowStyles = make(map[int]ui.Style)
|
|
}
|
|
state := strings.ToLower(table.Rows[i][2])
|
|
switch state {
|
|
case "running", "healthy":
|
|
table.RowStyles[i] = ui.NewStyle(ui.ColorGreen)
|
|
case "restarting", "unhealthy":
|
|
table.RowStyles[i] = ui.NewStyle(ui.ColorRed)
|
|
case "exited":
|
|
table.RowStyles[i] = ui.NewStyle(ui.ColorYellow)
|
|
default:
|
|
table.RowStyles[i] = ui.NewStyle(ui.ColorWhite)
|
|
}
|
|
}
|
|
return table
|
|
}
|
|
|
|
func buildPortsTable(s *Snapshot) *TableNoCol {
|
|
table := NewTableNoCol()
|
|
rows := [][]string{{"Port", "Number", "Reachable"}}
|
|
for _, p := range s.Ports {
|
|
state := "Closed"
|
|
if p.Reachable {
|
|
state = "Open"
|
|
}
|
|
rows = append(rows, []string{p.Name, p.Port, state})
|
|
}
|
|
table.Rows = rows
|
|
table.RowSeparator = true
|
|
table.Border = true
|
|
table.Title = "Ports"
|
|
return table
|
|
}
|
|
|
|
func buildModulesList(s *Snapshot) *widgets.List {
|
|
list := widgets.NewList()
|
|
list.Title = fmt.Sprintf("Modules (%d)", len(s.Modules))
|
|
rows := make([]string, len(s.Modules))
|
|
for i, mod := range s.Modules {
|
|
rows[i] = mod.Name
|
|
}
|
|
list.Rows = rows
|
|
list.WrapText = false
|
|
list.Border = true
|
|
list.BorderStyle = ui.NewStyle(ui.ColorCyan)
|
|
list.SelectedRowStyle = ui.NewStyle(ui.ColorCyan)
|
|
return list
|
|
}
|
|
|
|
func buildStorageParagraph(s *Snapshot) *widgets.Paragraph {
|
|
var b strings.Builder
|
|
entries := []struct {
|
|
Key string
|
|
Label string
|
|
}{
|
|
{"storage", "Storage"},
|
|
{"local_storage", "Local Storage"},
|
|
{"client_data", "Client Data"},
|
|
{"modules", "Modules"},
|
|
{"local_modules", "Local Modules"},
|
|
}
|
|
for _, item := range entries {
|
|
info, ok := s.Storage[item.Key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, " %-15s %s (%s)\n", item.Label, info.Path, info.Size)
|
|
}
|
|
par := widgets.NewParagraph()
|
|
par.Title = "Storage"
|
|
par.Text = strings.TrimRight(b.String(), "\n")
|
|
par.Border = true
|
|
par.BorderStyle = ui.NewStyle(ui.ColorYellow)
|
|
par.PaddingLeft = 0
|
|
par.PaddingRight = 0
|
|
return par
|
|
}
|
|
|
|
func buildVolumesParagraph(s *Snapshot) *widgets.Paragraph {
|
|
var b strings.Builder
|
|
entries := []struct {
|
|
Key string
|
|
Label string
|
|
}{
|
|
{"client_cache", "Client Cache"},
|
|
{"mysql_data", "MySQL Data"},
|
|
}
|
|
for _, item := range entries {
|
|
info, ok := s.Volumes[item.Key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, " %-13s %s\n", item.Label, info.Mountpoint)
|
|
}
|
|
par := widgets.NewParagraph()
|
|
par.Title = "Volumes"
|
|
par.Text = strings.TrimRight(b.String(), "\n")
|
|
par.Border = true
|
|
par.BorderStyle = ui.NewStyle(ui.ColorYellow)
|
|
par.PaddingLeft = 0
|
|
par.PaddingRight = 0
|
|
return par
|
|
}
|
|
|
|
func simplifyRepo(repo string) string {
|
|
repo = strings.TrimSpace(repo)
|
|
repo = strings.TrimSuffix(repo, ".git")
|
|
repo = strings.TrimPrefix(repo, "https://")
|
|
repo = strings.TrimPrefix(repo, "http://")
|
|
repo = strings.TrimPrefix(repo, "git@")
|
|
repo = strings.TrimPrefix(repo, "github.com:")
|
|
repo = strings.TrimPrefix(repo, "gitlab.com:")
|
|
repo = strings.TrimPrefix(repo, "github.com/")
|
|
repo = strings.TrimPrefix(repo, "gitlab.com/")
|
|
return repo
|
|
}
|
|
|
|
func buildInfoParagraph(s *Snapshot) *widgets.Paragraph {
|
|
build := s.Build
|
|
var lines []string
|
|
|
|
if build.Branch != "" {
|
|
lines = append(lines, fmt.Sprintf("Branch: %s", build.Branch))
|
|
}
|
|
|
|
if repo := simplifyRepo(build.Repo); repo != "" {
|
|
lines = append(lines, fmt.Sprintf("Repo: %s", repo))
|
|
}
|
|
|
|
commitLine := "Git: unknown"
|
|
if build.Commit != "" {
|
|
commitLine = fmt.Sprintf("Git: %s", build.Commit)
|
|
switch build.CommitSource {
|
|
case "image-label":
|
|
commitLine += " [image]"
|
|
case "source-tree":
|
|
commitLine += " [source]"
|
|
}
|
|
}
|
|
lines = append(lines, commitLine)
|
|
|
|
if build.Image != "" {
|
|
// Skip image line to keep header compact
|
|
}
|
|
|
|
lines = append(lines, fmt.Sprintf("Updated: %s", s.Timestamp))
|
|
|
|
par := widgets.NewParagraph()
|
|
par.Title = "Build"
|
|
par.Text = strings.Join(lines, "\n")
|
|
par.Border = true
|
|
par.BorderStyle = ui.NewStyle(ui.ColorYellow)
|
|
return par
|
|
}
|
|
|
|
func renderSnapshot(s *Snapshot, selectedModule int) (*widgets.List, *ui.Grid) {
|
|
hostname, err := os.Hostname()
|
|
if err != nil || hostname == "" {
|
|
hostname = "unknown"
|
|
}
|
|
ip := primaryIPv4()
|
|
if ip == "" {
|
|
ip = "unknown"
|
|
}
|
|
|
|
servicesTable := buildServicesTable(s)
|
|
portsTable := buildPortsTable(s)
|
|
for i := 1; i < len(portsTable.Rows); i++ {
|
|
if portsTable.RowStyles == nil {
|
|
portsTable.RowStyles = make(map[int]ui.Style)
|
|
}
|
|
if portsTable.Rows[i][2] == "Open" {
|
|
portsTable.RowStyles[i] = ui.NewStyle(ui.ColorGreen)
|
|
} else {
|
|
portsTable.RowStyles[i] = ui.NewStyle(ui.ColorYellow)
|
|
}
|
|
}
|
|
modulesList := buildModulesList(s)
|
|
if selectedModule >= 0 && selectedModule < len(modulesList.Rows) {
|
|
modulesList.SelectedRow = selectedModule
|
|
}
|
|
helpPar := widgets.NewParagraph()
|
|
helpPar.Title = "Controls"
|
|
helpPar.Text = " ↓ : Down\n ↑ : Up"
|
|
helpPar.Border = true
|
|
helpPar.BorderStyle = ui.NewStyle(ui.ColorMagenta)
|
|
|
|
moduleInfoPar := widgets.NewParagraph()
|
|
moduleInfoPar.Title = "Module Info"
|
|
if selectedModule >= 0 && selectedModule < len(s.Modules) {
|
|
mod := s.Modules[selectedModule]
|
|
moduleInfoPar.Text = fmt.Sprintf("%s\nCategory: %s\nType: %s", mod.Description, mod.Category, mod.Type)
|
|
} else {
|
|
moduleInfoPar.Text = "Select a module to view info"
|
|
}
|
|
moduleInfoPar.Border = true
|
|
moduleInfoPar.BorderStyle = ui.NewStyle(ui.ColorMagenta)
|
|
storagePar := buildStorageParagraph(s)
|
|
volumesPar := buildVolumesParagraph(s)
|
|
|
|
header := widgets.NewParagraph()
|
|
header.Text = fmt.Sprintf("Host: %s\nIP: %s\nProject: %s\nNetwork: %s", hostname, ip, s.Project, s.Network)
|
|
header.Border = true
|
|
|
|
buildPar := buildInfoParagraph(s)
|
|
|
|
usersPar := widgets.NewParagraph()
|
|
usersPar.Title = "Users"
|
|
usersPar.Text = fmt.Sprintf(" Online: %d\n Accounts: %d\n Characters: %d\n Active 7d: %d", s.Users.Online, s.Users.Accounts, s.Users.Characters, s.Users.Active7d)
|
|
usersPar.Border = true
|
|
|
|
const headerRowFrac = 0.18
|
|
const middleRowFrac = 0.43
|
|
const bottomRowFrac = 0.39
|
|
|
|
// Derive inner row ratios from the computed bottom row height so that
|
|
// internal containers tile their parent with the same spacing behavior
|
|
// as top-level rows.
|
|
grid := ui.NewGrid()
|
|
termWidth, termHeight := ui.TerminalDimensions()
|
|
|
|
headerHeight := int(float64(termHeight) * headerRowFrac)
|
|
middleHeight := int(float64(termHeight) * middleRowFrac)
|
|
bottomHeight := termHeight - headerHeight - middleHeight
|
|
if bottomHeight <= 0 {
|
|
bottomHeight = int(float64(termHeight) * bottomRowFrac)
|
|
}
|
|
|
|
helpHeight := int(float64(bottomHeight) * 0.32)
|
|
if helpHeight < 1 {
|
|
helpHeight = 1
|
|
}
|
|
moduleInfoHeight := bottomHeight - helpHeight
|
|
if moduleInfoHeight < 1 {
|
|
moduleInfoHeight = 1
|
|
}
|
|
|
|
storageHeight := int(float64(bottomHeight) * 0.513)
|
|
if storageHeight < 1 {
|
|
storageHeight = 1
|
|
}
|
|
volumesHeight := bottomHeight - storageHeight
|
|
if volumesHeight < 1 {
|
|
volumesHeight = 1
|
|
}
|
|
|
|
helpRatio := float64(helpHeight) / float64(bottomHeight)
|
|
moduleInfoRatio := float64(moduleInfoHeight) / float64(bottomHeight)
|
|
storageRatio := float64(storageHeight) / float64(bottomHeight)
|
|
volumesRatio := float64(volumesHeight) / float64(bottomHeight)
|
|
|
|
grid.SetRect(0, 0, termWidth, termHeight)
|
|
grid.Set(
|
|
ui.NewRow(headerRowFrac,
|
|
ui.NewCol(0.34, header),
|
|
ui.NewCol(0.33, buildPar),
|
|
ui.NewCol(0.33, usersPar),
|
|
),
|
|
ui.NewRow(middleRowFrac,
|
|
ui.NewCol(0.6, servicesTable),
|
|
ui.NewCol(0.4, portsTable),
|
|
),
|
|
ui.NewRow(bottomRowFrac,
|
|
ui.NewCol(0.25, modulesList),
|
|
ui.NewCol(0.15,
|
|
ui.NewRow(helpRatio, helpPar),
|
|
ui.NewRow(moduleInfoRatio, moduleInfoPar),
|
|
),
|
|
ui.NewCol(0.6,
|
|
ui.NewRow(storageRatio,
|
|
ui.NewCol(1.0, storagePar),
|
|
),
|
|
ui.NewRow(volumesRatio,
|
|
ui.NewCol(1.0, volumesPar),
|
|
),
|
|
),
|
|
),
|
|
)
|
|
ui.Render(grid)
|
|
return modulesList, grid
|
|
}
|
|
|
|
func main() {
|
|
if err := ui.Init(); err != nil {
|
|
log.Fatalf("failed to init termui: %v", err)
|
|
}
|
|
defer ui.Close()
|
|
|
|
snapshot, err := runSnapshot()
|
|
if err != nil {
|
|
log.Fatalf("failed to fetch snapshot: %v", err)
|
|
}
|
|
selectedModule := 0
|
|
modulesWidget, currentGrid := renderSnapshot(snapshot, selectedModule)
|
|
|
|
snapCh := make(chan *Snapshot, 1)
|
|
go func() {
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
snap, err := runSnapshot()
|
|
if err != nil {
|
|
log.Printf("snapshot error: %v", err)
|
|
continue
|
|
}
|
|
select {
|
|
case snapCh <- snap:
|
|
default:
|
|
}
|
|
}
|
|
}()
|
|
|
|
events := ui.PollEvents()
|
|
for {
|
|
select {
|
|
case e := <-events:
|
|
switch e.ID {
|
|
case "q", "<C-c>":
|
|
return
|
|
case "<Down>", "j":
|
|
if selectedModule < len(snapshot.Modules)-1 {
|
|
selectedModule++
|
|
modulesWidget, currentGrid = renderSnapshot(snapshot, selectedModule)
|
|
}
|
|
case "<Up>", "k":
|
|
if selectedModule > 0 {
|
|
selectedModule--
|
|
modulesWidget, currentGrid = renderSnapshot(snapshot, selectedModule)
|
|
}
|
|
case "<Resize>":
|
|
modulesWidget, currentGrid = renderSnapshot(snapshot, selectedModule)
|
|
continue
|
|
}
|
|
if modulesWidget != nil {
|
|
if selectedModule >= 0 && selectedModule < len(modulesWidget.Rows) {
|
|
modulesWidget.SelectedRow = selectedModule
|
|
}
|
|
}
|
|
if currentGrid != nil {
|
|
ui.Render(currentGrid)
|
|
}
|
|
case snap := <-snapCh:
|
|
snapshot = snap
|
|
if selectedModule >= len(snapshot.Modules) {
|
|
selectedModule = len(snapshot.Modules) - 1
|
|
if selectedModule < 0 {
|
|
selectedModule = 0
|
|
}
|
|
}
|
|
modulesWidget, currentGrid = renderSnapshot(snapshot, selectedModule)
|
|
}
|
|
}
|
|
}
|