package pzserver import ( "crypto/rand" "encoding/hex" "regexp" "strconv" "sync" "time" ) // ansiRe ловит управляющие последовательности терминала: SteamCMD и сам // сервер раскрашивают вывод, а в браузере эти коды превращаются в мусор // вида "[0mOK". Первый вариант — CSI (цвета, перемещение курсора), второй — // всё остальное, что начинается с ESC. var ansiRe = regexp.MustCompile("\x1b\\[[0-9;?]*[ -/]*[@-~]|\x1b.") // stripANSI убирает управляющие последовательности из строки вывода. func stripANSI(text string) string { return ansiRe.ReplaceAllString(text, "") } // LogLine — одна строка консоли сервера с монотонным номером, по которому // веб-клиент понимает, что он пропустил, и откуда догружать. type LogLine struct { Seq int64 `json:"seq"` Time int64 `json:"time"` // Unix-миллисекунды Stream string `json:"stream"` Text string `json:"text"` } // logBuffer — кольцевой буфер последних строк консоли плюс рассылка их // живым подписчикам (SSE-соединениям веб-панели). type logBuffer struct { mu sync.RWMutex lines []LogLine capacity int nextSeq int64 epoch string subs map[int]chan LogLine nextSub int } func newLogBuffer(capacity int) *logBuffer { return &logBuffer{ lines: make([]LogLine, 0, capacity), capacity: capacity, nextSeq: 1, epoch: newEpoch(), subs: make(map[int]chan LogLine), } } // newEpoch помечает этот запуск менеджера. Номера строк живут только в памяти // и после перезапуска начинаются заново — открытая вкладка приняла бы новые // строки за уже показанные и молчала бы до перезагрузки страницы. По смене // метки браузер понимает, что нумерацию надо начать с нуля. func newEpoch() string { var raw [8]byte if _, err := rand.Read(raw[:]); err != nil { // Случайность здесь не нужна для безопасности: метку достаточно // отличать от предыдущей, и время запуска с этим справляется. return strconv.FormatInt(time.Now().UnixNano(), 36) } return hex.EncodeToString(raw[:]) } // append кладёт строку в буфер и рассылает подписчикам. func (b *logBuffer) append(line LogLine) { // Чистим здесь, а не у каждого источника: через буфер проходят и вывод // сервера, и SteamCMD, и сообщения самой панели. line.Text = stripANSI(line.Text) b.mu.Lock() line.Seq = b.nextSeq b.nextSeq++ if len(b.lines) == b.capacity { copy(b.lines, b.lines[1:]) b.lines = b.lines[:len(b.lines)-1] } b.lines = append(b.lines, line) subs := make([]chan LogLine, 0, len(b.subs)) for _, ch := range b.subs { subs = append(subs, ch) } b.mu.Unlock() for _, ch := range subs { // Неблокирующая отправка: подписчик, который не успевает читать, // теряет строки, но не тормозит вывод сервера. select { case ch <- line: default: } } } // since возвращает строки с номером больше seq (seq<=0 — весь буфер). func (b *logBuffer) since(seq int64) []LogLine { b.mu.RLock() defer b.mu.RUnlock() out := make([]LogLine, 0, len(b.lines)) for _, l := range b.lines { if l.Seq > seq { out = append(out, l) } } return out } // subscribe открывает канал живых строк и функцию отписки. func (b *logBuffer) subscribe(bufferSize int) (<-chan LogLine, func()) { ch := make(chan LogLine, bufferSize) b.mu.Lock() id := b.nextSub b.nextSub++ b.subs[id] = ch b.mu.Unlock() return ch, func() { b.mu.Lock() if existing, ok := b.subs[id]; ok { delete(b.subs, id) close(existing) } b.mu.Unlock() } }