diff --git a/panel/frontend/src/api.js b/panel/frontend/src/api.js index a224e3a..a847110 100644 --- a/panel/frontend/src/api.js +++ b/panel/frontend/src/api.js @@ -69,8 +69,8 @@ return request(`/shares/${id}/samples?${params}`) }, services: () => request('/services'), - createService: (name, url) => - request('/services', { method: 'POST', body: { name, url } }), + createService: (name, url, publicUrl = '') => + request('/services', { method: 'POST', body: { name, url, public_url: publicUrl } }), updateService: (id, fields) => request(`/services/${id}`, { method: 'PATCH', body: fields }), deleteService: (id) => request(`/services/${id}`, { method: 'DELETE' }), @@ -82,6 +82,12 @@ }, serviceIncidents: (id, limit = 50) => request(`/services/${id}/incidents?limit=${limit}`), + serviceExternalSamples: (id, since, limit = 1000) => { + const params = new URLSearchParams() + if (since) params.set('since', since.toISOString()) + params.set('limit', String(limit)) + return request(`/services/${id}/external_samples?${params}`) + }, // Персональные MCP-ключи (только cookie-сессия, как /me PATCH); // createMcpToken возвращает plaintext-ключ ровно один раз mcpTokens: () => request('/me/mcp_tokens'), diff --git a/panel/frontend/src/chartTheme.js b/panel/frontend/src/chartTheme.js index 67388a2..61d398c 100644 --- a/panel/frontend/src/chartTheme.js +++ b/panel/frontend/src/chartTheme.js @@ -17,6 +17,9 @@ // Одиночная серия-«герой» (персональный график сервиса): светлее CHART_COLORS[0] // (контраст на #1E2522 ~7.5:1 против 5.5:1) — тонкая линия на большой карте читается export const SERIES_MAIN = '#5BC795' // verdant $color-secondary +// Внешний путь сервиса (публичный вход) — синий партнёр зелёного: на отдельной +// карточке отличает «снаружи» от внутреннего графика +export const SERIES_EXTERNAL = CHART_COLORS[1] export const INK = { text: '#C3D6CB', // $color-text-light — заголовки diff --git a/panel/frontend/src/i18n/messages/en.js b/panel/frontend/src/i18n/messages/en.js index b21f573..aec9ffd 100644 --- a/panel/frontend/src/i18n/messages/en.js +++ b/panel/frontend/src/i18n/messages/en.js @@ -124,6 +124,21 @@ "services.name": "Name", "services.url": "Health URL", "services.urlHint": "The address the panel polls (GET) to check health, for example http://gnexus-creds.local/api/v1/health", + "services.publicUrl": "Public URL (optional)", + "services.publicUrlHint": "https:// address of the public entry point. The panel checks public DNS → expected IP → TLS with SNI → HTTP. Set GHARD_PUBLIC_IP to compare DNS with the expected address.", + "services.publicUrlPlaceholder": "https://service.example.com/", + "services.externalBadge": "outside: {state}", + "services.extUp": "up", + "services.extPending": "waiting", + "services.extDnsMismatch": "DNS points elsewhere", + "services.extDnsFail": "no DNS", + "services.extTlsFail": "TLS/certificate", + "services.extHttpFail": "HTTP error", + "services.extDown": "unreachable", + "services.extProbeError": "probe failed", + "services.extCertDays": "certificate {n} d", + "services.externalChartTitle": "Public availability: response time, ms", + "services.externalNoData": "No external checks yet", "services.latency": "response {n} ms", "services.stateUp": "up", "services.stateDegraded": "degraded", diff --git a/panel/frontend/src/i18n/messages/ru.js b/panel/frontend/src/i18n/messages/ru.js index e0b20e1..c67ecff 100644 --- a/panel/frontend/src/i18n/messages/ru.js +++ b/panel/frontend/src/i18n/messages/ru.js @@ -123,6 +123,21 @@ "services.name": "Название", "services.url": "Health-URL", "services.urlHint": "Адрес, который панель опрашивает (GET) для проверки здоровья, например http://gnexus-creds.local/api/v1/health", + "services.publicUrl": "Публичный URL (необязательно)", + "services.publicUrlHint": "https:// адрес публичного входа. Панель проверяет публичный DNS → ожидаемый IP → TLS с SNI → HTTP. Для сверки DNS задайте GHARD_PUBLIC_IP.", + "services.publicUrlPlaceholder": "https://service.example.com/", + "services.externalBadge": "снаружи: {state}", + "services.extUp": "в порядке", + "services.extPending": "ожидание", + "services.extDnsMismatch": "DNS ведёт не туда", + "services.extDnsFail": "DNS не отвечает", + "services.extTlsFail": "сертификат/TLS", + "services.extHttpFail": "ошибка HTTP", + "services.extDown": "недоступен", + "services.extProbeError": "проба не удалась", + "services.extCertDays": "сертификат {n} дн.", + "services.externalChartTitle": "Доступность снаружи: время отклика, ms", + "services.externalNoData": "Внешних проверок ещё нет", "services.latency": "отклик {n} ms", "services.stateUp": "в порядке", "services.stateDegraded": "деградация", diff --git a/panel/frontend/src/i18n/messages/uk.js b/panel/frontend/src/i18n/messages/uk.js index c0c5587..6e1c525 100644 --- a/panel/frontend/src/i18n/messages/uk.js +++ b/panel/frontend/src/i18n/messages/uk.js @@ -123,6 +123,21 @@ "services.name": "Назва", "services.url": "Health-URL", "services.urlHint": "Адреса, яку панель опитує (GET) для перевірки здоров'я, наприклад http://gnexus-creds.local/api/v1/health", + "services.publicUrl": "Публічний URL (необов'язково)", + "services.publicUrlHint": "https:// адреса публічного входу. Панель перевіряє публічний DNS → очікуваний IP → TLS з SNI → HTTP. Для звірки DNS задайте GHARD_PUBLIC_IP.", + "services.publicUrlPlaceholder": "https://service.example.com/", + "services.externalBadge": "зовні: {state}", + "services.extUp": "у порядку", + "services.extPending": "очікування", + "services.extDnsMismatch": "DNS веде не туди", + "services.extDnsFail": "DNS не відповідає", + "services.extTlsFail": "сертифікат/TLS", + "services.extHttpFail": "помилка HTTP", + "services.extDown": "недоступний", + "services.extProbeError": "проба не вдалася", + "services.extCertDays": "сертифікат {n} дн.", + "services.externalChartTitle": "Доступність зовні: час відгуку, ms", + "services.externalNoData": "Зовнішніх перевірок ще немає", "services.latency": "відгук {n} ms", "services.stateUp": "у порядку", "services.stateDegraded": "деградація", diff --git a/panel/frontend/src/pages/ServicePage.vue b/panel/frontend/src/pages/ServicePage.vue index 3ac5115..3613b45 100644 --- a/panel/frontend/src/pages/ServicePage.vue +++ b/panel/frontend/src/pages/ServicePage.vue @@ -9,9 +9,12 @@ import { api } from '../api.js' import ChartLine from '../components/ChartLine.vue' -import { SERIES_MAIN } from '../chartTheme.js' +import { SERIES_EXTERNAL, SERIES_MAIN } from '../chartTheme.js' import { fmtAgo, fmtUptime } from '../format.js' import { t } from '../i18n/index.js' +import { + externalHint, externalText, externalVariant, internalText, internalVariant, +} from '../serviceStatus.js' const props = defineProps({ id: { type: String, required: true } }) const router = useRouter() @@ -19,6 +22,7 @@ const service = ref(null) const samples = ref([]) +const externalSamples = ref([]) const incidents = ref([]) const loading = ref(true) @@ -57,6 +61,15 @@ } } +async function loadExternalSamples() { + try { + const since = new Date(Date.now() - periodHours.value * 3600 * 1000) + externalSamples.value = await api.serviceExternalSamples(props.id, since, 5000) + } catch (e) { + if (e.status !== 401) toast.warning({ title: t('services.historyFailed'), text: e.message }) + } +} + async function loadIncidents() { try { incidents.value = await api.serviceIncidents(props.id) @@ -69,9 +82,12 @@ stopTimers() loadService() loadSamples() + loadExternalSamples() loadIncidents() serviceTimer = setInterval(loadService, 15000) - samplesTimer = setInterval(() => { loadSamples(); loadIncidents() }, 60000) + samplesTimer = setInterval(() => { + loadSamples(); loadExternalSamples(); loadIncidents() + }, 60000) } function stopTimers() { @@ -80,7 +96,7 @@ } watch(() => props.id, startTimers, { immediate: true }) -watch(period, loadSamples) +watch(period, () => { loadSamples(); loadExternalSamples() }) onUnmounted(stopTimers) // --- График latency ----------------------------------------------------------- @@ -107,20 +123,23 @@ }, ]) +// --- Внешний путь (публичный URL) --------------------------------------------- + +const externalChartLabels = computed(() => + externalSamples.value.map((p) => timeLabel(p.ts)), +) +const externalChartDatasets = computed(() => [ + { + label: t('services.externalChartTitle'), + // недоступные замеры — разрыв линии, а не ноль: ноль соврал бы про отклик + data: externalSamples.value.map((p) => (p.state === 'up' ? p.latency_ms : null)), + color: SERIES_EXTERNAL, + fill: true, + }, +]) + // --- Состояние / самосвидетельство ------------------------------------------- -const stateVariant = (status) => - status === 'up' ? 'success' - : status === 'degraded' ? 'warning' - : status === 'down' ? 'danger' - : 'warning' - -const stateText = (status) => ({ - up: t('services.stateUp'), - degraded: t('services.stateDegraded'), - down: t('services.stateDown'), -}[status] || t('services.statePending')) - const modeText = (mode) => ({ maintenance: t('services.modeMaintenance'), readonly: t('services.modeReadonly'), @@ -175,11 +194,13 @@ const editOpen = ref(false) const editName = ref('') const editUrl = ref('') +const editPublicUrl = ref('') const editSaving = ref(false) function openEdit() { editName.value = service.value.name editUrl.value = service.value.url + editPublicUrl.value = service.value.public_url || '' editOpen.value = true } @@ -187,10 +208,15 @@ if (!editName.value.trim() || !editUrl.value.trim() || editSaving.value) return editSaving.value = true try { - await api.updateService(props.id, { name: editName.value.trim(), url: editUrl.value.trim() }) + await api.updateService(props.id, { + name: editName.value.trim(), + url: editUrl.value.trim(), + public_url: editPublicUrl.value.trim(), + }) editOpen.value = false await loadService() loadIncidents() // PATCH url даёт новый замер — период мог открыться/закрыться + loadExternalSamples() // смена публичного URL начинает историю заново toast.success({ title: t('services.serviceUpdated') }) } catch (e) { toast.danger({ title: t('server.failedToSave'), text: e.message }) @@ -241,7 +267,14 @@ @@ -300,6 +345,13 @@

{{ t('services.urlHint') }}

+ +

{{ t('services.publicUrlHint') }}