From a9bcd7c5f7ff914860efa674e16970472b2df162 Mon Sep 17 00:00:00 2001 From: stepan Date: Sun, 13 Sep 2026 16:22:16 +0300 Subject: [PATCH 1/3] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=BD=D1=8F=D1=82=D1=8C?= =?UTF-8?q?=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=20=D0=B8=20observability=20?= =?UTF-8?q?=D0=B2=20dev-=D1=81=D1=82=D0=B5=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервис frontend: сборка SPA (Vite) и отдача через Caddy, /api → core:5080. В профиль observability добавлены Loki/Promtail/Grafana (в dev их не было). Dev-Caddyfile отдаёт SPA с Cache-Control no-cache, чтобы пересборка подхватывалась обновлением страницы. --- deploy/caddy/Caddyfile.dev | 19 +++++++++++ deploy/compose.dev.yml | 65 ++++++++++++++++++++++++++++++++++++++ src/frontend/Dockerfile | 14 ++++++++ 3 files changed, 98 insertions(+) create mode 100644 deploy/caddy/Caddyfile.dev create mode 100644 src/frontend/Dockerfile diff --git a/deploy/caddy/Caddyfile.dev b/deploy/caddy/Caddyfile.dev new file mode 100644 index 0000000..ce945ff --- /dev/null +++ b/deploy/caddy/Caddyfile.dev @@ -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 + } +} diff --git a/deploy/compose.dev.yml b/deploy/compose.dev.yml index b4403eb..145c4e2 100644 --- a/deploy/compose.dev.yml +++ b/deploy/compose.dev.yml @@ -254,6 +254,18 @@ services: timeout: 3s 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) # внутри dev-сети. Подъём: docker compose -f deploy/compose.dev.yml --profile observability up -d. # Конфиг — общий deploy/observability/prometheus.yml (те же имена сервисов и таргеты). UI — 9090. @@ -332,6 +344,56 @@ services: - /:/host/root:ro 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: deal_pgdata: deal_minio_data: @@ -340,3 +402,6 @@ volumes: deal_api_data: deal_prometheus_data: deal_tempo_data: + deal_loki_data: + deal_promtail_data: + deal_grafana_data: diff --git a/src/frontend/Dockerfile b/src/frontend/Dockerfile new file mode 100644 index 0000000..3802337 --- /dev/null +++ b/src/frontend/Dockerfile @@ -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 From a9f2b3a1ef5e482bac69871e6ba269125b41979d Mon Sep 17 00:00:00 2001 From: stepan Date: Sun, 13 Sep 2026 16:22:20 +0300 Subject: [PATCH 2/3] =?UTF-8?q?=D0=9F=D0=BE=D1=87=D0=B8=D0=BD=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20QR-=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B8=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B9=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=83=D1=81=20Telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tgStart ждёт старт QR до показа картинки (устранена гонка с 404). Вкладка Telegram опрашивает статус и обновляет QR по таймеру, не останавливаясь до авторизации. Статус: account берётся из живого статуса сервиса, connected = phase ready (авторизация), а не транспорт. --- src/core/Deal.Api/Telegram/TgStatusService.cs | 11 +++- .../src/components/settings/TelegramTab.vue | 59 +++++++++++++++++-- src/frontend/src/store/telegram.js | 9 ++- src/frontend/src/views/SettingsView.vue | 29 +-------- 4 files changed, 71 insertions(+), 37 deletions(-) diff --git a/src/core/Deal.Api/Telegram/TgStatusService.cs b/src/core/Deal.Api/Telegram/TgStatusService.cs index 9a6a4e7..1ba58ba 100644 --- a/src/core/Deal.Api/Telegram/TgStatusService.cs +++ b/src/core/Deal.Api/Telegram/TgStatusService.cs @@ -22,6 +22,7 @@ public sealed class TgStatusService( TelegramKeysService keys) { private const string IdlePhase = "idle"; + private const string ReadyPhase = "ready"; // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. private static readonly JsonSerializerOptions KvJsonOptions = new() @@ -37,13 +38,19 @@ public sealed class TgStatusService( public async Task GetAsync(CancellationToken ct) { 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; TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false); return new TgStatusDto( Phase: live.Phase, - Connected: live.Connected, + Connected: authorized, Listener: live.Listener, Account: account, Monitored: monitored, diff --git a/src/frontend/src/components/settings/TelegramTab.vue b/src/frontend/src/components/settings/TelegramTab.vue index fd83edd..a0b253b 100644 --- a/src/frontend/src/components/settings/TelegramTab.vue +++ b/src/frontend/src/components/settings/TelegramTab.vue @@ -2,10 +2,9 @@ // Вкладка «Telegram» настроек: подключение аккаунта (QR / номер / код / // облачный пароль) и авто-мониторинг новых чатов. Ключи приложения // (api_id/api_hash) задаёт оператор глобально — у тенанта их нет. -// Опрос статуса при QR-входе живёт в SettingsView (watch на state.tgState), -// чтобы поведение при переключении вкладок осталось прежним. -import { ref } from 'vue' -import { state, connectTgStep, tgStart, disconnectTg } from '../../store.js' +// Пока идёт QR-вход, вкладка сама опрашивает статус и обновляет картинку. +import { onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { state, connectTgStep, tgStart, disconnectTg, refreshTgStatus } from '../../store.js' import Icon from '../Icon.vue' import UiToggle from '../ui/ToggleSwitch.vue' @@ -14,6 +13,58 @@ const code = ref('') const tgPass = ref('') 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() { await connectTgStep(code.value) if (state.tgState === 'done') code.value = '' diff --git a/src/frontend/src/store/telegram.js b/src/frontend/src/store/telegram.js index 56a443a..e9b5153 100644 --- a/src/frontend/src/store/telegram.js +++ b/src/frontend/src/store/telegram.js @@ -5,11 +5,12 @@ import { state, toast, errMsg, fmtMsgTime } from './core.js' export function mapTgStatus(st) { 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.tgKeysSet = !!st.keysSet if (st.error) state.tgError = st.error - const phase = st.phase || 'idle' if (phase === 'ready') { state.tgState = 'done' } else if (['phone', 'code', 'password', 'qr'].includes(phase)) { @@ -147,10 +148,12 @@ export async function refreshTgStatus() { export async function tgStart() { state.tgError = '' if (state.tgQrMode) { - state.tgState = 'qr' try { + // Сначала стартуем QR на бэке, затем показываем картинку: иначе успевает запросить + // /api/tg/qr-image до готовности QR и остаётся пустым. const r = await api.post('/api/tg/start-qr') state.tgQrUrl = r.qrUrl || '' + state.tgState = 'qr' toast(t('settings.ssylka-dlya-vhoda-sgenerirovana-otkrojte'), { icon: 'send' }) } catch (e) { state.tgError = errMsg(e) diff --git a/src/frontend/src/views/SettingsView.vue b/src/frontend/src/views/SettingsView.vue index f0eb0e2..e16ef05 100644 --- a/src/frontend/src/views/SettingsView.vue +++ b/src/frontend/src/views/SettingsView.vue @@ -1,7 +1,6 @@