Newer
Older
hard-panel / panel / frontend / src / pages / StoragePage.vue
<script setup>
import { computed, onUnmounted, ref } from 'vue'
import {
  GnBadge, GnButton, GnCard, GnConfirmDialog, GnEmptyState, GnInput,
  GnModal, GnPageHeader, GnProgress, 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 { fmtGiB } from '../format.js'
import { t } from '../i18n/index.js'

const statusText = (status) => t('status.' + (status || 'waiting'))

const toast = useToast()

const shares = ref([])
const samples = ref({}) // share_id -> [{ts, percent}]
const loading = ref(true)

// --- Загрузка + поллинг (пробер пишет точки раз в GHARD_SHARE_INTERVAL) ------

let pollTimer = null
const HISTORY_HOURS = 24

async function load() {
  try {
    shares.value = await api.shares()
    await Promise.all(shares.value.map((sh) => loadSamples(sh.id)))
    loading.value = false
  } catch (e) {
    toast.danger({ title: t('common.failedToLoad'), text: e.message })
    loading.value = false
  }
}

async function loadSamples(shareId) {
  try {
    const since = new Date(Date.now() - HISTORY_HOURS * 3600 * 1000)
    const raw = await api.shareSamples(shareId, since, 1440)
    samples.value = {
      ...samples.value,
      [shareId]: raw.map((p) => ({
        ts: p.ts,
        percent: p.ok && p.total ? (p.used / p.total) * 100 : null,
      })),
    }
  } catch (e) {
    toast.warning({ title: t('storage.historyFailed'), text: e.message })
  }
}

load()
pollTimer = setInterval(load, 60000)
onUnmounted(() => clearInterval(pollTimer))

// --- Данные для карточек -----------------------------------------------------

function sharePercent(share) {
  if (!share.total) return null
  return (share.used / share.total) * 100
}

function usageVariant(value) {
  if (value === null || value === undefined) return 'secondary'
  if (value >= 90) return 'danger'
  if (value >= 75) return 'warning'
  return 'secondary'
}

const statusVariant = (status) =>
  status === 'online' ? 'success' : status === 'offline' ? 'danger' : 'warning'

// --- Общий график заполненности ---------------------------------------------

const chartDatasets = computed(() =>
  shares.value.map((sh, i) => ({
    label: sh.name,
    data: (samples.value[sh.id] || []).map((p) => p.percent),
    color: CHART_COLORS[i % CHART_COLORS.length],
  })),
)

const chartLabels = computed(() => {
  const longest = shares.value.reduce(
    (acc, sh) => ((samples.value[sh.id] || []).length > acc.length ? samples.value[sh.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 newPath = ref('')
const creating = ref(false)

function openAdd() {
  newName.value = ''
  newPath.value = ''
  addOpen.value = true
}

async function create() {
  if (!newName.value.trim() || !newPath.value.trim() || creating.value) return
  creating.value = true
  try {
    await api.createShare(newName.value.trim(), newPath.value.trim())
    addOpen.value = false
    await load()
    toast.success({ title: t('storage.shareAdded') })
  } 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(share) {
  deleteTarget.value = share
  deleteOpen.value = true
}

async function removeShare() {
  try {
    await api.deleteShare(deleteTarget.value.id)
    toast.success({ title: t('storage.shareDeleted') })
    await load()
  } catch (e) {
    toast.danger({ title: t('server.failedToDelete'), text: e.message })
  } finally {
    deleteOpen.value = false
  }
}
</script>

<template>
  <GnPageHeader :title="t('storage.title')" :subtitle="t('storage.subtitle')" kicker="GHard Monitor">
    <template #actions>
      <GnButton variant="secondary" icon="ph-plus" @click="openAdd">{{ t('storage.addShare') }}</GnButton>
    </template>
  </GnPageHeader>

  <div v-if="loading">
    <GnSkeleton type="card" :count="3" />
  </div>

  <GnEmptyState
    v-else-if="!shares.length"
    :title="t('storage.emptyTitle')"
    :text="t('storage.emptyText')"
    icon="ph-hard-drives"
  >
    <template #actions>
      <GnButton variant="secondary" icon="ph-plus" @click="openAdd">{{ t('storage.addShare') }}</GnButton>
    </template>
  </GnEmptyState>

  <template v-else>
    <div class="shares-grid">
      <GnCard v-for="share in shares" :key="share.id" class="share-card">
        <template #title>
          <span class="share-title">
            <span class="share-name">{{ share.name }}</span>
            <GnBadge :variant="statusVariant(share.status)">
              {{ statusText(share.status) }}
            </GnBadge>
          </span>
        </template>

        <code class="share-path">{{ share.path }}</code>

        <GnProgress
          v-if="sharePercent(share) !== null"
          :label="t('storage.usedLabel')"
          :value="sharePercent(share)"
          :variant="usageVariant(sharePercent(share))"
        />
        <div class="share-nodata" v-else>{{ t('storage.nodata') }}</div>

        <div class="share-nums" v-if="share.total">
          <span>{{ fmtGiB(share.used) }} {{ t('storage.used') }}</span>
          <span>{{ fmtGiB(share.total - share.used) }} {{ t('storage.free') }}</span>
          <span>{{ fmtGiB(share.total) }} {{ t('storage.total') }}</span>
        </div>

        <template #footer>
          <div class="share-footer">
            <span class="share-seen">{{
              share.last_sample
                ? t('storage.sampledAt', { time: new Date(share.last_sample).toLocaleTimeString() })
                : t('storage.notSampled')
            }}</span>
            <GnButton size="sm" variant="danger" icon="ph-trash" @click="openDelete(share)">{{ t('common.delete') }}</GnButton>
          </div>
        </template>
      </GnCard>
    </div>

    <GnCard :title="t('storage.historyTitle')" class="history-card">
      <ChartLine :labels="chartLabels" :datasets="chartDatasets" y-max="100" y-unit="%" />
    </GnCard>
  </template>

  <!-- Добавление -->
  <GnModal v-model:open="addOpen" :title="t('storage.addTitle')">
    <GnInput v-model="newName" :label="t('storage.name')" icon="ph-hard-drives" placeholder="media-nas" />
    <GnInput
      v-model="newPath"
      :label="t('storage.mountPath')"
      icon="ph-folder-simple"
      placeholder="/mnt/media-nas"
    />
    <template #actions="{ close }">
      <GnButton variant="primary" @click="close">{{ t('common.cancel') }}</GnButton>
      <GnButton variant="secondary" :loading="creating" :disabled="!newName.trim() || !newPath.trim()" @click="create">
        {{ t('common.add') }}
      </GnButton>
    </template>
  </GnModal>

  <!-- Удаление -->
  <GnConfirmDialog
    v-model:open="deleteOpen"
    :title="t('storage.deleteTitle')"
    :message="t('storage.deleteMessage', { name: deleteTarget?.name })"
    :confirm-text="t('common.delete')"
    :cancel-text="t('common.cancel')"
    confirm-variant="danger"
    @confirm="removeShare"
  />
</template>

<style scoped>
.shares-grid {
  display: grid;
  /* по 2 диска в ряд на десктопе и планшете, по 1 на мобиле */
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 20px;
  margin-top: 20px;
}
@media (max-width: 767px) {
  .shares-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}
.share-card {
  width: 100%;
  max-width: none;
}
.share-title {
  display: flex;
  align-items: center;
  gap: 10px;
}
.share-name {
  overflow: hidden;
  text-overflow: ellipsis;
}
.share-path {
  display: block;
  font-size: 13px;
  opacity: 0.75;
  margin-bottom: 14px;
  word-break: break-all;
}
.share-nodata {
  opacity: 0.6;
  font-size: 14px;
  padding: 8px 0;
}
.share-nums {
  display: flex;
  gap: 14px;
  flex-wrap: wrap;
  margin-top: 12px;
  font-size: 13px;
  opacity: 0.85;
}
.share-footer {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
}
.share-seen {
  opacity: 0.6;
  font-size: 13px;
}
.history-card {
  max-width: none;
  width: 100%;
  margin-top: 24px;
}
</style>