Newer
Older
hard-panel / panel / frontend / src / pages / ServicesPage.vue
<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>