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