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'

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: 'Failed to load', 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: 'Storage history failed to load', 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: 'Share added' })
  } catch (e) {
    toast.danger({ title: '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: 'Share deleted' })
    await load()
  } catch (e) {
    toast.danger({ title: 'Failed to delete', text: e.message })
  } finally {
    deleteOpen.value = false
  }
}
</script>

<template>
  <GnPageHeader title="Storage" subtitle="Network shares mounted to the panel host" kicker="GHard Monitor">
    <template #actions>
      <GnButton variant="secondary" icon="ph-plus" @click="openAdd">Add share</GnButton>
    </template>
  </GnPageHeader>

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

  <GnEmptyState
    v-else-if="!shares.length"
    title="No shares yet"
    text="Mount a network drive to the panel host and add its path here — the panel will track usage itself"
    icon="ph-hard-drives"
  >
    <template #actions>
      <GnButton variant="secondary" icon="ph-plus" @click="openAdd">Add share</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)">
              {{ share.status === 'online' ? 'online' : share.status === 'offline' ? 'offline' : 'waiting' }}
            </GnBadge>
          </span>
        </template>

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

        <GnProgress
          v-if="sharePercent(share) !== null"
          label="Used"
          :value="sharePercent(share)"
          :variant="usageVariant(sharePercent(share))"
        />
        <div class="share-nodata" v-else>no data — check the mount</div>

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

        <template #footer>
          <div class="share-footer">
            <span class="share-seen">{{
              share.last_sample ? 'sampled: ' + new Date(share.last_sample).toLocaleTimeString() : 'not sampled yet'
            }}</span>
            <GnButton size="sm" variant="danger" icon="ph-trash" @click="openDelete(share)">Delete</GnButton>
          </div>
        </template>
      </GnCard>
    </div>

    <GnCard title="Usage over 24 h, %" class="history-card">
      <ChartLine :labels="chartLabels" :datasets="chartDatasets" y-max="100" y-unit="%" />
    </GnCard>
  </template>

  <!-- Добавление -->
  <GnModal v-model:open="addOpen" title="Add network share">
    <GnInput v-model="newName" label="Name" icon="ph-hard-drives" placeholder="media-nas" />
    <GnInput
      v-model="newPath"
      label="Mount path on the panel host"
      icon="ph-folder-simple"
      placeholder="/mnt/media-nas"
    />
    <template #actions="{ close }">
      <GnButton variant="primary" @click="close">Cancel</GnButton>
      <GnButton variant="secondary" :loading="creating" :disabled="!newName.trim() || !newPath.trim()" @click="create">
        Add
      </GnButton>
    </template>
  </GnModal>

  <!-- Удаление -->
  <GnConfirmDialog
    v-model:open="deleteOpen"
    title="Delete share?"
    :message="`&quot;${deleteTarget?.name}&quot; will be removed from monitoring. The mount itself and its data are not touched.`"
    confirm-text="Delete"
    cancel-text="Cancel"
    confirm-variant="danger"
    @confirm="removeShare"
  />
</template>

<style scoped>
.shares-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(330px, 420px));
  gap: 20px;
  margin-top: 20px;
}
.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>