<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>