Some checks failed
Master CI / yarn_install_and_build (push) Has been cancelled
Phase 1: Foundation Setup - Create Unraid GraphQL client with type-safe queries/mutations - Add comprehensive TypeScript types for all Unraid data models - Implement tRPC router with 30+ endpoints for Unraid management - Add environment variables for Unraid connection Phase 2: Core Dashboard - Create SystemInfoCard component (CPU, RAM, OS, motherboard) - Create ArrayCard component (disks, parity, cache pools) - Create DockerCard component with start/stop controls - Create VmsCard component with power management - Add main Unraid dashboard page with real-time updates Phase 3: Orchis Theme Integration - Create Mantine theme override with Orchis design tokens - Add CSS custom properties for light/dark modes - Configure shadows, spacing, radius from Orchis specs - Style all Mantine components with Orchis patterns Files added: - src/lib/unraid/* (GraphQL client, types, queries) - src/server/api/routers/unraid/* (tRPC router) - src/components/Unraid/* (Dashboard components) - src/pages/unraid/* (Dashboard page) - src/styles/orchis/* (Theme configuration) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
307 lines
8.6 KiB
TypeScript
307 lines
8.6 KiB
TypeScript
/**
|
|
* Array Card Component
|
|
* Displays Unraid array status and disk information
|
|
*/
|
|
|
|
import {
|
|
ActionIcon,
|
|
Badge,
|
|
Card,
|
|
Group,
|
|
Progress,
|
|
Stack,
|
|
Table,
|
|
Text,
|
|
ThemeIcon,
|
|
Title,
|
|
Tooltip,
|
|
} from '@mantine/core';
|
|
import {
|
|
IconDatabase,
|
|
IconPlayerPlay,
|
|
IconPlayerStop,
|
|
IconTemperature,
|
|
IconHardDrive,
|
|
} from '@tabler/icons-react';
|
|
|
|
import type { UnraidArray, ArrayDisk, ArrayState } from '~/lib/unraid/types';
|
|
|
|
interface ArrayCardProps {
|
|
array: UnraidArray;
|
|
onStartArray?: () => void;
|
|
onStopArray?: () => void;
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function getStatusColor(status: string): string {
|
|
switch (status) {
|
|
case 'DISK_OK':
|
|
return 'green';
|
|
case 'DISK_INVALID':
|
|
case 'DISK_WRONG':
|
|
return 'red';
|
|
case 'DISK_DSBL':
|
|
case 'DISK_DSBL_NEW':
|
|
return 'orange';
|
|
case 'DISK_NEW':
|
|
return 'blue';
|
|
case 'DISK_NP':
|
|
return 'gray';
|
|
default:
|
|
return 'gray';
|
|
}
|
|
}
|
|
|
|
function getArrayStateColor(state: ArrayState): string {
|
|
switch (state) {
|
|
case 'STARTED':
|
|
return 'green';
|
|
case 'STOPPED':
|
|
return 'red';
|
|
default:
|
|
return 'orange';
|
|
}
|
|
}
|
|
|
|
function DiskRow({ disk }: { disk: ArrayDisk }) {
|
|
const usedPercent = disk.fsSize && disk.fsUsed
|
|
? ((disk.fsUsed / disk.fsSize) * 100).toFixed(1)
|
|
: null;
|
|
|
|
return (
|
|
<tr>
|
|
<td>
|
|
<Group spacing="xs">
|
|
<ThemeIcon size="sm" variant="light" color={disk.spunDown ? 'gray' : 'blue'}>
|
|
<IconHardDrive size={14} />
|
|
</ThemeIcon>
|
|
<Text size="sm" weight={500}>
|
|
{disk.name}
|
|
</Text>
|
|
</Group>
|
|
</td>
|
|
<td>
|
|
<Badge size="xs" color={getStatusColor(disk.status)} variant="light">
|
|
{disk.status.replace('DISK_', '')}
|
|
</Badge>
|
|
</td>
|
|
<td>
|
|
<Text size="sm">{formatBytes(disk.size)}</Text>
|
|
</td>
|
|
<td>
|
|
{disk.temp !== null ? (
|
|
<Group spacing={4}>
|
|
<IconTemperature size={14} />
|
|
<Text size="sm" color={disk.temp > 50 ? 'red' : disk.temp > 40 ? 'orange' : undefined}>
|
|
{disk.temp}°C
|
|
</Text>
|
|
</Group>
|
|
) : (
|
|
<Text size="sm" color="dimmed">
|
|
{disk.spunDown ? 'Spun down' : '-'}
|
|
</Text>
|
|
)}
|
|
</td>
|
|
<td style={{ width: 150 }}>
|
|
{usedPercent ? (
|
|
<Tooltip label={`${formatBytes(disk.fsUsed!)} / ${formatBytes(disk.fsSize!)}`}>
|
|
<Progress
|
|
value={parseFloat(usedPercent)}
|
|
size="sm"
|
|
radius="md"
|
|
color={parseFloat(usedPercent) > 90 ? 'red' : parseFloat(usedPercent) > 75 ? 'orange' : 'blue'}
|
|
/>
|
|
</Tooltip>
|
|
) : (
|
|
<Text size="sm" color="dimmed">-</Text>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
export function ArrayCard({ array, onStartArray, onStopArray, isLoading }: ArrayCardProps) {
|
|
const usedPercent = ((array.capacity.used / array.capacity.total) * 100).toFixed(1);
|
|
const isStarted = array.state === 'STARTED';
|
|
|
|
return (
|
|
<Card shadow="sm" radius="md" withBorder>
|
|
<Card.Section withBorder inheritPadding py="xs">
|
|
<Group position="apart">
|
|
<Group>
|
|
<ThemeIcon size="lg" radius="md" variant="light" color="blue">
|
|
<IconDatabase size={20} />
|
|
</ThemeIcon>
|
|
<div>
|
|
<Title order={4}>Array</Title>
|
|
<Text size="xs" color="dimmed">
|
|
{formatBytes(array.capacity.used)} / {formatBytes(array.capacity.total)}
|
|
</Text>
|
|
</div>
|
|
</Group>
|
|
<Group spacing="xs">
|
|
<Badge color={getArrayStateColor(array.state)} variant="filled">
|
|
{array.state}
|
|
</Badge>
|
|
{isStarted && onStopArray && (
|
|
<Tooltip label="Stop Array">
|
|
<ActionIcon
|
|
color="red"
|
|
variant="light"
|
|
onClick={onStopArray}
|
|
loading={isLoading}
|
|
>
|
|
<IconPlayerStop size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
{!isStarted && onStartArray && (
|
|
<Tooltip label="Start Array">
|
|
<ActionIcon
|
|
color="green"
|
|
variant="light"
|
|
onClick={onStartArray}
|
|
loading={isLoading}
|
|
>
|
|
<IconPlayerPlay size={16} />
|
|
</ActionIcon>
|
|
</Tooltip>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
</Card.Section>
|
|
|
|
<Stack spacing="md" mt="md">
|
|
{/* Array Capacity */}
|
|
<div>
|
|
<Group position="apart" mb={5}>
|
|
<Text size="sm" weight={500}>
|
|
Total Capacity
|
|
</Text>
|
|
<Text size="xs" color="dimmed">
|
|
{usedPercent}% used
|
|
</Text>
|
|
</Group>
|
|
<Progress
|
|
value={parseFloat(usedPercent)}
|
|
size="lg"
|
|
radius="md"
|
|
color={parseFloat(usedPercent) > 90 ? 'red' : parseFloat(usedPercent) > 75 ? 'orange' : 'blue'}
|
|
/>
|
|
</div>
|
|
|
|
{/* Parity Check Status */}
|
|
{array.parityCheckStatus?.running && (
|
|
<div>
|
|
<Group position="apart" mb={5}>
|
|
<Text size="sm" weight={500} color="orange">
|
|
Parity Check in Progress
|
|
</Text>
|
|
<Text size="xs" color="dimmed">
|
|
{array.parityCheckStatus.progress.toFixed(1)}% - {array.parityCheckStatus.errors} errors
|
|
</Text>
|
|
</Group>
|
|
<Progress
|
|
value={array.parityCheckStatus.progress}
|
|
size="md"
|
|
radius="md"
|
|
color="orange"
|
|
animate
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Parity Disks */}
|
|
{array.parities.length > 0 && (
|
|
<>
|
|
<Text size="sm" weight={600} mt="xs">
|
|
Parity ({array.parities.length})
|
|
</Text>
|
|
<Table fontSize="sm" verticalSpacing={4}>
|
|
<thead>
|
|
<tr>
|
|
<th>Disk</th>
|
|
<th>Status</th>
|
|
<th>Size</th>
|
|
<th>Temp</th>
|
|
<th>Usage</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{array.parities.map((disk) => (
|
|
<DiskRow key={disk.id} disk={disk} />
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
</>
|
|
)}
|
|
|
|
{/* Data Disks */}
|
|
{array.disks.length > 0 && (
|
|
<>
|
|
<Text size="sm" weight={600} mt="xs">
|
|
Data Disks ({array.disks.length})
|
|
</Text>
|
|
<Table fontSize="sm" verticalSpacing={4}>
|
|
<thead>
|
|
<tr>
|
|
<th>Disk</th>
|
|
<th>Status</th>
|
|
<th>Size</th>
|
|
<th>Temp</th>
|
|
<th>Usage</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{array.disks.map((disk) => (
|
|
<DiskRow key={disk.id} disk={disk} />
|
|
))}
|
|
</tbody>
|
|
</Table>
|
|
</>
|
|
)}
|
|
|
|
{/* Cache Pools */}
|
|
{array.caches.length > 0 && (
|
|
<>
|
|
<Text size="sm" weight={600} mt="xs">
|
|
Cache Pools ({array.caches.length})
|
|
</Text>
|
|
{array.caches.map((cache) => {
|
|
const cacheUsedPercent = ((cache.fsUsed / cache.fsSize) * 100).toFixed(1);
|
|
return (
|
|
<div key={cache.id}>
|
|
<Group position="apart" mb={5}>
|
|
<Text size="sm" weight={500}>
|
|
{cache.name} ({cache.fsType})
|
|
</Text>
|
|
<Text size="xs" color="dimmed">
|
|
{formatBytes(cache.fsUsed)} / {formatBytes(cache.fsSize)}
|
|
</Text>
|
|
</Group>
|
|
<Progress
|
|
value={parseFloat(cacheUsedPercent)}
|
|
size="sm"
|
|
radius="md"
|
|
color={parseFloat(cacheUsedPercent) > 90 ? 'red' : parseFloat(cacheUsedPercent) > 75 ? 'orange' : 'teal'}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</Stack>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default ArrayCard;
|