Newer
Older
hard-panel / panel / frontend / src / pages / ServerPage.vue
<script setup>
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
  GnAlert, GnBadge, GnButton, GnCard, GnConfirmDialog, GnDescriptionList,
  GnEmptyState, GnIconButton, GnInput, GnMetricCard, GnModal, GnPageHeader,
  GnProgress, GnSkeleton, GnTable, GnTabs, GnTextarea,
} 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, SERIES_IN, SERIES_OUT } from '../chartTheme.js'
import { fmtAgo, fmtBytes, fmtGiB, fmtMbs, fmtPercent, fmtUptime, ramPercent } from '../format.js'

const props = defineProps({ id: { type: String, required: true } })
const router = useRouter()
const toast = useToast()

const server = ref(null)
const loading = ref(true)
const error = ref('')
const history = ref([])
const activeTab = ref('overview')

// --- Период и автообновление -------------------------------------------------

const PERIODS = [
  { id: '1h', label: '1 ч', hours: 1 },
  { id: '6h', label: '6 ч', hours: 6 },
  { id: '24h', label: '24 ч', hours: 24 },
  { id: '7d', label: '7 д', hours: 24 * 7 },
]
const period = ref('1h')
const periodHours = computed(() => PERIODS.find((p) => p.id === period.value).hours)

let serverTimer = null
let historyTimer = null

async function loadServer() {
  try {
    server.value = await api.server(props.id)
    error.value = ''
  } catch (e) {
    if (e.status !== 401) error.value = e.message
  } finally {
    loading.value = false
  }
}

async function loadHistory() {
  try {
    const since = new Date(Date.now() - periodHours.value * 3600 * 1000)
    history.value = await api.metrics(props.id, since, null, 1500)
  } catch (e) {
    if (e.status !== 401) toast.warning({ title: 'История не загрузилась', text: e.message })
  }
}

function startTimers() {
  stopTimers()
  loadServer()
  loadHistory()
  serverTimer = setInterval(loadServer, 10000)
  historyTimer = setInterval(loadHistory, 30000)
}

function stopTimers() {
  clearInterval(serverTimer)
  clearInterval(historyTimer)
}

watch(() => props.id, startTimers, { immediate: true })
watch(period, loadHistory)
onUnmounted(stopTimers)

// --- Данные для графиков ------------------------------------------------------

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 labels = computed(() => history.value.map((m) => timeLabel(m.ts)))

const cpuRamDatasets = computed(() => [
  { label: 'CPU', data: history.value.map((m) => m.cpu), color: CHART_COLORS[1] },
  { label: 'RAM', data: history.value.map((m) => ramPercent(m)), color: CHART_COLORS[0] },
])

const netDatasets = computed(() => [
  { label: 'Приём', data: history.value.map((m) => m.net_in_mbs), color: SERIES_IN, fill: true },
  { label: 'Отдача', data: history.value.map((m) => m.net_out_mbs), color: SERIES_OUT },
])

const diskMounts = computed(() => {
  const mounts = new Map()
  for (const m of history.value) {
    for (const d of m.disks || []) mounts.set(d.mount, true)
  }
  return [...mounts.keys()]
})

const diskDatasets = computed(() =>
  diskMounts.value.map((mount, i) => ({
    label: mount,
    data: history.value.map((m) => m.disks?.find((d) => d.mount === mount)?.percent ?? null),
    color: CHART_COLORS[i % CHART_COLORS.length],
  })),
)

const metrics = computed(() => server.value?.metrics)
const ram = computed(() => ramPercent(metrics.value))

// --- Таблицы (последний снимок) ----------------------------------------------

const diskColumns = [
  { key: 'mount', label: 'Точка монтирования' },
  { key: 'total', label: 'Размер' },
  { key: 'used', label: 'Занято' },
  { key: 'percent', label: 'Заполнено' },
]
const processColumns = [
  { key: 'name', label: 'Процесс' },
  { key: 'pid', label: 'PID' },
  { key: 'cpu', label: 'CPU %' },
  { key: 'mem', label: 'RAM %' },
]
const dockerColumns = [
  { key: 'name', label: 'Контейнер' },
  { key: 'image', label: 'Образ' },
  { key: 'status', label: 'Статус' },
]

const dockerVariant = (status) => (status?.startsWith('Up') ? 'success' : 'danger')

// --- Инфо о сервере ------------------------------------------------------------

const infoItems = computed(() => {
  const s = server.value
  if (!s) return []
  return [
    { term: 'Hostname', value: s.hostname || '—' },
    { term: 'ОС', value: s.os || '—' },
    { term: 'Ядро', value: s.kernel || '—' },
    { term: 'IP-адреса', value: s.ips?.length ? s.ips.join(', ') : '—' },
    { term: 'IP соединения', value: s.source_ip || '—' },
    { term: 'Интервал агента', value: `${s.interval} с` },
    { term: 'Последний пакет', value: fmtAgo(s.last_seen) },
  ]
})

