Files
homarr/src/components/Unraid/Dashboard/ArrayCard.tsx
Kaloyan Danchev 83a8546521
Some checks failed
Master CI / yarn_install_and_build (push) Has been cancelled
Add Unraid API integration and Orchis theme
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>
2026-02-06 09:19:21 +02:00

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;