4 Commits
Author SHA1 Message Date
rust a9bfecf9cc Merge pull request 'Поднять dev-стек в Docker (фронт + observability) и починить подключение Telegram' (#24) from t23_dev_stack_telegram into main
ci / build-test (push) Successful in 2m55s
Reviewed-on: #24
2026-09-13 17:58:14 +03:00
stepan 13d7994511 Обновить тест статуса Telegram под новую семантику
ci / build-test (pull_request) Successful in 2m50s
connected = phase ready; живой account приоритетнее KV tgAccount.
2026-09-13 16:27:19 +03:00
stepan a9f2b3a1ef Починить QR-вход и реактивный статус Telegram
ci / build-test (pull_request) Failing after 1m58s
tgStart ждёт старт QR до показа картинки (устранена гонка с 404). Вкладка Telegram опрашивает статус и обновляет QR по таймеру, не останавливаясь до авторизации. Статус: account берётся из живого статуса сервиса, connected = phase ready (авторизация), а не транспорт.
2026-09-13 16:22:20 +03:00
stepan a9bcd7c5f7 Поднять фронт и observability в dev-стеке
Сервис frontend: сборка SPA (Vite) и отдача через Caddy, /api → core:5080. В профиль observability добавлены Loki/Promtail/Grafana (в dev их не было). Dev-Caddyfile отдаёт SPA с Cache-Control no-cache, чтобы пересборка подхватывалась обновлением страницы.
2026-09-13 16:22:16 +03:00
8 changed files with 174 additions and 41 deletions
+19
View File
@@ -0,0 +1,19 @@
# Dev-edge «Дейла» (compose.dev.yml, сервис frontend): SPA + /api на core.
# Отличие от prod-Caddyfile: HTTP без TLS и без плейсхолдер-домена — для локального просмотра UI.
# Статика — собранный SPA (Vite) в /srv, неизвестные пути отдают index.html (история браузера).
:80 {
# API core: /api/* уходит на core:5080 без перезаписи (контракт /api неизменен).
# SSE (/api/events), файлы и QR-SVG проходят reverse_proxy потоково.
handle /api/* {
reverse_proxy core:5080
}
handle {
# SPA/ассеты в dev не кэшируем: пересборка фронта должна подхватываться по F5.
header Cache-Control "no-cache"
root * /srv
try_files {path} /index.html
file_server
}
}
+65
View File
@@ -254,6 +254,18 @@ services:
timeout: 3s timeout: 3s
retries: 10 retries: 10
# Фронтенд (SPA) — сборка образа (Vite) и отдача через Caddy; /api → core:5080. UI — http://localhost:8080.
frontend:
build:
context: ..
dockerfile: src/frontend/Dockerfile
container_name: deal-frontend
ports:
- "8080:80"
depends_on:
core:
condition: service_healthy
# Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464) # Prometheus (профиль observability, этап 12/пакет A) — сбор /metrics всех 4 процессов (:9464)
# внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d. # внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d.
# Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090. # Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090.
@@ -332,6 +344,56 @@ services:
- /:/host/root:ro - /:/host/root:ro
pid: host pid: host
# Loki — хранилище логов (профиль observability), UI/API — :3100.
loki:
image: grafana/loki:3.4.2
container_name: deal-loki
profiles: ["observability"]
command: -config.file=/etc/loki/loki.yml
ports:
- "3100:3100"
volumes:
- ./observability/loki.yml:/etc/loki/loki.yml:ro
- deal_loki_data:/loki
# Promtail — сбор docker-логов deal-процессов в Loki (docker.sock, профиль observability).
promtail:
image: grafana/promtail:3.4.2
container_name: deal-promtail
profiles: ["observability"]
command: -config.file=/etc/promtail/promtail.yml
volumes:
- ./observability/promtail.yml:/etc/promtail/promtail.yml:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
- deal_promtail_data:/var/lib/promtail
depends_on:
loki:
condition: service_started
# Grafana — UI логов/метрик/трейсов (профиль observability), локальный вход admin/admin.
grafana:
image: grafana/grafana:11.5.2
container_name: deal-grafana
profiles: ["observability"]
environment:
GF_SECURITY_ADMIN_USER: admin
GF_SECURITY_ADMIN_PASSWORD: admin
GF_USERS_ALLOW_SIGN_UP: "false"
GF_AUTH_ANONYMOUS_ENABLED: "false"
ports:
- "3001:3000"
volumes:
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
- ./observability/grafana/dashboards:/var/lib/grafana/dashboards:ro
- deal_grafana_data:/var/lib/grafana
depends_on:
loki:
condition: service_started
prometheus:
condition: service_started
tempo:
condition: service_started
volumes: volumes:
deal_pgdata: deal_pgdata:
deal_minio_data: deal_minio_data:
@@ -340,3 +402,6 @@ volumes:
deal_api_data: deal_api_data:
deal_prometheus_data: deal_prometheus_data:
deal_tempo_data: deal_tempo_data:
deal_loki_data:
deal_promtail_data:
deal_grafana_data:
@@ -22,6 +22,7 @@ public sealed class TgStatusService(
TelegramKeysService keys) TelegramKeysService keys)
{ {
private const string IdlePhase = "idle"; private const string IdlePhase = "idle";
private const string ReadyPhase = "ready";
// Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра.
private static readonly JsonSerializerOptions KvJsonOptions = new() private static readonly JsonSerializerOptions KvJsonOptions = new()
@@ -37,13 +38,19 @@ public sealed class TgStatusService(
public async Task<TgStatusDto> GetAsync(CancellationToken ct) public async Task<TgStatusDto> GetAsync(CancellationToken ct)
{ {
TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false); TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false);
string account = await ReadAccountAsync(ct).ConfigureAwait(false); // «Подключён» для UI = авторизован (phase ready). Транспортный connected сервиса
// означает лишь живость соединения и не гарантирует вход — в UI он даёт «зависание».
bool authorized = string.Equals(live.Phase, ReadyPhase, StringComparison.Ordinal);
// Живой account (имя из Telegram) приоритетнее KV: при QR-входе KV ещё не заполнен.
string account = string.IsNullOrEmpty(live.Account)
? await ReadAccountAsync(ct).ConfigureAwait(false)
: live.Account;
int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count; int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count;
TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false); TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false);
return new TgStatusDto( return new TgStatusDto(
Phase: live.Phase, Phase: live.Phase,
Connected: live.Connected, Connected: authorized,
Listener: live.Listener, Listener: live.Listener,
Account: account, Account: account,
Monitored: monitored, Monitored: monitored,
@@ -67,21 +67,22 @@ public sealed class TgStatusServiceTests
/// Готовый аккаунт /// Готовый аккаунт
/// </summary> /// </summary>
[Fact] [Fact]
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys() public async Task GetAsync_ReadyGateway_PrefersLiveAccountOverKvAndComputesConnected()
{ {
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create(); (TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\""); settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop"); PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
// Транспортный Connected=false, но phase ready → UI должен считать аккаунт подключённым.
gateway.Status = new TelegramAccountStatusDto( gateway.Status = new TelegramAccountStatusDto(
Phase: "ready", Connected: true, Listener: true, Account: "gateway-account", Error: null, QrUrl: null); Phase: "ready", Connected: false, Listener: true, Account: "@liveuser", Error: null, QrUrl: null);
TgStatusDto status = await service.GetAsync(CancellationToken.None); TgStatusDto status = await service.GetAsync(CancellationToken.None);
Assert.Equal("ready", status.Phase); Assert.Equal("ready", status.Phase);
Assert.True(status.Connected); Assert.True(status.Connected); // connected = авторизация (phase ready), не транспорт
Assert.True(status.Listener); Assert.True(status.Listener);
Assert.Equal("@realuser", status.Account); // KV tgAccount перекрывает справочное поле гейта (Ruling 8) Assert.Equal("@liveuser", status.Account); // живой account приоритетнее KV
Assert.Equal(1, status.Monitored); Assert.Equal(1, status.Monitored);
Assert.True(status.KeysSet); Assert.True(status.KeysSet);
} }
+14
View File
@@ -0,0 +1,14 @@
# Фронтенд Deal: сборка SPA (Vite) и отдача статики через Caddy.
# Контекст сборки — корень репозитория (см. deploy/compose.dev.yml: build.context ..).
# /api/* проксируется на core:5080 (см. deploy/caddy/Caddyfile.dev).
FROM node:24-alpine AS build
WORKDIR /app
COPY src/frontend/package.json src/frontend/package-lock.json ./
RUN npm ci
COPY src/frontend/ ./
RUN npm run build
FROM caddy:2.9.1
COPY --from=build /app/dist /srv
COPY deploy/caddy/Caddyfile.dev /etc/caddy/Caddyfile
@@ -2,10 +2,9 @@
// Вкладка «Telegram» настроек: подключение аккаунта (QR / номер / код / // Вкладка «Telegram» настроек: подключение аккаунта (QR / номер / код /
// облачный пароль) и авто-мониторинг новых чатов. Ключи приложения // облачный пароль) и авто-мониторинг новых чатов. Ключи приложения
// (api_id/api_hash) задаёт оператор глобально — у тенанта их нет. // (api_id/api_hash) задаёт оператор глобально — у тенанта их нет.
// Опрос статуса при QR-входе живёт в SettingsView (watch на state.tgState), // Пока идёт QR-вход, вкладка сама опрашивает статус и обновляет картинку.
// чтобы поведение при переключении вкладок осталось прежним. import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { ref } from 'vue' import { state, connectTgStep, tgStart, disconnectTg, refreshTgStatus } from '../../store.js'
import { state, connectTgStep, tgStart, disconnectTg } from '../../store.js'
import Icon from '../Icon.vue' import Icon from '../Icon.vue'
import UiToggle from '../ui/ToggleSwitch.vue' import UiToggle from '../ui/ToggleSwitch.vue'
@@ -14,6 +13,58 @@ const code = ref('')
const tgPass = ref('') const tgPass = ref('')
const qrTick = ref(0) const qrTick = ref(0)
// QR-ссылка приходит после старта: перезагружаем картинку, когда она появилась/сменилась.
watch(
() => state.tgQrUrl,
() => {
if (state.tgState === 'qr') qrTick.value++
},
)
// Таймер QR: пока идёт вход — периодически обновляем статус и саму картинку
// (токен Telegram меняется); при подключении показываем учётку и останавливаемся.
const QR_POLL_MS = 5000
let qrTimer = null
function stopQrTimer() {
if (qrTimer) {
clearInterval(qrTimer)
qrTimer = null
}
}
function startQrTimer() {
stopQrTimer()
qrTimer = setInterval(async () => {
await refreshTgStatus()
// Продолжаем опрос, пока не авторизованы: переходной статус (phase ready до
// подтверждения транспорта) не должен останавливать обновление.
if (state.tgConnected) {
stopQrTimer()
return
}
if (state.tgState === 'idle') {
stopQrTimer()
return
}
qrTick.value++
}, QR_POLL_MS)
}
// Таймер стартует при входе в режим QR и сам останавливается при подключении/сбросе.
watch(
() => state.tgState,
(s) => {
if (s === 'qr') startQrTimer()
},
)
onMounted(async () => {
await refreshTgStatus()
if (state.tgState === 'qr') startQrTimer()
})
onBeforeUnmount(stopQrTimer)
async function submitCode() { async function submitCode() {
await connectTgStep(code.value) await connectTgStep(code.value)
if (state.tgState === 'done') code.value = '' if (state.tgState === 'done') code.value = ''
+6 -3
View File
@@ -5,11 +5,12 @@ import { state, toast, errMsg, fmtMsgTime } from './core.js'
export function mapTgStatus(st) { export function mapTgStatus(st) {
if (!st) return if (!st) return
state.tgConnected = !!st.connected const phase = st.phase || 'idle'
// «Подключён» = авторизован (phase ready); транспортный connected ненадёжен для UI.
state.tgConnected = phase === 'ready' || !!st.connected
state.tgAccount = st.account || '' state.tgAccount = st.account || ''
state.tgKeysSet = !!st.keysSet state.tgKeysSet = !!st.keysSet
if (st.error) state.tgError = st.error if (st.error) state.tgError = st.error
const phase = st.phase || 'idle'
if (phase === 'ready') { if (phase === 'ready') {
state.tgState = 'done' state.tgState = 'done'
} else if (['phone', 'code', 'password', 'qr'].includes(phase)) { } else if (['phone', 'code', 'password', 'qr'].includes(phase)) {
@@ -147,10 +148,12 @@ export async function refreshTgStatus() {
export async function tgStart() { export async function tgStart() {
state.tgError = '' state.tgError = ''
if (state.tgQrMode) { if (state.tgQrMode) {
state.tgState = 'qr'
try { try {
// Сначала стартуем QR на бэке, затем показываем картинку: иначе <img> успевает запросить
// /api/tg/qr-image до готовности QR и остаётся пустым.
const r = await api.post('/api/tg/start-qr') const r = await api.post('/api/tg/start-qr')
state.tgQrUrl = r.qrUrl || '' state.tgQrUrl = r.qrUrl || ''
state.tgState = 'qr'
toast(t('settings.ssylka-dlya-vhoda-sgenerirovana-otkrojte'), { icon: 'send' }) toast(t('settings.ssylka-dlya-vhoda-sgenerirovana-otkrojte'), { icon: 'send' })
} catch (e) { } catch (e) {
state.tgError = errMsg(e) state.tgError = errMsg(e)
+1 -28
View File
@@ -1,7 +1,6 @@
<script setup> <script setup>
import { t } from '@/i18n/index.js' import { t } from '@/i18n/index.js'
import { onBeforeUnmount, watch } from 'vue' import { state } from '../store.js'
import { state, refreshTgStatus } from '../store.js'
import Icon from '../components/Icon.vue' import Icon from '../components/Icon.vue'
import TelegramTab from '../components/settings/TelegramTab.vue' import TelegramTab from '../components/settings/TelegramTab.vue'
import AiTab from '../components/settings/AiTab.vue' import AiTab from '../components/settings/AiTab.vue'
@@ -26,32 +25,6 @@ const TABS = [
{ id: 'appearance', name: t('settings.vneshnij-vid'), icon: 'palette' }, { id: 'appearance', name: t('settings.vneshnij-vid'), icon: 'palette' },
{ id: 'profile', name: t('settings.profil'), icon: 'key' }, { id: 'profile', name: t('settings.profil'), icon: 'key' },
] ]
// пока идёт QR-вход (вкладка Telegram) — опрашиваем статус, чтобы поймать
// момент подтверждения. Watch живёт на уровне экрана настроек: при
// переключении вкладок опрос не прерывается (как в исходном SettingsView).
let qrTimer = null
watch(
() => state.tgState,
(s) => {
if (s === 'qr') {
stopQrPoll()
qrTimer = setInterval(async () => {
await refreshTgStatus()
if (state.tgState === 'done') stopQrPoll()
}, 4000)
} else {
stopQrPoll()
}
},
)
function stopQrPoll() {
if (qrTimer) {
clearInterval(qrTimer)
qrTimer = null
}
}
onBeforeUnmount(stopQrPoll)
</script> </script>
<template> <template>