<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
GnBadge, GnButton, GnCard, GnConfirmDialog, GnEmptyState, GnIconButton,
GnInput, GnModal, GnPageHeader, GnSkeleton, GnTable,
} from 'gnexus-ui-kit/vue'
import { useToast } from 'gnexus-ui-kit/vue'
import { api } from '../api.js'
import ChartLine from '../components/ChartLine.vue'
import { fmtAgo, fmtUptime } from '../format.js'
import { t } from '../i18n/index.js'
const props = defineProps({ id: { type: String, required: true } })
const router = useRouter()
const toast = useToast()
const service = ref(null)
const samples = ref([])
const incidents = ref([])
const loading = ref(true)
// --- Период графика + поллинг (пробер пишет раз в GHARD_HEALTH_INTERVAL) -----
const PERIODS = [
{ id: '1h', label: '1 h', hours: 1 },
{ id: '6h', label: '6 h', hours: 6 },
{ id: '24h', label: '24 h', hours: 24 },
{ id: '7d', label: '7 d', hours: 24 * 7 },
]
const period = ref('24h')
const periodHours = computed(() => PERIODS.find((p) => p.id === period.value).hours)
let serviceTimer = null
let samplesTimer = null
async function loadService() {
try {
// состав страницы: карточка сервиса есть в общем списке
const all = await api.services()
service.value = all.find((s) => String(s.id) === String(props.id)) || null
} catch (e) {
if (e.status !== 401) service.value = null
} finally {
loading.value = false
}
}
async function loadSamples() {
try {
const since = new Date(Date.now() - periodHours.value * 3600 * 1000)
samples.value = await api.serviceSamples(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)
} catch (e) {
if (e.status !== 401) toast.warning({ title: t('services.historyFailed'), text: e.message })
}
}
function startTimers() {
stopTimers()
loadService()
loadSamples()
loadIncidents()
serviceTimer = setInterval(loadService, 15000)
samplesTimer = setInterval(() => { loadSamples(); loadIncidents() }, 60000)
}
function stopTimers() {
clearInterval(serviceTimer)
clearInterval(samplesTimer)
}
watch(() => props.id, startTimers, { immediate: true })
watch(period, loadSamples)
onUnmounted(stopTimers)
// --- График latency -----------------------------------------------------------
function timeLabel(iso) {
const d = new Date(iso)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
if (periodHours.value >= 24) {
const dd = String(d.getDate()).padStart(2, '0')
const mo = String(d.getMonth() + 1).padStart(2, '0')
return `${dd}.${mo} ${hh}:${mm}`
}
return `${hh}:${mm}`
}
const chartLabels = computed(() => samples.value.map((p) => timeLabel(p.ts)))
const chartDatasets = computed(() => [
{ label: t('services.latency', { n: '' }).trim(), data: samples.value.map((p) => (p.state === 'up' ? p.latency_ms : null)) },
])
// --- Состояние / самосвидетельство -------------------------------------------
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'),
draining: t('services.modeDraining'),
}[mode] || mode)
// --- Журнал недоступности ------------------------------------------------------
const incidentColumns = computed(() => [
{ key: 'start', label: t('services.colStart') },
{ key: 'duration', label: t('services.colDuration') },
{ key: 'what', label: t('services.colWhat') },
{ key: 'reason', label: t('services.colReason') },
])
const incidentRows = computed(() =>
incidents.value.map((inc, i) => ({
id: i,
start: fmtDateTime(inc.start),
duration: inc.end === null ? null : fmtDurationShort(inc.duration_s),
what: inc.worst,
reason: reasonText(inc),
ongoing: inc.end === null,
})),
)
function fmtDateTime(iso) {
const d = new Date(iso)
const dd = String(d.getDate()).padStart(2, '0')
const mo = String(d.getMonth() + 1).padStart(2, '0')
return `${dd}.${mo} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
function fmtDurationShort(seconds) {
const s = Math.round(seconds)
const days = Math.floor(s / 86400)
const hours = Math.floor((s % 86400) / 3600)
const mins = Math.ceil((s % 3600) / 60)
if (days) return `${days} ${t('services.durD')} ${hours} ${t('services.durH')}`
if (hours) return `${hours} ${t('services.durH')} ${mins} ${t('services.durM')}`
return `${s} ${t('services.durS')}`
}
function reasonText(inc) {
if (inc.message && inc.code != null) return `http ${inc.code} · ${inc.message}`
if (inc.message) return inc.message
return '—'
}
// --- Редактирование / удаление --------------------------------------------------
const editOpen = ref(false)
const editName = ref('')
const editUrl = ref('')
const editSaving = ref(false)
function openEdit() {
editName.value = service.value.name
editUrl.value = service.value.url
editOpen.value = true
}
async function save() {
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() })
editOpen.value = false
await loadService()
loadIncidents() // PATCH url даёт новый замер — период мог открыться/закрыться
toast.success({ title: t('services.serviceUpdated') })
} catch (e) {
toast.danger({ title: t('server.failedToSave'), text: e.message })
} finally {
editSaving.value = false
}
}
const deleteOpen = ref(false)
async function removeService() {
try {
await api.deleteService(props.id)
toast.success({ title: t('services.serviceDeleted') })
router.push('/services')
} catch (e) {
toast.danger({ title: t('server.failedToDelete'), text: e.message })
}
}
</script>
<template>
<div class="back-row">
<GnIconButton icon="ph-arrow-left" :label="t('services.back')" size="sm" @click="router.push('/services')" />
</div>
<div v-if="loading">
<GnSkeleton type="card" :count="3" />
</div>
<GnEmptyState
v-else-if="!service"
:title="t('services.notFound')"
:text="t('services.notFoundText')"
icon="ph-heartbeat"
>
<template #actions>
<GnButton variant="secondary" @click="router.push('/services')">{{ t('services.back') }}</GnButton>
</template>
</GnEmptyState>
<template v-else>
<GnPageHeader :title="service.name" :subtitle="service.url" kicker="GHard Monitor">
<template #meta>
<GnBadge :variant="stateVariant(service.status)">{{ stateText(service.status) }}</GnBadge>
<GnBadge v-if="service.report?.mode" variant="secondary">
{{ modeText(service.report.mode) }}
</GnBadge>
</template>
<template #actions>
<GnButton variant="primary" icon="ph-pencil-simple" @click="openEdit">{{ t('services.edit') }}</GnButton>
<GnButton variant="danger" icon="ph-trash" @click="deleteOpen = true">{{ t('common.delete') }}</GnButton>
</template>
</GnPageHeader>
<!-- Самосвидетельство + замер -->
<div class="meta-row" v-if="service.report?.version || service.report?.gauges || service.latency_ms != null">
<span v-if="service.report?.version">v{{ service.report.version }}</span>
<span v-if="service.report?.environment">{{ service.report.environment }}</span>
<span v-if="service.report?.uptime !== undefined">
{{ t('server.uptime', { value: fmtUptime(service.report.uptime) }) }}
</span>
<span v-for="g in Object.entries(service.report?.gauges || {})" :key="g[0]" class="gauge">
{{ g[0] }}: {{ g[1] }}
</span>
<span v-if="service.latency_ms != null">{{ t('services.latency', { n: service.latency_ms }) }}</span>
<span v-if="service.last_sample">{{ t('storage.sampledAt', { time: fmtDateTime(service.last_sample) }) }}</span>
</div>
<!-- Персональный график -->
<GnCard :title="t('services.chartTitle')" class="chart-card">
<div class="period-row">
<GnButton
v-for="p in PERIODS"
:key="p.id"
size="sm"
:variant="period === p.id ? 'secondary' : 'primary'"
@click="period = p.id"
>
{{ p.label }}
</GnButton>
</div>
<ChartLine :labels="chartLabels" :datasets="chartDatasets" y-unit=" ms" />
</GnCard>
<!-- Журнал моментов недоступности -->
<GnCard :title="t('services.logTitle')" class="log-card">
<GnTable :columns="incidentColumns" :rows="incidentRows" :empty-text="t('services.noIncidents')">
<template #cell-duration="{ row }">
<span v-if="row.ongoing" class="ongoing">{{ t('services.ongoing') }}</span>
<template v-else>{{ row.duration }}</template>
</template>
<template #cell-what="{ value }">
<GnBadge :variant="value === 'down' ? 'danger' : 'warning'">{{ stateText(value) }}</GnBadge>
</template>
</GnTable>
</GnCard>
<!-- Редактирование -->
<GnModal v-model:open="editOpen" :title="t('services.editTitle')">
<GnInput v-model="editName" :label="t('services.name')" icon="ph-gear" />
<GnInput v-model="editUrl" :label="t('services.url')" icon="ph-globe" />
<p class="input-info">{{ t('services.urlHint') }}</p>
<template #actions="{ close }">
<GnButton variant="primary" @click="close">{{ t('common.cancel') }}</GnButton>
<GnButton
variant="secondary"
:loading="editSaving"
:disabled="!editName.trim() || !editUrl.trim()"
@click="save"
>
{{ t('common.save') }}
</GnButton>
</template>
</GnModal>
<!-- Удаление -->
<GnConfirmDialog
v-model:open="deleteOpen"
:title="t('services.deleteTitle')"
:message="t('services.deleteMessage', { name: service.name })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
confirm-variant="danger"
@confirm="removeService"
/>
</template>
</template>
<style scoped>
.back-row {
margin-bottom: 16px;
}
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 14px;
font-size: 13px;
opacity: 0.8;
margin-top: 16px;
}
.gauge {
font-family: 'JetBrains Mono', monospace;
}
.chart-card {
margin-top: 20px;
max-width: none;
width: 100%;
}
.period-row {
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 14px;
}
.log-card {
margin-top: 20px;
max-width: none;
width: 100%;
}
.ongoing {
color: var(--gn-danger, #e05252);
font-weight: 600;
}
.input-info {
opacity: 0.6;
font-size: 13px;
margin-top: 6px;
}
</style>