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).
This commit is contained in:
2026-09-20 21:26:17 +03:00
parent 8ae8ca1a1d
commit 4c3736b5fb
8 changed files with 1128 additions and 19 deletions
+20 -2
View File
@@ -110,17 +110,35 @@ func (c *AdminClient) ListParticipants(ctx context.Context, room string) ([]Part
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
}
payload, err := json.Marshal(body)
token, err := c.issuer.IssueAdmin(room)
if err != nil {
return err
}
token, err := c.issuer.IssueAdmin(room)
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
}
+23 -14
View File
@@ -87,6 +87,11 @@ func (i *TokenIssuer) Issue(room, identity, displayName string, grants Grants) (
claims.NotBefore = now.Add(-10 * time.Second).Unix()
claims.ExpiresAt = now.Add(i.ttl).Unix()
return i.sign(claims)
}
// sign подписывает произвольные claims алгоритмом HS256.
func (i *TokenIssuer) sign(claims any) (string, error) {
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
if err != nil {
return "", err
@@ -142,20 +147,7 @@ func (i *TokenIssuer) IssueAdmin(room string) (string, error) {
"roomAdmin": true,
},
}
header, err := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
if err != nil {
return "", err
}
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
signingInput := encodeSegment(header) + "." + encodeSegment(payload)
mac := hmac.New(sha256.New, []byte(i.apiSecret))
if _, err := mac.Write([]byte(signingInput)); err != nil {
return "", err
}
return signingInput + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
return i.sign(claims)
}
// VerifyWebhook проверяет подпись вебхука LiveKit: это JWT, подписанный тем же
@@ -272,6 +264,23 @@ func ResolveLiveKitURL(raw string) (string, error) {
return strings.TrimSuffix(parsed.String(), "/"), nil
}
// IssueRoomList подписывает служебный токен для запросов, которым не нужна
// конкретная комната (проверка доступности RoomService в healthcheck).
func (i *TokenIssuer) IssueRoomList() (string, error) {
if !i.Enabled() {
return "", ErrDisabled
}
return i.sign(map[string]any{
"iss": i.apiKey,
"sub": i.apiKey,
"nbf": i.now().UTC().Add(-10 * time.Second).Unix(),
"exp": i.now().UTC().Add(5 * time.Minute).Unix(),
"video": map[string]any{
"roomList": true,
},
})
}
// VoiceURLForClient отдаёт адрес LiveKit так, как его должен использовать
// клиент: пусто, если голос не настроен.
func (i *TokenIssuer) VoiceURLForClient(publicURL string) string {