mirror of
https://github.com/Crosstalk-Solutions/project-nomad.git
synced 2026-04-04 15:56:16 +02:00
feat: Add storage projection bar to easy setup wizard
Adds a dynamic storage projection bar that shows users how their selections will impact disk space: - Displays current disk usage and projected usage after installation - Updates in real-time as users select maps, ZIM collections, and tiers - Color-coded warnings (green→tan→orange→red) based on projected usage - Shows "exceeds available space" warning if selections exceed capacity - Works on both Linux (disk array) and Windows (fsSize array) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c03f2ae702
commit
7bf3f25c47
122
admin/inertia/components/StorageProjectionBar.tsx
Normal file
122
admin/inertia/components/StorageProjectionBar.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import classNames from '~/lib/classNames'
|
||||||
|
import { formatBytes } from '~/lib/util'
|
||||||
|
import { IconAlertTriangle, IconServer } from '@tabler/icons-react'
|
||||||
|
|
||||||
|
interface StorageProjectionBarProps {
|
||||||
|
totalSize: number // Total disk size in bytes
|
||||||
|
currentUsed: number // Currently used space in bytes
|
||||||
|
projectedAddition: number // Additional space that will be used in bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StorageProjectionBar({
|
||||||
|
totalSize,
|
||||||
|
currentUsed,
|
||||||
|
projectedAddition,
|
||||||
|
}: StorageProjectionBarProps) {
|
||||||
|
const projectedTotal = currentUsed + projectedAddition
|
||||||
|
const currentPercent = (currentUsed / totalSize) * 100
|
||||||
|
const projectedPercent = (projectedAddition / totalSize) * 100
|
||||||
|
const projectedTotalPercent = (projectedTotal / totalSize) * 100
|
||||||
|
const remainingAfter = totalSize - projectedTotal
|
||||||
|
const willExceed = projectedTotal > totalSize
|
||||||
|
|
||||||
|
// Determine warning level based on projected total
|
||||||
|
const getProjectedColor = () => {
|
||||||
|
if (willExceed) return 'bg-desert-red'
|
||||||
|
if (projectedTotalPercent >= 90) return 'bg-desert-orange'
|
||||||
|
if (projectedTotalPercent >= 75) return 'bg-desert-tan'
|
||||||
|
return 'bg-desert-olive'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getProjectedGlow = () => {
|
||||||
|
if (willExceed) return 'shadow-desert-red/50'
|
||||||
|
if (projectedTotalPercent >= 90) return 'shadow-desert-orange/50'
|
||||||
|
if (projectedTotalPercent >= 75) return 'shadow-desert-tan/50'
|
||||||
|
return 'shadow-desert-olive/50'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-desert-stone-lighter/30 rounded-lg p-4 border border-desert-stone-light">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconServer size={20} className="text-desert-green" />
|
||||||
|
<span className="font-semibold text-desert-green">Storage</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-desert-stone-dark font-mono">
|
||||||
|
{formatBytes(projectedTotal, 1)} / {formatBytes(totalSize, 1)}
|
||||||
|
{projectedAddition > 0 && (
|
||||||
|
<span className="text-desert-stone ml-2">
|
||||||
|
(+{formatBytes(projectedAddition, 1)} selected)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<div className="h-8 bg-desert-green-lighter/20 rounded-lg border border-desert-stone-light overflow-hidden">
|
||||||
|
{/* Current usage - darker/subdued */}
|
||||||
|
<div
|
||||||
|
className="absolute h-full bg-desert-stone transition-all duration-300"
|
||||||
|
style={{ width: `${Math.min(currentPercent, 100)}%` }}
|
||||||
|
/>
|
||||||
|
{/* Projected addition - highlighted */}
|
||||||
|
{projectedAddition > 0 && (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'absolute h-full transition-all duration-300 shadow-lg',
|
||||||
|
getProjectedColor(),
|
||||||
|
getProjectedGlow()
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `${Math.min(currentPercent, 100)}%`,
|
||||||
|
width: `${Math.min(projectedPercent, 100 - currentPercent)}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Percentage label */}
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
'absolute top-1/2 -translate-y-1/2 font-bold text-sm',
|
||||||
|
projectedTotalPercent > 15
|
||||||
|
? 'left-3 text-desert-white drop-shadow-md'
|
||||||
|
: 'right-3 text-desert-green'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Math.round(projectedTotalPercent)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend and warnings */}
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className="w-3 h-3 rounded bg-desert-stone" />
|
||||||
|
<span className="text-desert-stone-dark">Current ({formatBytes(currentUsed, 1)})</span>
|
||||||
|
</div>
|
||||||
|
{projectedAddition > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<div className={classNames('w-3 h-3 rounded', getProjectedColor())} />
|
||||||
|
<span className="text-desert-stone-dark">
|
||||||
|
Selected (+{formatBytes(projectedAddition, 1)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{willExceed ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-desert-red text-xs font-medium">
|
||||||
|
<IconAlertTriangle size={14} />
|
||||||
|
<span>Exceeds available space by {formatBytes(projectedTotal - totalSize, 1)}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-desert-stone">
|
||||||
|
{formatBytes(remainingAfter, 1)} will remain free
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Head, router } from '@inertiajs/react'
|
import { Head, router } from '@inertiajs/react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import AppLayout from '~/layouts/AppLayout'
|
import AppLayout from '~/layouts/AppLayout'
|
||||||
import StyledButton from '~/components/StyledButton'
|
import StyledButton from '~/components/StyledButton'
|
||||||
import api from '~/lib/api'
|
import api from '~/lib/api'
|
||||||
|
|
@ -10,9 +10,11 @@ import CategoryCard from '~/components/CategoryCard'
|
||||||
import TierSelectionModal from '~/components/TierSelectionModal'
|
import TierSelectionModal from '~/components/TierSelectionModal'
|
||||||
import LoadingSpinner from '~/components/LoadingSpinner'
|
import LoadingSpinner from '~/components/LoadingSpinner'
|
||||||
import Alert from '~/components/Alert'
|
import Alert from '~/components/Alert'
|
||||||
|
import StorageProjectionBar from '~/components/StorageProjectionBar'
|
||||||
import { IconCheck } from '@tabler/icons-react'
|
import { IconCheck } from '@tabler/icons-react'
|
||||||
import { useNotifications } from '~/context/NotificationContext'
|
import { useNotifications } from '~/context/NotificationContext'
|
||||||
import useInternetStatus from '~/hooks/useInternetStatus'
|
import useInternetStatus from '~/hooks/useInternetStatus'
|
||||||
|
import { useSystemInfo } from '~/hooks/useSystemInfo'
|
||||||
import classNames from 'classnames'
|
import classNames from 'classnames'
|
||||||
import { CuratedCategory, CategoryTier, CategoryResource } from '../../../types/downloads'
|
import { CuratedCategory, CategoryTier, CategoryResource } from '../../../types/downloads'
|
||||||
|
|
||||||
|
|
@ -51,6 +53,7 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
const { addNotification } = useNotifications()
|
const { addNotification } = useNotifications()
|
||||||
const { isOnline } = useInternetStatus()
|
const { isOnline } = useInternetStatus()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const { data: systemInfo } = useSystemInfo({ enabled: true })
|
||||||
|
|
||||||
const anySelectionMade =
|
const anySelectionMade =
|
||||||
selectedServices.length > 0 ||
|
selectedServices.length > 0 ||
|
||||||
|
|
@ -144,6 +147,47 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
return resources
|
return resources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calculate total projected storage from all selections
|
||||||
|
const projectedStorageBytes = useMemo(() => {
|
||||||
|
let totalBytes = 0
|
||||||
|
|
||||||
|
// Add tier resources
|
||||||
|
const tierResources = getSelectedTierResources()
|
||||||
|
totalBytes += tierResources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0)
|
||||||
|
|
||||||
|
// Add map collections
|
||||||
|
if (mapCollections) {
|
||||||
|
selectedMapCollections.forEach((slug) => {
|
||||||
|
const collection = mapCollections.find((c) => c.slug === slug)
|
||||||
|
if (collection) {
|
||||||
|
totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ZIM collections
|
||||||
|
if (zimCollections) {
|
||||||
|
selectedZimCollections.forEach((slug) => {
|
||||||
|
const collection = zimCollections.find((c) => c.slug === slug)
|
||||||
|
if (collection) {
|
||||||
|
totalBytes += collection.resources.reduce((sum, r) => sum + r.size_mb * 1024 * 1024, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return totalBytes
|
||||||
|
}, [selectedTiers, selectedMapCollections, selectedZimCollections, categories, mapCollections, zimCollections])
|
||||||
|
|
||||||
|
// Get primary disk/filesystem info for storage projection
|
||||||
|
// Try disk array first (Linux/production), fall back to fsSize (Windows/dev)
|
||||||
|
const primaryDisk = systemInfo?.disk?.[0]
|
||||||
|
const primaryFs = systemInfo?.fsSize?.[0]
|
||||||
|
const storageInfo = primaryDisk
|
||||||
|
? { totalSize: primaryDisk.totalSize, totalUsed: primaryDisk.totalUsed }
|
||||||
|
: primaryFs
|
||||||
|
? { totalSize: primaryFs.size, totalUsed: primaryFs.used }
|
||||||
|
: null
|
||||||
|
|
||||||
const canProceedToNextStep = () => {
|
const canProceedToNextStep = () => {
|
||||||
if (!isOnline) return false // Must be online to proceed
|
if (!isOnline) return false // Must be online to proceed
|
||||||
if (currentStep === 1) return true // Can skip app installation
|
if (currentStep === 1) return true // Can skip app installation
|
||||||
|
|
@ -657,6 +701,15 @@ export default function EasySetupWizard(props: { system: { services: ServiceSlim
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
<div className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<div className="bg-white rounded-md shadow-md">
|
<div className="bg-white rounded-md shadow-md">
|
||||||
{renderStepIndicator()}
|
{renderStepIndicator()}
|
||||||
|
{storageInfo && (
|
||||||
|
<div className="px-6 pt-4">
|
||||||
|
<StorageProjectionBar
|
||||||
|
totalSize={storageInfo.totalSize}
|
||||||
|
currentUsed={storageInfo.totalUsed}
|
||||||
|
projectedAddition={projectedStorageBytes}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="p-6 min-h-fit">
|
<div className="p-6 min-h-fit">
|
||||||
{currentStep === 1 && renderStep1()}
|
{currentStep === 1 && renderStep1()}
|
||||||
{currentStep === 2 && renderStep2()}
|
{currentStep === 2 && renderStep2()}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user