// --- Заметка ------------------------------------------------------------------

const noteDraft = ref(null)
const noteSaving = ref(false)

const note = computed({
  get: () => (noteDraft.value === null ? server.value?.note || '' : noteDraft.value),
  set: (v) => { noteDraft.value = v },
})
const noteDirty = computed(() => noteDraft.value !== null && noteDraft.value !== server.value?.note)

async function saveNote() {
  if (!noteDirty.value || noteSaving.value) return
  noteSaving.value = true
  try {
    await api.updateServer(props.id, { note: noteDraft.value })
    server.value.note = noteDraft.value
    noteDraft.value = null
    toast.success({ title: 'Заметка сохранена' })
  } catch (e) {
    toast.danger({ title: 'Не сохранилось', text: e.message })
  } finally {
    noteSaving.value = false
  }
}

// --- Переименование и удаление -------------------------------------------------

const renameOpen = ref(false)
const nameDraft = ref('')
const renameSaving = ref(false)

function openRename() {
  nameDraft.value = server.value.name
  renameOpen.value = true
}

async function rename() {
  if (!nameDraft.value.trim() || renameSaving.value) return
  renameSaving.value = true
  try {
    await api.updateServer(props.id, { name: nameDraft.value.trim() })
    server.value.name = nameDraft.value.trim()
    renameOpen.value = false
    toast.success({ title: 'Переименовано' })
  } catch (e) {
    toast.danger({ title: 'Не переименовалось', text: e.message })
  } finally {
    renameSaving.value = false
  }
}

const deleteOpen = ref(false)

async function removeServer() {
  try {
    await api.deleteServer(props.id)
    toast.success({ title: 'Сервер удалён' })
    router.push('/')
  } catch (e) {
    toast.danger({ title: 'Не удалилось', text: e.message })
  }
}

const statusVariant = computed(() => {
  switch (server.value?.status) {
    case 'online': return 'success'
    case 'offline': return 'danger'
    default: return 'warning'
  }
})
</script>

