616 lines
22 KiB
TypeScript
616 lines
22 KiB
TypeScript
import { useForm, Controller } from 'react-hook-form';
|
|
import { useEffect, useRef } from 'react';
|
|
import { useInstanceConfig } from '../../hooks/useInstances';
|
|
import { useNodes } from '../../hooks/useNodes';
|
|
import type { HardwareInfo } from '../../services/api/types';
|
|
import { Input, Label, Button } from '../ui';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '../ui/select';
|
|
|
|
export interface NodeFormData {
|
|
hostname: string;
|
|
role: 'controlplane' | 'worker';
|
|
disk: string;
|
|
targetIp: string;
|
|
interface?: string;
|
|
schematicId?: string;
|
|
maintenance: boolean;
|
|
}
|
|
|
|
interface NodeFormProps {
|
|
initialValues?: Partial<NodeFormData>;
|
|
detection?: HardwareInfo;
|
|
onSubmit: (data: NodeFormData) => Promise<void>;
|
|
onApply?: (data: NodeFormData) => Promise<void>;
|
|
onCancel?: () => void;
|
|
submitLabel?: string;
|
|
showApplyButton?: boolean;
|
|
instanceName?: string;
|
|
}
|
|
|
|
function getInitialValues(
|
|
initial?: Partial<NodeFormData>,
|
|
detection?: HardwareInfo,
|
|
nodes?: Array<{ role: string; hostname?: string }>,
|
|
hostnamePrefix?: string
|
|
): NodeFormData {
|
|
// Determine default role: controlplane unless there are already 3+ control nodes
|
|
let defaultRole: 'controlplane' | 'worker' = 'controlplane';
|
|
if (nodes) {
|
|
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
|
if (controlPlaneCount >= 3) {
|
|
defaultRole = 'worker';
|
|
}
|
|
}
|
|
|
|
const role = initial?.role || defaultRole;
|
|
|
|
// Generate default hostname based on role and existing nodes
|
|
let defaultHostname = '';
|
|
if (!initial?.hostname) {
|
|
const prefix = hostnamePrefix || '';
|
|
|
|
// Generate a hostname even if nodes is not loaded yet
|
|
// The useEffect will fix it later when data is available
|
|
if (role === 'controlplane') {
|
|
if (nodes) {
|
|
// Find next control plane number
|
|
const controlNumbers = nodes
|
|
.filter(n => n.role === 'controlplane')
|
|
.map(n => {
|
|
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
|
return match ? parseInt(match[1], 10) : null;
|
|
})
|
|
.filter((n): n is number => n !== null);
|
|
|
|
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
|
defaultHostname = `${prefix}control-${nextNumber}`;
|
|
} else {
|
|
// No nodes loaded yet, default to 1
|
|
defaultHostname = `${prefix}control-1`;
|
|
}
|
|
} else {
|
|
if (nodes) {
|
|
// Find next worker number
|
|
const workerNumbers = nodes
|
|
.filter(n => n.role === 'worker')
|
|
.map(n => {
|
|
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
|
return match ? parseInt(match[1], 10) : null;
|
|
})
|
|
.filter((n): n is number => n !== null);
|
|
|
|
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
|
defaultHostname = `${prefix}worker-${nextNumber}`;
|
|
} else {
|
|
// No nodes loaded yet, default to 1
|
|
defaultHostname = `${prefix}worker-1`;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Auto-select first disk if none specified
|
|
let defaultDisk = initial?.disk || detection?.selected_disk || '';
|
|
if (!defaultDisk && detection?.disks && detection.disks.length > 0) {
|
|
defaultDisk = detection.disks[0].path;
|
|
}
|
|
|
|
// Auto-select first interface if none specified
|
|
let defaultInterface = initial?.interface || detection?.interface || '';
|
|
if (!defaultInterface && detection?.interfaces && detection.interfaces.length > 0) {
|
|
defaultInterface = detection.interfaces[0];
|
|
}
|
|
|
|
return {
|
|
hostname: initial?.hostname || defaultHostname,
|
|
role,
|
|
disk: defaultDisk,
|
|
targetIp: initial?.targetIp || detection?.ip || '', // Auto-fill from detection
|
|
interface: defaultInterface,
|
|
schematicId: initial?.schematicId || '',
|
|
maintenance: initial?.maintenance ?? true,
|
|
};
|
|
}
|
|
|
|
export function NodeForm({
|
|
initialValues,
|
|
detection,
|
|
onSubmit,
|
|
onApply,
|
|
onCancel,
|
|
submitLabel = 'Save',
|
|
showApplyButton = false,
|
|
instanceName,
|
|
}: NodeFormProps) {
|
|
// Track if we're editing an existing node (has initial hostname from backend)
|
|
const isExistingNode = Boolean(initialValues?.hostname);
|
|
const { config: instanceConfig } = useInstanceConfig(instanceName);
|
|
const { nodes } = useNodes(instanceName);
|
|
|
|
const hostnamePrefix = instanceConfig?.cluster?.hostnamePrefix || '';
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
setValue,
|
|
watch,
|
|
control,
|
|
reset,
|
|
formState: { errors, isSubmitting },
|
|
} = useForm<NodeFormData>({
|
|
defaultValues: getInitialValues(initialValues, detection, nodes, hostnamePrefix),
|
|
});
|
|
|
|
const schematicId = watch('schematicId');
|
|
const role = watch('role');
|
|
const hostname = watch('hostname');
|
|
|
|
// Reset form when switching between different nodes in configure mode
|
|
// This ensures select boxes and all fields show the current values
|
|
// Use refs to track both the hostname and mode to avoid unnecessary resets
|
|
const prevHostnameRef = useRef<string | undefined>(undefined);
|
|
const prevModeRef = useRef<'add' | 'configure' | undefined>(undefined);
|
|
|
|
useEffect(() => {
|
|
const currentHostname = initialValues?.hostname;
|
|
const currentMode = initialValues?.hostname ? 'configure' : 'add';
|
|
|
|
// Only reset if we're actually switching between different nodes in configure mode
|
|
// or switching from add to configure mode (or vice versa)
|
|
const modeChanged = currentMode !== prevModeRef.current;
|
|
const hostnameChanged = currentMode === 'configure' && currentHostname !== prevHostnameRef.current;
|
|
|
|
if (modeChanged || hostnameChanged) {
|
|
prevHostnameRef.current = currentHostname;
|
|
prevModeRef.current = currentMode;
|
|
const newValues = getInitialValues(initialValues, detection, nodes, hostnamePrefix);
|
|
reset(newValues);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [initialValues, detection, nodes, hostnamePrefix]);
|
|
|
|
// Set default role based on existing control plane nodes
|
|
useEffect(() => {
|
|
if (!initialValues?.role && nodes) {
|
|
const controlPlaneCount = nodes.filter(n => n.role === 'controlplane').length;
|
|
const defaultRole: 'controlplane' | 'worker' = controlPlaneCount >= 3 ? 'worker' : 'controlplane';
|
|
const currentRole = watch('role');
|
|
|
|
// Only update if the current role is still the initial default and we now have node data
|
|
if (currentRole === 'controlplane' && controlPlaneCount >= 3) {
|
|
setValue('role', defaultRole);
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [nodes, initialValues?.role]);
|
|
|
|
// Pre-populate schematic ID from cluster config if available
|
|
useEffect(() => {
|
|
if (!schematicId && instanceConfig?.cluster?.nodes?.talos?.schematicId) {
|
|
setValue('schematicId', instanceConfig.cluster.nodes.talos.schematicId);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [instanceConfig, schematicId]);
|
|
|
|
// Auto-generate hostname when role changes (only for NEW nodes without initial hostname)
|
|
useEffect(() => {
|
|
if (!nodes) return;
|
|
|
|
// Don't auto-generate if this is an existing node with initial hostname
|
|
// This check must happen FIRST to prevent regeneration when hostnamePrefix loads
|
|
if (isExistingNode) return;
|
|
|
|
const prefix = hostnamePrefix || '';
|
|
const currentHostname = watch('hostname');
|
|
|
|
if (!currentHostname) return;
|
|
|
|
// Check if current hostname follows our naming pattern WITH prefix
|
|
const hostnameMatch = currentHostname.match(new RegExp(`^${prefix}(control|worker)-(\\d+)$`));
|
|
|
|
// If no match with prefix, check if it matches WITHOUT prefix (generated before prefix was loaded)
|
|
const hostnameMatchNoPrefix = !hostnameMatch && prefix ?
|
|
currentHostname.match(/^(control|worker)-(\d+)$/) : null;
|
|
|
|
// Check if this is a generated hostname (either with or without prefix)
|
|
const isGeneratedHostname = hostnameMatch !== null || hostnameMatchNoPrefix !== null;
|
|
|
|
// Use whichever match succeeded
|
|
const activeMatch = hostnameMatch || hostnameMatchNoPrefix;
|
|
|
|
// Check if the role prefix in the hostname matches the current role
|
|
const hostnameRolePrefix = activeMatch?.[1]; // 'control' or 'worker'
|
|
const expectedRolePrefix = role === 'controlplane' ? 'control' : 'worker';
|
|
const roleMatches = hostnameRolePrefix === expectedRolePrefix;
|
|
|
|
// Check if the hostname has the expected prefix
|
|
const hasCorrectPrefix = hostnameMatch !== null;
|
|
|
|
// Auto-update hostname if it was previously auto-generated AND either:
|
|
// 1. The role prefix doesn't match (e.g., hostname is "control-1" but role is "worker")
|
|
// 2. The hostname is missing the prefix (e.g., "control-1" instead of "test-control-1")
|
|
// 3. The number needs updating (existing logic)
|
|
if (isGeneratedHostname && (!roleMatches || !hasCorrectPrefix)) {
|
|
// Role changed, need to regenerate with correct prefix
|
|
if (role === 'controlplane') {
|
|
const controlNumbers = nodes
|
|
.filter(n => n.role === 'controlplane')
|
|
.map(n => {
|
|
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
|
return match ? parseInt(match[1], 10) : null;
|
|
})
|
|
.filter((n): n is number => n !== null);
|
|
|
|
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
|
const newHostname = `${prefix}control-${nextNumber}`;
|
|
setValue('hostname', newHostname);
|
|
} else {
|
|
const workerNumbers = nodes
|
|
.filter(n => n.role === 'worker')
|
|
.map(n => {
|
|
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
|
return match ? parseInt(match[1], 10) : null;
|
|
})
|
|
.filter((n): n is number => n !== null);
|
|
|
|
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
|
const newHostname = `${prefix}worker-${nextNumber}`;
|
|
setValue('hostname', newHostname);
|
|
}
|
|
} else if (isGeneratedHostname && roleMatches && hasCorrectPrefix) {
|
|
// Role matches and prefix is correct, but check if the number needs updating (original logic)
|
|
if (role === 'controlplane') {
|
|
const controlNumbers = nodes
|
|
.filter(n => n.role === 'controlplane')
|
|
.map(n => {
|
|
const match = n.hostname?.match(new RegExp(`${prefix}control-(\\d+)$`));
|
|
return match ? parseInt(match[1], 10) : null;
|
|
})
|
|
.filter((n): n is number => n !== null);
|
|
|
|
const nextNumber = controlNumbers.length > 0 ? Math.max(...controlNumbers) + 1 : 1;
|
|
const newHostname = `${prefix}control-${nextNumber}`;
|
|
if (currentHostname !== newHostname) {
|
|
setValue('hostname', newHostname);
|
|
}
|
|
} else {
|
|
const workerNumbers = nodes
|
|
.filter(n => n.role === 'worker')
|
|
.map(n => {
|
|
const match = n.hostname?.match(new RegExp(`${prefix}worker-(\\d+)$`));
|
|
return match ? parseInt(match[1], 10) : null;
|
|
})
|
|
.filter((n): n is number => n !== null);
|
|
|
|
const nextNumber = workerNumbers.length > 0 ? Math.max(...workerNumbers) + 1 : 1;
|
|
const newHostname = `${prefix}worker-${nextNumber}`;
|
|
if (currentHostname !== newHostname) {
|
|
setValue('hostname', newHostname);
|
|
}
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [role, nodes, hostnamePrefix, isExistingNode]);
|
|
|
|
// Auto-calculate target IP for control plane nodes
|
|
useEffect(() => {
|
|
// Skip if this is an existing node (configure mode)
|
|
if (initialValues?.targetIp) return;
|
|
|
|
// Skip if there's a detection IP (hardware detection provides the actual IP)
|
|
if (detection?.ip) return;
|
|
|
|
// Skip if there's already a targetIp from detection
|
|
const currentTargetIp = watch('targetIp');
|
|
if (currentTargetIp && role === 'worker') return; // For workers, keep any existing value
|
|
|
|
const clusterConfig = instanceConfig?.cluster as any;
|
|
const vip = clusterConfig?.nodes?.control?.vip as string | undefined;
|
|
|
|
if (role === 'controlplane' && vip) {
|
|
|
|
// Parse VIP to get base and last octet
|
|
const vipParts = vip.split('.');
|
|
if (vipParts.length !== 4) return;
|
|
|
|
const vipLastOctet = parseInt(vipParts[3], 10);
|
|
if (isNaN(vipLastOctet)) return;
|
|
|
|
const vipPrefix = vipParts.slice(0, 3).join('.');
|
|
|
|
// Find all control plane IPs in the same subnet range
|
|
const usedOctets = nodes
|
|
.filter(node => node.role === 'controlplane' && node.target_ip)
|
|
.map(node => {
|
|
const parts = node.target_ip.split('.');
|
|
if (parts.length !== 4) return null;
|
|
// Only consider IPs in the same subnet
|
|
if (parts.slice(0, 3).join('.') !== vipPrefix) return null;
|
|
const octet = parseInt(parts[3], 10);
|
|
return isNaN(octet) ? null : octet;
|
|
})
|
|
.filter((octet): octet is number => octet !== null && octet > vipLastOctet);
|
|
|
|
// Find the first available IP after VIP
|
|
let nextOctet = vipLastOctet + 1;
|
|
|
|
// Sort used octets to find gaps
|
|
const sortedOctets = [...usedOctets].sort((a, b) => a - b);
|
|
|
|
// Check for gaps in the sequence starting from VIP+1
|
|
for (const usedOctet of sortedOctets) {
|
|
if (usedOctet === nextOctet) {
|
|
nextOctet++;
|
|
} else if (usedOctet > nextOctet) {
|
|
// Found a gap, use it
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Ensure we don't exceed valid IP range
|
|
if (nextOctet > 254) {
|
|
console.warn('No available IPs in subnet after VIP');
|
|
return;
|
|
}
|
|
|
|
// Set the calculated IP
|
|
setValue('targetIp', `${vipPrefix}.${nextOctet}`);
|
|
} else if (role === 'worker' && !detection?.ip) {
|
|
// For worker nodes without detection, only clear if it looks like an auto-calculated control plane IP
|
|
const currentTargetIp = watch('targetIp');
|
|
// Only clear if it looks like an auto-calculated IP (matches VIP pattern)
|
|
if (currentTargetIp && vip) {
|
|
const vipPrefix = vip.split('.').slice(0, 3).join('.');
|
|
if (currentTargetIp.startsWith(vipPrefix)) {
|
|
setValue('targetIp', '');
|
|
}
|
|
}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [role, instanceConfig, nodes, initialValues?.targetIp, detection?.ip]);
|
|
|
|
// Build disk options from both detection and initial values
|
|
const diskOptions = (() => {
|
|
const options = [...(detection?.disks || [])];
|
|
// If configuring existing node, ensure its disk is in options
|
|
if (initialValues?.disk && !options.some(d => d.path === initialValues.disk)) {
|
|
options.push({ path: initialValues.disk, size: 0 });
|
|
}
|
|
return options;
|
|
})();
|
|
|
|
// Build interface options from both detection and initial values
|
|
const interfaceOptions = (() => {
|
|
const options = [...(detection?.interfaces || [])];
|
|
// If configuring existing node, ensure its interface is in options
|
|
if (initialValues?.interface && !options.includes(initialValues.interface)) {
|
|
options.push(initialValues.interface);
|
|
}
|
|
// Also add detection.interface if present
|
|
if (detection?.interface && !options.includes(detection.interface)) {
|
|
options.push(detection.interface);
|
|
}
|
|
return options;
|
|
})();
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
|
<div>
|
|
<Label htmlFor="role">Role</Label>
|
|
<Controller
|
|
name="role"
|
|
control={control}
|
|
rules={{ required: 'Role is required' }}
|
|
render={({ field }) => (
|
|
<Select value={field.value || ''} onValueChange={field.onChange}>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue placeholder="Select role" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="controlplane">Control Plane</SelectItem>
|
|
<SelectItem value="worker">Worker</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
)}
|
|
/>
|
|
{errors.role && <p className="text-sm text-red-600 mt-1">{errors.role.message}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="hostname">Hostname</Label>
|
|
<Input
|
|
id="hostname"
|
|
type="text"
|
|
{...register('hostname', {
|
|
required: 'Hostname is required',
|
|
pattern: {
|
|
value: /^[a-z0-9-]+$/,
|
|
message: 'Hostname must contain only lowercase letters, numbers, and hyphens',
|
|
},
|
|
})}
|
|
className="mt-1"
|
|
/>
|
|
{errors.hostname && (
|
|
<p className="text-sm text-red-600 mt-1">{errors.hostname.message}</p>
|
|
)}
|
|
{hostname && hostname.match(/^.*?(control|worker)-\d+$/) && (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Auto-generated based on role and existing nodes
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="disk">Disk</Label>
|
|
{diskOptions.length > 0 ? (
|
|
<Controller
|
|
name="disk"
|
|
control={control}
|
|
rules={{ required: 'Disk is required' }}
|
|
render={({ field }) => {
|
|
// Ensure we have a value - use the field value or fall back to first option
|
|
const value = field.value || (diskOptions.length > 0 ? diskOptions[0].path : '');
|
|
return (
|
|
<Select value={value} onValueChange={field.onChange}>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue placeholder="Select a disk" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{diskOptions.map((disk) => (
|
|
<SelectItem key={disk.path} value={disk.path}>
|
|
{disk.path}
|
|
{disk.size > 0 && ` (${formatBytes(disk.size)})`}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Controller
|
|
name="disk"
|
|
control={control}
|
|
rules={{ required: 'Disk is required' }}
|
|
render={({ field }) => (
|
|
<Input
|
|
id="disk"
|
|
type="text"
|
|
value={field.value || ''}
|
|
onChange={field.onChange}
|
|
className="mt-1"
|
|
placeholder="/dev/sda"
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
{errors.disk && <p className="text-sm text-red-600 mt-1">{errors.disk.message}</p>}
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="targetIp">Target IP Address</Label>
|
|
<Input
|
|
id="targetIp"
|
|
type="text"
|
|
{...register('targetIp')}
|
|
className="mt-1"
|
|
/>
|
|
{errors.targetIp && (
|
|
<p className="text-sm text-red-600 mt-1">{errors.targetIp.message}</p>
|
|
)}
|
|
{role === 'controlplane' && (instanceConfig?.cluster as any)?.nodes?.control?.vip && (
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Auto-calculated from VIP ({(instanceConfig?.cluster as any)?.nodes?.control?.vip})
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="interface">Network Interface</Label>
|
|
{interfaceOptions.length > 0 ? (
|
|
<Controller
|
|
name="interface"
|
|
control={control}
|
|
render={({ field }) => {
|
|
// Ensure we have a value - use the field value or fall back to first option
|
|
const value = field.value || (interfaceOptions.length > 0 ? interfaceOptions[0] : '');
|
|
return (
|
|
<Select value={value} onValueChange={field.onChange}>
|
|
<SelectTrigger className="mt-1">
|
|
<SelectValue placeholder="Select interface..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{interfaceOptions.map((iface) => (
|
|
<SelectItem key={iface} value={iface}>
|
|
{iface}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Controller
|
|
name="interface"
|
|
control={control}
|
|
render={({ field }) => (
|
|
<Input
|
|
id="interface"
|
|
type="text"
|
|
value={field.value || ''}
|
|
onChange={field.onChange}
|
|
className="mt-1"
|
|
placeholder="eth0"
|
|
/>
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<Label htmlFor="schematicId">Schematic ID (Optional)</Label>
|
|
<Input
|
|
id="schematicId"
|
|
type="text"
|
|
{...register('schematicId')}
|
|
className="mt-1 font-mono text-xs"
|
|
placeholder="abc123def456..."
|
|
/>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
Leave blank to use default Talos configuration
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{onCancel && (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
reset();
|
|
onCancel();
|
|
}}
|
|
disabled={isSubmitting}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
)}
|
|
{showApplyButton && onApply ? (
|
|
<Button
|
|
type="button"
|
|
onClick={handleSubmit(onApply)}
|
|
disabled={isSubmitting}
|
|
className="flex-1"
|
|
>
|
|
{isSubmitting ? 'Applying...' : 'Apply Configuration'}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
type="submit"
|
|
disabled={isSubmitting}
|
|
className="flex-1"
|
|
>
|
|
{isSubmitting ? 'Saving...' : submitLabel}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
|
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
|
}
|