Files
grendervill 4c3736b5fb feat(instance): метрики и healthcheck инстанса для дашборда
Раздел «Инстанс» получает живые показатели сервера (AGENT.md 7.19).

- `internal/sysinfo`: загрузка процессора по cgroup v2 (`cpu.stat`/`cpu.max`)
  с откатом на `/proc/stat`, память по лимиту контейнера либо `/proc/meminfo`,
  место на разделе данных и размер базы вместе с журналом WAL;
- `GET /api/v1/instance/metrics` (только администратор инстанса): метрики,
  состояние (`ok|warn|failed`), проверки app/database/disk/storage/voice/
  gateway, версия и время работы; недоступные источники не ломают ответ, а
  попадают в проверку `metrics`;
- тяжёлые проверки (БД, SFU, запись) кэшируются на 5 секунд: ручку можно
  опрашивать раз в секунду;
- `voice.IssueRoomList` и `AdminClient.Ping` проверяют доступность RoomService
  и валидность ключей LiveKit (та же связка, что ломала модерацию);
- у метрик свой лимит частоты, они исключены из общего лимита API
  (`RateLimiter.MiddlewareExcept`), иначе открытый раздел съедал бы половину
  бюджета запросов;
- тесты: `internal/sysinfo` (cgroup, /proc, память, диск, размеры файлов) и
  `internal/server/api_metrics_test.go` (доступ, состав метрик, кэш, 429).
2026-09-20 21:26:17 +03:00

173 lines
6.3 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package voice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// AdminClient обращается к RoomService LiveKit (Twirp/JSON) для модерации:
// серверный мьют дорожки и удаление участника из комнаты (AGENT.md 7.14).
// Собственный HTTP-клиент вместо SDK: нужны три метода из всего API.
type AdminClient struct {
issuer *TokenIssuer
baseURL string
http *http.Client
}
// NewAdminClient создаёт клиента RoomService. Пустой baseURL означает, что
// модерация на стороне SFU недоступна (например, в тестах).
func NewAdminClient(issuer *TokenIssuer, baseURL string) *AdminClient {
return &AdminClient{
issuer: issuer,
baseURL: strings.TrimSuffix(strings.TrimSpace(baseURL), "/"),
http: &http.Client{Timeout: 5 * time.Second},
}
}
// Enabled сообщает, настроен ли доступ к RoomService.
func (c *AdminClient) Enabled() bool {
return c != nil && c.baseURL != "" && c.issuer.Enabled()
}
// Participant — участник комнаты на стороне SFU.
type Participant struct {
Identity string `json:"identity"`
SID string `json:"sid"`
Tracks []struct {
SID string `json:"sid"`
Type string `json:"type"`
Source string `json:"source"`
Muted bool `json:"muted"`
Name string `json:"name"`
} `json:"tracks"`
}
// participantEnvelope принимает оба формата ответа RoomService: LiveKit 1.x
// отдаёт объект участника на верхнем уровне (`GetParticipant`), а обёртку
// `{"participant": {...}}` поддерживаем на случай смены формы в новых версиях.
type participantEnvelope struct {
Participant
Wrapped *Participant `json:"participant"`
}
// ParticipantInfo отдаёт данные участника комнаты.
func (c *AdminClient) ParticipantInfo(ctx context.Context, room, identity string) (*Participant, error) {
var envelope participantEnvelope
err := c.call(ctx, room, "GetParticipant", map[string]any{"room": room, "identity": identity}, &envelope)
if err != nil {
return nil, err
}
if envelope.Wrapped != nil && envelope.Identity == "" && len(envelope.Tracks) == 0 {
return envelope.Wrapped, nil
}
participant := envelope.Participant
return &participant, nil
}
// SetMicrophoneMuted включает или выключает микрофон участника на стороне SFU:
// клиент физически не может обойти серверный мьют (AGENT.md 7.14).
func (c *AdminClient) SetMicrophoneMuted(ctx context.Context, room, identity string, muted bool) error {
participant, err := c.ParticipantInfo(ctx, room, identity)
if err != nil {
return err
}
trackSID := ""
for _, track := range participant.Tracks {
if strings.EqualFold(track.Source, "MICROPHONE") || strings.EqualFold(track.Type, "AUDIO") {
trackSID = track.SID
break
}
}
if trackSID == "" {
// Участник без микрофона: мьютить нечего, это не ошибка.
return nil
}
return c.call(ctx, room, "MutePublishedTrack", map[string]any{
"room": room, "identity": identity, "track_sid": trackSID, "muted": muted,
}, nil)
}
// RemoveParticipant удаляет участника из комнаты (кик из голосовой).
func (c *AdminClient) RemoveParticipant(ctx context.Context, room, identity string) error {
return c.call(ctx, room, "RemoveParticipant", map[string]any{"room": room, "identity": identity}, nil)
}
// ListParticipants перечисляет участников комнаты: используется для проверки
// расхождений между БД и SFU.
func (c *AdminClient) ListParticipants(ctx context.Context, room string) ([]Participant, error) {
var response struct {
Participants []Participant `json:"participants"`
}
if err := c.call(ctx, room, "ListParticipants", map[string]any{"room": room}, &response); err != nil {
return nil, err
}
return response.Participants, nil
}
// Ping проверяет доступность RoomService: healthcheck дашборда заодно
// убеждается, что ключи LiveKit валидны (их неверность ломает модерацию).
func (c *AdminClient) Ping(ctx context.Context) error {
if !c.Enabled() {
return ErrDisabled
}
token, err := c.issuer.IssueRoomList()
if err != nil {
return err
}
return c.callWithToken(ctx, token, "ListRooms", map[string]any{}, nil)
}
// call выполняет запрос к Twirp-методу RoomService с административным токеном
// этой комнаты: LiveKit принимает `roomAdmin` только вместе с именем комнаты.
func (c *AdminClient) call(ctx context.Context, room, method string, body any, out any) error {
if !c.Enabled() {
return ErrDisabled
}
token, err := c.issuer.IssueAdmin(room)
if err != nil {
return err
}
return c.callWithToken(ctx, token, method, body, out)
}
// callWithToken выполняет запрос к Twirp-методу с готовым токеном.
func (c *AdminClient) callWithToken(ctx context.Context, token, method string, body any, out any) error {
payload, err := json.Marshal(body)
if err != nil {
return err
}
endpoint := fmt.Sprintf("%s/twirp/livekit.RoomService/%s", c.baseURL, method)
request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+token)
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("voice.api_unavailable: %w", err)
}
defer func() { _ = response.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return err
}
if response.StatusCode != http.StatusOK {
return fmt.Errorf("voice.api_error: %s: %s", response.Status, strings.TrimSpace(string(raw)))
}
if out == nil || len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("voice.api_decode: %w", err)
}
return nil
}