<script setup>
import { onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import {
GnBadge, GnButton, GnCard, GnEmptyState, GnInput,
GnModal, GnPageHeader, GnSkeleton,
} from 'gnexus-ui-kit/vue'
import { useToast } from 'gnexus-ui-kit/vue'
import { api } from '../api.js'
import { fmtUptime } from '../format.js'
import { t } from '../i18n/index.js'
const toast = useToast()
const router = useRouter()
const services = ref([])
const loading = ref(true)
// --- Загрузка + поллинг (пробер пишет точки раз в GHARD_HEALTH_INTERVAL) ----
let pollTimer = null
async function load() {
try {
services.value = await api.services()
loading.value = false
} catch (e) {
toast.danger({ title: t('common.failedToLoad'), text: e.message })
loading.value = false
}
}
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)
// --- Добавление --------------------------------------------------------------
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
}
}
</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="secondary" icon="ph-arrow-right" @click="router.push('/services/' + service.id)">
{{ t('server.open') }}
</GnButton>
</div>
</template>
</GnCard>
</div>
</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>
</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;
}
</style>