CI / build (push) Successful in 1m18s
- Fix Subscribe overwriting channel without closing old one (goroutine leak) - Add context.Context to Stream() for cancellation support - Add WebSocket read pump to detect dead clients (StreamLogs, StreamSteamCMDLogs, StreamRPTLogs) - Use defer f.Close() pattern in StreamRPTLogs file handling - Fix findLatestRPT nil dereference on failed os.Stat - Optimize findLatestRPT to collect stat results before sorting - Apply gofmt formatting to modlists.go, settings.go
270 lines
5.6 KiB
Go
270 lines
5.6 KiB
Go
package api
|
|
|
|
import (
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
const wsReadTimeout = 60 * time.Second
|
|
|
|
func readPump(conn *websocket.Conn, done chan struct{}) {
|
|
defer close(done)
|
|
conn.SetReadLimit(4096)
|
|
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
|
conn.SetPongHandler(func(string) error {
|
|
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
|
return nil
|
|
})
|
|
for {
|
|
_, _, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return
|
|
}
|
|
conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
|
}
|
|
}
|
|
|
|
func (h *Handler) StreamLogs(c *gin.Context) {
|
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
log.Printf("websocket upgrade error: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
done := make(chan struct{})
|
|
go readPump(conn, done)
|
|
|
|
clientID := conn.RemoteAddr().String()
|
|
ch := h.streamer.Subscribe("server", clientID)
|
|
defer h.streamer.Unsubscribe("server", clientID)
|
|
|
|
for {
|
|
select {
|
|
case line, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
|
return
|
|
}
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Handler) ListLogs(c *gin.Context) {
|
|
profilesDir := h.process.ProfilesDir()
|
|
files := h.collectLogFiles(profilesDir, "")
|
|
|
|
logsDir := filepath.Join(profilesDir, "logs")
|
|
if fi, err := os.Stat(logsDir); err == nil && fi.IsDir() {
|
|
files = append(files, h.collectLogFiles(logsDir, "logs")...)
|
|
}
|
|
|
|
slices.Sort(files)
|
|
if files == nil {
|
|
files = []string{}
|
|
}
|
|
c.JSON(http.StatusOK, files)
|
|
}
|
|
|
|
func (h *Handler) collectLogFiles(dir, prefix string) []string {
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
var files []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
ext := filepath.Ext(e.Name())
|
|
if ext == ".log" || ext == ".rpt" || e.Name() == "script.log" {
|
|
if prefix != "" {
|
|
files = append(files, prefix+"/"+e.Name())
|
|
} else {
|
|
files = append(files, e.Name())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return files
|
|
}
|
|
|
|
func (h *Handler) StreamSteamCMDLogs(c *gin.Context) {
|
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
log.Printf("websocket upgrade error: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
done := make(chan struct{})
|
|
go readPump(conn, done)
|
|
|
|
clientID := conn.RemoteAddr().String()
|
|
ch := h.streamer.Subscribe("steamcmd", clientID)
|
|
defer h.streamer.Unsubscribe("steamcmd", clientID)
|
|
|
|
for {
|
|
select {
|
|
case line, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
|
return
|
|
}
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *Handler) StreamRPTLogs(c *gin.Context) {
|
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
|
if err != nil {
|
|
log.Printf("websocket upgrade error: %v", err)
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
|
|
done := make(chan struct{})
|
|
go readPump(conn, done)
|
|
|
|
profilesDir := h.process.ProfilesDir()
|
|
var currentPath string
|
|
var currentOffset int64
|
|
ticker := time.NewTicker(250 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-done:
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
|
|
path := findLatestRPT(profilesDir)
|
|
|
|
if path != currentPath {
|
|
currentPath = path
|
|
currentOffset = 0
|
|
if currentPath == "" {
|
|
continue
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte("--- tailing: "+filepath.Base(currentPath)+" ---")); err != nil {
|
|
return
|
|
}
|
|
}
|
|
if currentPath == "" {
|
|
continue
|
|
}
|
|
|
|
fi, err := os.Stat(currentPath)
|
|
if err != nil {
|
|
currentPath = ""
|
|
continue
|
|
}
|
|
if fi.Size() <= currentOffset {
|
|
continue
|
|
}
|
|
|
|
if err := func() error {
|
|
f, err := os.Open(currentPath)
|
|
if err != nil {
|
|
currentPath = ""
|
|
return nil
|
|
}
|
|
defer f.Close()
|
|
|
|
if _, err := f.Seek(currentOffset, io.SeekStart); err != nil {
|
|
return nil
|
|
}
|
|
buf := make([]byte, fi.Size()-currentOffset)
|
|
n, _ := io.ReadFull(f, buf)
|
|
|
|
currentOffset += int64(n)
|
|
for _, line := range strings.Split(string(buf[:n]), "\n") {
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if err := conn.WriteMessage(websocket.TextMessage, []byte(line)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}(); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func findLatestRPT(dir string) string {
|
|
pattern := filepath.Join(dir, "arma3server_x64_*.rpt")
|
|
matches, err := filepath.Glob(pattern)
|
|
if err != nil || len(matches) == 0 {
|
|
return ""
|
|
}
|
|
type rptInfo struct {
|
|
path string
|
|
time time.Time
|
|
}
|
|
var infos []rptInfo
|
|
for _, m := range matches {
|
|
fi, err := os.Stat(m)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
infos = append(infos, rptInfo{path: m, time: fi.ModTime()})
|
|
}
|
|
if len(infos) == 0 {
|
|
return ""
|
|
}
|
|
sort.Slice(infos, func(i, j int) bool {
|
|
return infos[i].time.After(infos[j].time)
|
|
})
|
|
return infos[0].path
|
|
}
|
|
|
|
func (h *Handler) GetLog(c *gin.Context) {
|
|
filename := filepath.Base(c.Param("file"))
|
|
if filename == "" || filename == "." || strings.ContainsAny(filename, "/\\") {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
|
|
return
|
|
}
|
|
profilesDir := h.process.ProfilesDir()
|
|
path := filepath.Join(profilesDir, filename)
|
|
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
logsPath := filepath.Join(profilesDir, "logs", filename)
|
|
if _, err2 := os.Stat(logsPath); err2 == nil {
|
|
path = logsPath
|
|
} else {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
|
return
|
|
}
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.String(http.StatusOK, string(data))
|
|
}
|