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 @@
- {{ t('services.externalNoData') }}{{ service.public_url }}
+
{{ t('services.urlHint') }}
+{{ t('services.publicUrlHint') }}
{{ t('services.urlHint') }}
+{{ t('services.publicUrlHint') }}