<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="`"${deleteTarget?.name}" 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>