<template>
  <div class="back-row">
    <GnIconButton icon="ph-arrow-left" label="Назад к дашборду" size="sm" @click="router.push('/')" />
  </div>

  <div v-if="loading">
    <GnSkeleton type="line" :count="4" />
  </div>

  <GnEmptyState
    v-else-if="!server"
    title="Сервер не найден"
    text="Возможно, он удалён"
    icon="ph-question"
  >
    <template #actions>
      <GnButton variant="secondary" @click="router.push('/')">На дашборд</GnButton>
    </template>
  </GnEmptyState>

  <template v-else>
    <GnPageHeader :title="server.name" :subtitle="server.hostname + (server.os ? ' · ' + server.os : '')">
      <template #meta>
        <GnBadge :variant="statusVariant">
          {{ server.status === 'online' ? 'онлайн' : server.status === 'offline' ? 'офлайн' : 'ждёт данных' }}
        </GnBadge>
      </template>
      <template #actions>
        <GnButton variant="primary" icon="ph-pencil-simple" @click="openRename">Переименовать</GnButton>
        <GnButton variant="danger" icon="ph-trash" @click="deleteOpen = true">Удалить</GnButton>
      </template>
    </GnPageHeader>

    <GnAlert v-if="error" variant="danger" style="margin-bottom: 16px">{{ error }}</GnAlert>

    <!-- Метрики сейчас -->
    <div class="metric-row" v-if="metrics">
      <GnMetricCard label="CPU" :value="fmtPercent(metrics.cpu)" icon="ph-cpu" />
      <GnMetricCard label="RAM" :value="fmtGiB(metrics.ram.used)" :meta="`всего ${fmtGiB(metrics.ram.total)}`" icon="ph-database" />
      <GnMetricCard label="Свап" :value="fmtGiB(metrics.swap.used)" :meta="`всего ${fmtGiB(metrics.swap.total)}`" icon="ph-swap" />
      <GnMetricCard label="Аптайм" :value="fmtUptime(metrics.uptime)" icon="ph-clock" />
      <GnMetricCard
        label="Сеть"
        :value="`↓ ${fmtMbs(metrics.net_in_mbs)}`"
        :meta="`↑ ${fmtMbs(metrics.net_out_mbs)}`"
        icon="ph-arrows-down-up"
      />
    </div>

    <!-- Инфо + заметка -->
    <div class="info-grid">
      <GnCard title="Инфо">
        <GnDescriptionList :items="infoItems" />
      </GnCard>
      <GnCard title="Заметка">
        <GnTextarea
          v-model="note"
          label="Заметка о сервере"
          :rows="5"
          placeholder="Для чего сервер, что на нём крутится, пароли не хранить :)"
        />
        <div class="note-actions">
          <GnButton
            v-if="noteDirty"
            variant="secondary"
            size="sm"
            :loading="noteSaving"
            @click="saveNote"
          >
            Сохранить
          </GnButton>
          <GnButton
            v-if="noteDirty"
            variant="primary"
            size="sm"
            @click="noteDraft = null"
          >
            Отменить
          </GnButton>
        </div>
      </GnCard>
    </div>

    <!-- История -->
    <div class="history-block">
      <div class="period-row">
        <span class="period-label">История:</span>
        <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>

      <GnTabs
        v-model="activeTab"
        :items="[
          { id: 'overview', label: 'Обзор', icon: 'ph-squares-four' },
          { id: 'disks', label: 'Диски', icon: 'ph-hard-drives' },
          { id: 'net', label: 'Сеть', icon: 'ph-arrows-down-up' },
          { id: 'processes', label: 'Процессы', icon: 'ph-list-dashes' },
          { id: 'docker', label: 'Docker', icon: 'ph-cube' },
        ]"
      >
        <template #overview>
          <div class="charts-grid">
            <GnCard title="CPU и RAM, %">
              <ChartLine :labels="labels" :datasets="cpuRamDatasets" y-max="100" y-unit="%" />
            </GnCard>
            <GnCard title="Заполненность дисков, %">
              <ChartLine :labels="labels" :datasets="diskDatasets" y-max="100" y-unit="%" />
            </GnCard>
          </div>
        </template>

        <template #disks>
          <GnTable
            :columns="diskColumns"
            :rows="metrics?.disks || []"
            empty-text="Дисков в последнем пакете нет"
          >
            <template #cell-total="{ value }">{{ fmtGiB(value) }}</template>
            <template #cell-used="{ value }">{{ fmtGiB(value) }}</template>
            <template #cell-percent="{ value }">
              <GnProgress
                :value="value"
                :variant="value >= 90 ? 'danger' : value >= 75 ? 'warning' : 'secondary'"
              />
            </template>
          </GnTable>
        </template>

        <template #net>
          <div class="net-chart">
            <GnCard title="Скорость сети, МБ/с">
              <ChartLine :labels="labels" :datasets="netDatasets" y-unit=" МБ/с" :height="280" />
            </GnCard>
          </div>
        </template>

        <template #processes>
          <GnTable
            :columns="processColumns"
            :rows="metrics?.processes || []"
            empty-text="Данных о процессах нет"
          />
        </template>

        <template #docker>
          <GnTable
            :columns="dockerColumns"
            :rows="metrics?.docker || []"
            empty-text="Docker-контейнеры не найдены"
          >
            <template #cell-status="{ value }">
              <GnBadge :variant="dockerVariant(value)">{{ value }}</GnBadge>
            </template>
          </GnTable>
        </template>
      </GnTabs>
    </div>

    <!-- Переименование -->
    <GnModal v-model:open="renameOpen" title="Переименовать сервер">
      <GnInput
        v-model="nameDraft"
        label="Имя сервера"
        icon="ph-desktop-tower"
        @keydown.enter="rename"
      />
      <template #actions="{ close }">
        <GnButton variant="primary" @click="close">Отмена</GnButton>
        <GnButton variant="secondary" :loading="renameSaving" @click="rename">Сохранить</GnButton>
      </template>
    </GnModal>

    <!-- Удаление -->
    <GnConfirmDialog
      v-model:open="deleteOpen"
      title="Удалить сервер?"
      :message="`«${server.name}» и вся его история метрик будут удалены безвозвратно.`"
      confirm-text="Удалить"
      cancel-text="Отмена"
      confirm-variant="danger"
      @confirm="removeServer"
    />
  </template>
</template>

<style scoped>
.back-row {
  margin-bottom: 16px;
}
.metric-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  gap: 16px;
  margin-top: 20px;
}
.info-grid {
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
  gap: 20px;
  margin-top: 20px;
}
.info-grid :deep(.note-actions) {
  display: flex;
  gap: 10px;
  margin-top: 12px;
}
.history-block {
  margin-top: 24px;
}
.period-row {
  display: flex;
  gap: 8px;
  align-items: center;
  margin-bottom: 14px;
}
.period-label {
  opacity: 0.7;
  margin-right: 6px;
  font-size: 14px;
}
.charts-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
  gap: 20px;
  margin-top: 16px;
}
/* .card в ките — max-width:340px + width:max-content; для графиков это
   схлопывает canvas. Растягиваем карточки с ChartLine на всю ширину. */
.charts-grid :deep(.card),
.net-chart :deep(.card) {
  max-width: none;
  width: 100%;
}
@media (max-width: 800px) {
  .info-grid {
    grid-template-columns: 1fr;
  }
}
</style>