Newer
Older
hard-panel / panel / frontend / src / pages / ServicePage.vue
<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 { SERIES_MAIN } from '../chartTheme.js'
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)),
    color: SERIES_MAIN,
    fill: true,
  },
])

// --- Состояние / самосвидетельство -------------------------------------------

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>