Веб-панель управления сервером Project Zomboid
Менеджер запускает сервер PZ как дочерний процесс и держит его stdin/stdout: отсюда живая консоль без RCON, стриминг логов через SSE и корректная остановка по цепочке quit -> SIGTERM -> SIGKILL для всей группы процессов. Сам менеджер работает под systemd. Что входит: - управление жизненным циклом сервера, автозапуск и автоподъём после падения; - метрики процесса из /proc и состояние хоста, список игроков онлайн; - редакторы servertest.ini и SandboxVars.lua, сохраняющие исходное форматирование файлов; - моды: списки Workshop ID и Mod ID с порядком загрузки, названия из Steam; - бэкапы мира по расписанию и вручную, с ротацией и восстановлением; - установка и обновление сервера через SteamCMD; - вход по логину и паролю, первичная настройка по одноразовому коду. Веб-интерфейс без сборщика, вшит в бинарник через embed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package pzconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// workshopAPI — публичный метод Steam, ключ доступа не требуется.
|
||||
const workshopAPI = "https://api.steampowered.com/ISteamRemoteStorage/GetPublishedFileDetails/v1/"
|
||||
|
||||
// WorkshopItem — то, что панель показывает про мод из мастерской.
|
||||
type WorkshopItem struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Preview string `json:"preview"`
|
||||
Description string `json:"description"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// WorkshopClient запрашивает названия модов у Steam и кеширует их: список
|
||||
// модов на странице обновляется часто, а названия не меняются.
|
||||
type WorkshopClient struct {
|
||||
HTTP *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]cachedItem
|
||||
}
|
||||
|
||||
type cachedItem struct {
|
||||
item WorkshopItem
|
||||
fetched time.Time
|
||||
}
|
||||
|
||||
// cacheTTL — как долго доверять закешированному названию мода.
|
||||
const cacheTTL = 12 * time.Hour
|
||||
|
||||
// NewWorkshopClient создаёт клиент со своим HTTP-таймаутом.
|
||||
func NewWorkshopClient() *WorkshopClient {
|
||||
return &WorkshopClient{
|
||||
HTTP: &http.Client{Timeout: 15 * time.Second},
|
||||
cache: make(map[string]cachedItem),
|
||||
}
|
||||
}
|
||||
|
||||
// Details запрашивает описание модов по их Workshop ID. Если Steam недоступен,
|
||||
// возвращает записи с заполненным полем Error — панель всё равно покажет
|
||||
// список, просто без названий.
|
||||
func (c *WorkshopClient) Details(ctx context.Context, ids []string) []WorkshopItem {
|
||||
out := make([]WorkshopItem, 0, len(ids))
|
||||
missing := make([]string, 0, len(ids))
|
||||
|
||||
c.mu.Lock()
|
||||
for _, id := range ids {
|
||||
if entry, ok := c.cache[id]; ok && time.Since(entry.fetched) < cacheTTL {
|
||||
out = append(out, entry.item)
|
||||
continue
|
||||
}
|
||||
missing = append(missing, id)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if len(missing) > 0 {
|
||||
fetched, err := c.fetch(ctx, missing)
|
||||
if err != nil {
|
||||
for _, id := range missing {
|
||||
out = append(out, WorkshopItem{ID: id, Error: err.Error()})
|
||||
}
|
||||
} else {
|
||||
c.mu.Lock()
|
||||
for _, item := range fetched {
|
||||
c.cache[item.ID] = cachedItem{item: item, fetched: time.Now()}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
out = append(out, fetched...)
|
||||
}
|
||||
}
|
||||
|
||||
// Возвращаем в том же порядке, в каком просили.
|
||||
byID := make(map[string]WorkshopItem, len(out))
|
||||
for _, item := range out {
|
||||
byID[item.ID] = item
|
||||
}
|
||||
ordered := make([]WorkshopItem, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if item, ok := byID[id]; ok {
|
||||
ordered = append(ordered, item)
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, WorkshopItem{ID: id, Error: "нет данных"})
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
func (c *WorkshopClient) fetch(ctx context.Context, ids []string) ([]WorkshopItem, error) {
|
||||
form := url.Values{}
|
||||
form.Set("itemcount", strconv.Itoa(len(ids)))
|
||||
for n, id := range ids {
|
||||
form.Set(fmt.Sprintf("publishedfileids[%d]", n), id)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, workshopAPI,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Steam недоступен: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Steam ответил %s", resp.Status)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Response struct {
|
||||
PublishedFileDetails []struct {
|
||||
PublishedFileID string `json:"publishedfileid"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
Result int `json:"result"`
|
||||
} `json:"publishedfiledetails"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return nil, fmt.Errorf("ответ Steam не разобран: %w", err)
|
||||
}
|
||||
|
||||
items := make([]WorkshopItem, 0, len(payload.Response.PublishedFileDetails))
|
||||
for _, d := range payload.Response.PublishedFileDetails {
|
||||
item := WorkshopItem{ID: d.PublishedFileID, Title: d.Title, Preview: d.PreviewURL}
|
||||
// result == 1 означает "найдено"; всё остальное — удалён или скрыт.
|
||||
if d.Result != 1 {
|
||||
item.Error = "мод не найден в мастерской"
|
||||
}
|
||||
if len(d.Description) > 400 {
|
||||
d.Description = d.Description[:400] + "…"
|
||||
}
|
||||
item.Description = d.Description
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
Reference in New Issue
Block a user