<script setup>
import { computed, onUnmounted, ref } from 'vue'
import {
GnBadge, GnButton, GnCard, GnConfirmDialog, GnEmptyState, GnInput,
GnModal, GnPageHeader, GnSkeleton,
} from 'gnexus-ui-kit/vue'
import { useToast } from 'gnexus-ui-kit/vue'
import { api } from '../api.js'
import ChartLine from '../components/ChartLine.vue'
import { CHART_COLORS } from '../chartTheme.js'
import { fmtUptime } from '../format.js'
import { t } from '../i18n/index.js'
const toast = useToast()
const services = ref([])
const samples = ref({}) // service_id -> [{ts, latency_ms, state}]
const loading = ref(true)
// --- Загрузка + поллинг (пробер пишет точки раз в GHARD_HEALTH_INTERVAL) ----
let pollTimer = null
const HISTORY_HOURS = 24
async function load() {
try {
services.value = await api.services()
await Promise.all(services.value.map((sv) => loadSamples(sv.id)))
loading.value = false
} catch (e) {
toast.danger({ title: t('common.failedToLoad'), text: e.message })
loading.value = false
}
}
async function loadSamples(serviceId) {
try {
const since = new Date(Date.now() - HISTORY_HOURS * 3600 * 1000)
const raw = await api.serviceSamples(serviceId, since, 2880)
samples.value = { ...samples.value, [serviceId]: raw }
} catch (e) {
if (e.status !== 401) toast.warning({ title: t('services.historyFailed'), text: e.message })
}
}
load()
pollTimer = setInterval(load, 30000)
onUnmounted(() => clearInterval(pollTimer))
// --- Карточки ----------------------------------------------------------------
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 latencyText = (service) =>
t('services.latency', { n: service.latency_ms ?? '—' })
// --- Самосвидетельство (§5 спеки): identity / mode / gauges -------------------
// mode — сознательное состояние (обслуживание/только чтение), не состояние живости
const modeText = (mode) => ({
maintenance: t('services.modeMaintenance'),
readonly: t('services.modeReadonly'),
draining: t('services.modeDraining'),
}[mode] || mode)
const hasIdentity = (report) =>
report && (report.version || report.environment || report.uptime !== undefined)
// --- График: датасет на сервис; down/ошибки — null (разрыв линии) ------------
const chartDatasets = computed(() =>
services.value.map((sv, i) => ({
label: sv.name,
data: (samples.value[sv.id] || []).map((p) => (p.state === 'up' ? p.latency_ms : null)),
color: CHART_COLORS[i % CHART_COLORS.length],
})),
)
const chartLabels = computed(() => {
const longest = services.value.reduce(
(acc, sv) => ((samples.value[sv.id] || []).length > acc.length ? samples.value[sv.id] || [] : acc),
[],
)
return longest.map((p) => {
const d = new Date(p.ts)
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')}`
})
})
// --- Добавление / удаление ---------------------------------------------------
const addOpen = ref(false)
const newName = ref('')
const newUrl = ref('')
const creating = ref(false)
function openAdd() {
newName.value = ''
newUrl.value = ''
addOpen.value = true
}
async function create() {
if (!newName.value.trim() || !newUrl.value.trim() || creating.value) return
creating.value = true
try {
await api.createService(newName.value.trim(), newUrl.value.trim())
addOpen.value = false
await load()
toast.success({ title: t('services.serviceAdded') })
} catch (e) {
toast.danger({ title: t('dash.failed'), text: e.message })
} finally {
creating.value = false
}
}
const deleteOpen = ref(false)
const deleteTarget = ref(null)
function openDelete(service) {
deleteTarget.value = service
deleteOpen.value = true
}
async function removeService() {
try {
await api.deleteService(deleteTarget.value.id)
toast.success({ title: t('services.serviceDeleted') })
await load()
} catch (e) {
toast.danger({ title: t('server.failedToDelete'), text: e.message })
} finally {
deleteOpen.value = false
}
}
</script>
<template>
<GnPageHeader :title="t('services.title')" :subtitle="t('services.subtitle')" kicker="GHard Monitor">
<template #actions>
<GnButton variant="secondary" icon="ph-plus" @click="openAdd">{{ t('services.addService') }}</GnButton>
</template>
</GnPageHeader>
<div v-if="loading">
<GnSkeleton type="card" :count="3" />
</div>
<GnEmptyState
v-else-if="!services.length"
:title="t('services.emptyTitle')"
:text="t('services.emptyText')"
icon="ph-heartbeat"
>
<template #actions>
<GnButton variant="secondary" icon="ph-plus" @click="openAdd">{{ t('services.addService') }}</GnButton>
</template>
</GnEmptyState>
<template v-else>
<div class="services-grid">
<GnCard v-for="service in services" :key="service.id" class="service-card">
<template #title>
<span class="service-title">
<span class="service-name">{{ service.name }}</span>
<GnBadge :variant="stateVariant(service.status)">
{{ stateText(service.status) }}
</GnBadge>
<GnBadge v-if="service.report?.mode" variant="secondary">
{{ modeText(service.report.mode) }}
</GnBadge>
</span>
</template>
<code class="service-url">{{ service.url }}</code>
<div class="service-meta" v-if="hasIdentity(service.report)">
<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>
</div>
<div class="service-gauges" v-if="Object.keys(service.report?.gauges || {}).length">
<span v-for="(value, name) in service.report.gauges" :key="name" class="gauge-chip">
{{ name }}: {{ value }}
</span>
</div>
<div class="service-latency" v-if="service.status !== 'pending'">
{{ latencyText(service) }}
</div>
<div class="service-nodata" v-else>{{ t('services.statePending') }}</div>
<div class="service-error" v-if="service.status === 'down' && service.message">
{{ t('services.notResponding', { message: service.message }) }}
</div>
<template #footer>
<div class="service-footer">
<span class="service-seen">{{
service.last_sample
? t('storage.sampledAt', { time: new Date(service.last_sample).toLocaleTimeString() })
: t('storage.notSampled')
}}</span>
<GnButton size="sm" variant="danger" icon="ph-trash" @click="openDelete(service)">{{ t('common.delete') }}</GnButton>
</div>
</template>
</GnCard>
</div>
<GnCard :title="t('services.historyTitle')" class="history-card">
<ChartLine :labels="chartLabels" :datasets="chartDatasets" y-unit=" ms" />
</GnCard>
</template>
<!-- Добавление -->
<GnModal v-model:open="addOpen" :title="t('services.addTitle')">
<GnInput v-model="newName" :label="t('services.name')" icon="ph-gear" placeholder="gnexus-creds" />
<GnInput
v-model="newUrl"
:label="t('services.url')"
icon="ph-globe"
placeholder="http://gnexus-creds.local/api/v1/health"
/>
<p class="input-info">{{ t('services.urlHint') }}</p>
<template #actions="{ close }">
<GnButton variant="primary" @click="close">{{ t('common.cancel') }}</GnButton>
<GnButton variant="secondary" :loading="creating" :disabled="!newName.trim() || !newUrl.trim()" @click="create">
{{ t('common.add') }}
</GnButton>
</template>
</GnModal>
<!-- Удаление -->
<GnConfirmDialog
v-model:open="deleteOpen"
:title="t('services.deleteTitle')"
:message="t('services.deleteMessage', { name: deleteTarget?.name })"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
confirm-variant="danger"
@confirm="removeService"
/>
</template>
<style scoped>
.page-header {
margin-bottom: 20px;
}
.services-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(330px, 420px));
gap: 20px;
margin-top: 20px;
}
.service-card {
width: 100%;
max-width: none;
}
.service-title {
display: flex;
align-items: center;
gap: 10px;
}
.service-name {
overflow: hidden;
text-overflow: ellipsis;
}
.service-url {
display: block;
font-size: 13px;
opacity: 0.75;
margin-bottom: 14px;
word-break: break-all;
}
.service-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
opacity: 0.7;
margin-bottom: 8px;
}
.service-gauges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.gauge-chip {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
padding: 2px 8px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
opacity: 0.85;
}
.service-latency {
font-size: 14px;
opacity: 0.85;
}
.service-nodata {
opacity: 0.6;
font-size: 14px;
padding: 8px 0;
}
.service-error {
margin-top: 10px;
font-size: 13px;
opacity: 0.65;
word-break: break-all;
}
.service-footer {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.service-seen {
opacity: 0.6;
font-size: 13px;
}
.history-card {
max-width: none;
width: 100%;
margin-top: 24px;
}
</style>