Add Unraid API integration and Orchis theme
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>
This commit is contained in:
Kaloyan Danchev
2026-02-06 09:19:21 +02:00
parent e881ec6cb5
commit 83a8546521
19 changed files with 4431 additions and 1 deletions

View File

@@ -0,0 +1,306 @@
/**
* 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;

View File

@@ -0,0 +1,205 @@
/**
* Docker Card Component
* Displays Docker containers with start/stop controls
*/
import {
ActionIcon,
Badge,
Card,
Group,
Menu,
ScrollArea,
Stack,
Text,
ThemeIcon,
Title,
Tooltip,
} from '@mantine/core';
import {
IconBrandDocker,
IconPlayerPlay,
IconPlayerStop,
IconDots,
IconBox,
} from '@tabler/icons-react';
import type { Docker, DockerContainer, ContainerState } from '~/lib/unraid/types';
interface DockerCardProps {
docker: Docker;
onStartContainer?: (id: string) => void;
onStopContainer?: (id: string) => void;
loadingContainers?: string[];
}
function getStateColor(state: ContainerState): string {
switch (state) {
case 'RUNNING':
return 'green';
case 'EXITED':
return 'red';
case 'PAUSED':
return 'yellow';
case 'RESTARTING':
return 'orange';
case 'CREATED':
return 'blue';
case 'DEAD':
return 'gray';
default:
return 'gray';
}
}
function ContainerRow({
container,
onStart,
onStop,
isLoading,
}: {
container: DockerContainer;
onStart?: () => void;
onStop?: () => void;
isLoading?: boolean;
}) {
const isRunning = container.state === 'RUNNING';
const containerName = container.names[0]?.replace(/^\//, '') || 'Unknown';
return (
<Group position="apart" py="xs" sx={(theme) => ({
borderBottom: `1px solid ${
theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]
}`,
'&:last-child': {
borderBottom: 'none',
},
})}>
<Group spacing="sm">
<ThemeIcon size="md" variant="light" color={getStateColor(container.state)}>
<IconBox size={16} />
</ThemeIcon>
<div>
<Text size="sm" weight={500} lineClamp={1}>
{containerName}
</Text>
<Text size="xs" color="dimmed" lineClamp={1}>
{container.image}
</Text>
</div>
</Group>
<Group spacing="xs">
<Badge size="xs" color={getStateColor(container.state)} variant="light">
{container.state}
</Badge>
{isRunning && onStop && (
<Tooltip label="Stop">
<ActionIcon
color="red"
variant="subtle"
size="sm"
onClick={onStop}
loading={isLoading}
>
<IconPlayerStop size={14} />
</ActionIcon>
</Tooltip>
)}
{!isRunning && onStart && (
<Tooltip label="Start">
<ActionIcon
color="green"
variant="subtle"
size="sm"
onClick={onStart}
loading={isLoading}
>
<IconPlayerPlay size={14} />
</ActionIcon>
</Tooltip>
)}
<Menu shadow="md" width={150} position="bottom-end">
<Menu.Target>
<ActionIcon variant="subtle" size="sm">
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item disabled>View Logs</Menu.Item>
<Menu.Item disabled>Edit</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" disabled>
Remove
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
);
}
export function DockerCard({
docker,
onStartContainer,
onStopContainer,
loadingContainers = [],
}: DockerCardProps) {
const runningCount = docker.containers.filter((c) => c.state === 'RUNNING').length;
const totalCount = docker.containers.length;
// Sort containers: running first, then by name
const sortedContainers = [...docker.containers].sort((a, b) => {
if (a.state === 'RUNNING' && b.state !== 'RUNNING') return -1;
if (a.state !== 'RUNNING' && b.state === 'RUNNING') return 1;
return (a.names[0] || '').localeCompare(b.names[0] || '');
});
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="cyan">
<IconBrandDocker size={20} />
</ThemeIcon>
<div>
<Title order={4}>Docker</Title>
<Text size="xs" color="dimmed">
{runningCount} running / {totalCount} total
</Text>
</div>
</Group>
<Badge color="cyan" variant="light">
{docker.networks.length} networks
</Badge>
</Group>
</Card.Section>
<ScrollArea.Autosize maxHeight={400} mt="xs">
<Stack spacing={0}>
{sortedContainers.length === 0 ? (
<Text size="sm" color="dimmed" align="center" py="lg">
No containers
</Text>
) : (
sortedContainers.map((container) => (
<ContainerRow
key={container.id}
container={container}
onStart={onStartContainer ? () => onStartContainer(container.id) : undefined}
onStop={onStopContainer ? () => onStopContainer(container.id) : undefined}
isLoading={loadingContainers.includes(container.id)}
/>
))
)}
</Stack>
</ScrollArea.Autosize>
</Card>
);
}
export default DockerCard;

View File

@@ -0,0 +1,192 @@
/**
* System Info Card Component
* Displays Unraid system information in the dashboard
*/
import {
Badge,
Card,
Group,
Progress,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from '@mantine/core';
import {
IconCpu,
IconDeviceDesktop,
IconServer,
IconClock,
IconBrandUbuntu,
} from '@tabler/icons-react';
import type { SystemInfo, ServerVars, Registration } from '~/lib/unraid/types';
interface SystemInfoCardProps {
info: SystemInfo;
vars: ServerVars;
registration: Registration;
}
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days}d ${hours}h ${minutes}m`;
}
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
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];
}
export function SystemInfoCard({ info, vars, registration }: SystemInfoCardProps) {
const memoryUsedPercent = ((info.memory.used / info.memory.total) * 100).toFixed(1);
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">
<IconServer size={20} />
</ThemeIcon>
<div>
<Title order={4}>{vars.name}</Title>
<Text size="xs" color="dimmed">
{vars.description}
</Text>
</div>
</Group>
<Badge
color={registration.type === 'Pro' ? 'green' : registration.type === 'Plus' ? 'blue' : 'gray'}
variant="filled"
>
{registration.type}
</Badge>
</Group>
</Card.Section>
<Stack spacing="md" mt="md">
{/* CPU Info */}
<Group position="apart" noWrap>
<Group spacing="xs">
<ThemeIcon size="sm" radius="md" variant="light" color="violet">
<IconCpu size={14} />
</ThemeIcon>
<Text size="sm" weight={500}>
CPU
</Text>
</Group>
<Text size="sm" color="dimmed" align="right" style={{ flex: 1 }}>
{info.cpu.brand}
</Text>
</Group>
<SimpleGrid cols={3} spacing="xs">
<div>
<Text size="xs" color="dimmed">
Cores
</Text>
<Text size="sm" weight={500}>
{info.cpu.cores}
</Text>
</div>
<div>
<Text size="xs" color="dimmed">
Threads
</Text>
<Text size="sm" weight={500}>
{info.cpu.threads}
</Text>
</div>
<div>
<Text size="xs" color="dimmed">
Speed
</Text>
<Text size="sm" weight={500}>
{info.cpu.speed.toFixed(2)} GHz
</Text>
</div>
</SimpleGrid>
{/* Memory Info */}
<div>
<Group position="apart" mb={5}>
<Text size="sm" weight={500}>
Memory
</Text>
<Text size="xs" color="dimmed">
{formatBytes(info.memory.used)} / {formatBytes(info.memory.total)} ({memoryUsedPercent}%)
</Text>
</Group>
<Progress
value={parseFloat(memoryUsedPercent)}
size="md"
radius="md"
color={parseFloat(memoryUsedPercent) > 80 ? 'red' : parseFloat(memoryUsedPercent) > 60 ? 'yellow' : 'blue'}
/>
</div>
{/* OS Info */}
<Group position="apart" noWrap>
<Group spacing="xs">
<ThemeIcon size="sm" radius="md" variant="light" color="green">
<IconBrandUbuntu size={14} />
</ThemeIcon>
<Text size="sm" weight={500}>
OS
</Text>
</Group>
<Text size="sm" color="dimmed">
Unraid {info.versions.unraid}
</Text>
</Group>
{/* Motherboard */}
<Group position="apart" noWrap>
<Group spacing="xs">
<ThemeIcon size="sm" radius="md" variant="light" color="orange">
<IconDeviceDesktop size={14} />
</ThemeIcon>
<Text size="sm" weight={500}>
Motherboard
</Text>
</Group>
<Text size="sm" color="dimmed" lineClamp={1}>
{info.baseboard.manufacturer} {info.baseboard.model}
</Text>
</Group>
{/* Uptime */}
<Group position="apart" noWrap>
<Group spacing="xs">
<ThemeIcon size="sm" radius="md" variant="light" color="teal">
<IconClock size={14} />
</ThemeIcon>
<Text size="sm" weight={500}>
Uptime
</Text>
</Group>
<Text size="sm" color="dimmed">
{formatUptime(info.os.uptime)}
</Text>
</Group>
</Stack>
</Card>
);
}
export default SystemInfoCard;

View File

@@ -0,0 +1,277 @@
/**
* VMs Card Component
* Displays Virtual Machines with power controls
*/
import {
ActionIcon,
Badge,
Card,
Group,
Menu,
ScrollArea,
Stack,
Text,
ThemeIcon,
Title,
Tooltip,
} from '@mantine/core';
import {
IconServer2,
IconPlayerPlay,
IconPlayerStop,
IconPlayerPause,
IconRefresh,
IconDots,
} from '@tabler/icons-react';
import type { VirtualMachine, VmState } from '~/lib/unraid/types';
interface VmsCardProps {
vms: VirtualMachine[];
onStartVm?: (id: string) => void;
onStopVm?: (id: string) => void;
onPauseVm?: (id: string) => void;
onResumeVm?: (id: string) => void;
onRebootVm?: (id: string) => void;
loadingVms?: string[];
}
function getStateColor(state: VmState): string {
switch (state) {
case 'RUNNING':
return 'green';
case 'SHUTOFF':
return 'red';
case 'PAUSED':
case 'PMSUSPENDED':
return 'yellow';
case 'SHUTDOWN':
return 'orange';
case 'IDLE':
return 'blue';
case 'CRASHED':
return 'red';
case 'NOSTATE':
default:
return 'gray';
}
}
function formatMemory(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];
}
function VmRow({
vm,
onStart,
onStop,
onPause,
onResume,
onReboot,
isLoading,
}: {
vm: VirtualMachine;
onStart?: () => void;
onStop?: () => void;
onPause?: () => void;
onResume?: () => void;
onReboot?: () => void;
isLoading?: boolean;
}) {
const isRunning = vm.state === 'RUNNING';
const isPaused = vm.state === 'PAUSED' || vm.state === 'PMSUSPENDED';
const isStopped = vm.state === 'SHUTOFF' || vm.state === 'SHUTDOWN' || vm.state === 'NOSTATE';
return (
<Group
position="apart"
py="xs"
sx={(theme) => ({
borderBottom: `1px solid ${
theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]
}`,
'&:last-child': {
borderBottom: 'none',
},
})}
>
<Group spacing="sm">
<ThemeIcon size="md" variant="light" color={getStateColor(vm.state)}>
<IconServer2 size={16} />
</ThemeIcon>
<div>
<Text size="sm" weight={500} lineClamp={1}>
{vm.name}
</Text>
<Group spacing={4}>
<Text size="xs" color="dimmed">
{vm.cpus} vCPU
</Text>
<Text size="xs" color="dimmed">
</Text>
<Text size="xs" color="dimmed">
{formatMemory(vm.memory)}
</Text>
</Group>
</div>
</Group>
<Group spacing="xs">
<Badge size="xs" color={getStateColor(vm.state)} variant="light">
{vm.state}
</Badge>
{isRunning && onPause && (
<Tooltip label="Pause">
<ActionIcon
color="yellow"
variant="subtle"
size="sm"
onClick={onPause}
loading={isLoading}
>
<IconPlayerPause size={14} />
</ActionIcon>
</Tooltip>
)}
{isPaused && onResume && (
<Tooltip label="Resume">
<ActionIcon
color="green"
variant="subtle"
size="sm"
onClick={onResume}
loading={isLoading}
>
<IconPlayerPlay size={14} />
</ActionIcon>
</Tooltip>
)}
{isRunning && onStop && (
<Tooltip label="Stop">
<ActionIcon
color="red"
variant="subtle"
size="sm"
onClick={onStop}
loading={isLoading}
>
<IconPlayerStop size={14} />
</ActionIcon>
</Tooltip>
)}
{isStopped && onStart && (
<Tooltip label="Start">
<ActionIcon
color="green"
variant="subtle"
size="sm"
onClick={onStart}
loading={isLoading}
>
<IconPlayerPlay size={14} />
</ActionIcon>
</Tooltip>
)}
<Menu shadow="md" width={150} position="bottom-end">
<Menu.Target>
<ActionIcon variant="subtle" size="sm">
<IconDots size={14} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
{isRunning && onReboot && (
<Menu.Item
icon={<IconRefresh size={14} />}
onClick={onReboot}
>
Reboot
</Menu.Item>
)}
<Menu.Item disabled>VNC Console</Menu.Item>
<Menu.Item disabled>Edit</Menu.Item>
<Menu.Divider />
<Menu.Item color="red" disabled>
Force Stop
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
);
}
export function VmsCard({
vms,
onStartVm,
onStopVm,
onPauseVm,
onResumeVm,
onRebootVm,
loadingVms = [],
}: VmsCardProps) {
const runningCount = vms.filter((vm) => vm.state === 'RUNNING').length;
const totalCount = vms.length;
// Sort VMs: running first, then by name
const sortedVms = [...vms].sort((a, b) => {
if (a.state === 'RUNNING' && b.state !== 'RUNNING') return -1;
if (a.state !== 'RUNNING' && b.state === 'RUNNING') return 1;
return a.name.localeCompare(b.name);
});
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="violet">
<IconServer2 size={20} />
</ThemeIcon>
<div>
<Title order={4}>Virtual Machines</Title>
<Text size="xs" color="dimmed">
{runningCount} running / {totalCount} total
</Text>
</div>
</Group>
</Group>
</Card.Section>
<ScrollArea.Autosize maxHeight={400} mt="xs">
<Stack spacing={0}>
{sortedVms.length === 0 ? (
<Text size="sm" color="dimmed" align="center" py="lg">
No virtual machines
</Text>
) : (
sortedVms.map((vm) => (
<VmRow
key={vm.id}
vm={vm}
onStart={onStartVm ? () => onStartVm(vm.id) : undefined}
onStop={onStopVm ? () => onStopVm(vm.id) : undefined}
onPause={onPauseVm ? () => onPauseVm(vm.id) : undefined}
onResume={onResumeVm ? () => onResumeVm(vm.id) : undefined}
onReboot={onRebootVm ? () => onRebootVm(vm.id) : undefined}
isLoading={loadingVms.includes(vm.id)}
/>
))
)}
</Stack>
</ScrollArea.Autosize>
</Card>
);
}
export default VmsCard;

View File

@@ -0,0 +1,8 @@
/**
* Unraid Dashboard Components
*/
export { SystemInfoCard } from './SystemInfoCard';
export { ArrayCard } from './ArrayCard';
export { DockerCard } from './DockerCard';
export { VmsCard } from './VmsCard';

View File

@@ -40,6 +40,12 @@ const env = createEnv({
DOCKER_HOST: z.string().optional(),
DOCKER_PORT: portSchema,
DEMO_MODE: z.string().optional(),
// Unraid API
UNRAID_HOST: z.string().optional(),
UNRAID_API_KEY: z.string().optional(),
UNRAID_USE_SSL: zodParsedBoolean().default('false'),
UNRAID_PORT: portSchema,
DISABLE_UPGRADE_MODAL: zodParsedBoolean().default('false'),
HOSTNAME: z.string().optional(),
@@ -167,6 +173,11 @@ const env = createEnv({
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
DEMO_MODE: process.env.DEMO_MODE,
DISABLE_UPGRADE_MODAL: process.env.DISABLE_UPGRADE_MODAL,
// Unraid API
UNRAID_HOST: process.env.UNRAID_HOST,
UNRAID_API_KEY: process.env.UNRAID_API_KEY,
UNRAID_USE_SSL: process.env.UNRAID_USE_SSL,
UNRAID_PORT: process.env.UNRAID_PORT,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
});

450
src/lib/unraid/client.ts Normal file
View File

@@ -0,0 +1,450 @@
/**
* Unraid GraphQL Client
* Provides type-safe access to the Unraid API
*/
import axios, { AxiosInstance } from 'axios';
import type {
Customization,
Disk,
Docker,
Flash,
Network,
Notification,
Plugin,
Registration,
Server,
ServerVars,
Service,
Share,
SystemInfo,
UnraidApiResponse,
UnraidArray,
UpsDevice,
VirtualMachine,
} from './types';
import {
ARRAY_QUERY,
CUSTOMIZATION_QUERY,
DASHBOARD_QUERY,
DISK_QUERY,
DISKS_QUERY,
DOCKER_QUERY,
FLASH_QUERY,
INFO_QUERY,
NETWORK_QUERY,
NOTIFICATIONS_QUERY,
PLUGINS_QUERY,
REGISTRATION_QUERY,
SERVER_QUERY,
SERVICES_QUERY,
SHARES_QUERY,
UPS_DEVICES_QUERY,
VARS_QUERY,
VMS_QUERY,
} from './queries';
import {
ARRAY_SET_STATE_MUTATION,
DOCKER_START_MUTATION,
DOCKER_STOP_MUTATION,
NOTIFICATION_ARCHIVE_MUTATION,
NOTIFICATION_DELETE_MUTATION,
PARITY_CHECK_CANCEL_MUTATION,
PARITY_CHECK_PAUSE_MUTATION,
PARITY_CHECK_RESUME_MUTATION,
PARITY_CHECK_START_MUTATION,
VM_FORCE_STOP_MUTATION,
VM_PAUSE_MUTATION,
VM_REBOOT_MUTATION,
VM_RESUME_MUTATION,
VM_START_MUTATION,
VM_STOP_MUTATION,
} from './queries/mutations';
export interface UnraidClientConfig {
host: string;
apiKey: string;
useSsl?: boolean;
port?: number;
}
export interface DashboardData {
info: SystemInfo;
vars: ServerVars;
registration: Registration;
array: UnraidArray;
docker: Docker;
vms: VirtualMachine[];
shares: Share[];
services: Service[];
notifications: Notification[];
}
export class UnraidClient {
private client: AxiosInstance;
private config: UnraidClientConfig;
constructor(config: UnraidClientConfig) {
this.config = config;
const protocol = config.useSsl ? 'https' : 'http';
const port = config.port || (config.useSsl ? 443 : 80);
const baseURL = `${protocol}://${config.host}:${port}`;
this.client = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
'x-api-key': config.apiKey,
},
timeout: 30000,
});
}
/**
* Execute a GraphQL query
*/
private async query<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const response = await this.client.post<UnraidApiResponse<T>>('/graphql', {
query,
variables,
});
if (response.data.errors?.length) {
throw new Error(response.data.errors.map((e) => e.message).join(', '));
}
return response.data.data;
}
/**
* Execute a GraphQL mutation
*/
private async mutate<T>(mutation: string, variables?: Record<string, unknown>): Promise<T> {
return this.query<T>(mutation, variables);
}
// ============================================================================
// SYSTEM QUERIES
// ============================================================================
async getInfo(): Promise<SystemInfo> {
const data = await this.query<{ info: SystemInfo }>(INFO_QUERY);
return data.info;
}
async getVars(): Promise<ServerVars> {
const data = await this.query<{ vars: ServerVars }>(VARS_QUERY);
return data.vars;
}
async getServer(): Promise<Server> {
const data = await this.query<{ server: Server }>(SERVER_QUERY);
return data.server;
}
async getRegistration(): Promise<Registration> {
const data = await this.query<{ registration: Registration }>(REGISTRATION_QUERY);
return data.registration;
}
async getFlash(): Promise<Flash> {
const data = await this.query<{ flash: Flash }>(FLASH_QUERY);
return data.flash;
}
// ============================================================================
// ARRAY QUERIES
// ============================================================================
async getArray(): Promise<UnraidArray> {
const data = await this.query<{ array: UnraidArray }>(ARRAY_QUERY);
return data.array;
}
async getDisks(): Promise<Disk[]> {
const data = await this.query<{ disks: Disk[] }>(DISKS_QUERY);
return data.disks;
}
async getDisk(id: string): Promise<Disk> {
const data = await this.query<{ disk: Disk }>(DISK_QUERY, { id });
return data.disk;
}
// ============================================================================
// DOCKER QUERIES
// ============================================================================
async getDocker(): Promise<Docker> {
const data = await this.query<{ docker: Docker }>(DOCKER_QUERY);
return data.docker;
}
// ============================================================================
// VM QUERIES
// ============================================================================
async getVms(): Promise<VirtualMachine[]> {
const data = await this.query<{ vms: VirtualMachine[] }>(VMS_QUERY);
return data.vms;
}
// ============================================================================
// SHARES QUERIES
// ============================================================================
async getShares(): Promise<Share[]> {
const data = await this.query<{ shares: Share[] }>(SHARES_QUERY);
return data.shares;
}
// ============================================================================
// NOTIFICATIONS QUERIES
// ============================================================================
async getNotifications(): Promise<Notification[]> {
const data = await this.query<{ notifications: Notification[] }>(NOTIFICATIONS_QUERY);
return data.notifications;
}
// ============================================================================
// SERVICES QUERIES
// ============================================================================
async getServices(): Promise<Service[]> {
const data = await this.query<{ services: Service[] }>(SERVICES_QUERY);
return data.services;
}
// ============================================================================
// NETWORK QUERIES
// ============================================================================
async getNetwork(): Promise<Network> {
const data = await this.query<{ network: Network }>(NETWORK_QUERY);
return data.network;
}
// ============================================================================
// UPS QUERIES
// ============================================================================
async getUpsDevices(): Promise<UpsDevice[]> {
const data = await this.query<{ upsDevices: UpsDevice[] }>(UPS_DEVICES_QUERY);
return data.upsDevices;
}
// ============================================================================
// PLUGINS QUERIES
// ============================================================================
async getPlugins(): Promise<Plugin[]> {
const data = await this.query<{ plugins: Plugin[] }>(PLUGINS_QUERY);
return data.plugins;
}
// ============================================================================
// CUSTOMIZATION QUERIES
// ============================================================================
async getCustomization(): Promise<Customization> {
const data = await this.query<{ customization: Customization }>(CUSTOMIZATION_QUERY);
return data.customization;
}
// ============================================================================
// DASHBOARD (COMPOSITE QUERY)
// ============================================================================
async getDashboard(): Promise<DashboardData> {
const data = await this.query<DashboardData>(DASHBOARD_QUERY);
return data;
}
// ============================================================================
// ARRAY MUTATIONS
// ============================================================================
async startArray(): Promise<{ state: string }> {
const data = await this.mutate<{ array: { setState: { state: string } } }>(
ARRAY_SET_STATE_MUTATION,
{ state: 'start' }
);
return data.array.setState;
}
async stopArray(): Promise<{ state: string }> {
const data = await this.mutate<{ array: { setState: { state: string } } }>(
ARRAY_SET_STATE_MUTATION,
{ state: 'stop' }
);
return data.array.setState;
}
// ============================================================================
// PARITY CHECK MUTATIONS
// ============================================================================
async startParityCheck(correct = false): Promise<{ running: boolean; progress: number }> {
const data = await this.mutate<{
parityCheck: { start: { running: boolean; progress: number } };
}>(PARITY_CHECK_START_MUTATION, { correct });
return data.parityCheck.start;
}
async pauseParityCheck(): Promise<{ running: boolean; progress: number }> {
const data = await this.mutate<{
parityCheck: { pause: { running: boolean; progress: number } };
}>(PARITY_CHECK_PAUSE_MUTATION);
return data.parityCheck.pause;
}
async resumeParityCheck(): Promise<{ running: boolean; progress: number }> {
const data = await this.mutate<{
parityCheck: { resume: { running: boolean; progress: number } };
}>(PARITY_CHECK_RESUME_MUTATION);
return data.parityCheck.resume;
}
async cancelParityCheck(): Promise<{ running: boolean }> {
const data = await this.mutate<{ parityCheck: { cancel: { running: boolean } } }>(
PARITY_CHECK_CANCEL_MUTATION
);
return data.parityCheck.cancel;
}
// ============================================================================
// DOCKER MUTATIONS
// ============================================================================
async startContainer(id: string): Promise<{ id: string; state: string; status: string }> {
const data = await this.mutate<{
docker: { start: { id: string; state: string; status: string } };
}>(DOCKER_START_MUTATION, { id });
return data.docker.start;
}
async stopContainer(id: string): Promise<{ id: string; state: string; status: string }> {
const data = await this.mutate<{
docker: { stop: { id: string; state: string; status: string } };
}>(DOCKER_STOP_MUTATION, { id });
return data.docker.stop;
}
// ============================================================================
// VM MUTATIONS
// ============================================================================
async startVm(id: string): Promise<{ id: string; state: string }> {
const data = await this.mutate<{ vm: { start: { id: string; state: string } } }>(
VM_START_MUTATION,
{ id }
);
return data.vm.start;
}
async stopVm(id: string): Promise<{ id: string; state: string }> {
const data = await this.mutate<{ vm: { stop: { id: string; state: string } } }>(
VM_STOP_MUTATION,
{ id }
);
return data.vm.stop;
}
async pauseVm(id: string): Promise<{ id: string; state: string }> {
const data = await this.mutate<{ vm: { pause: { id: string; state: string } } }>(
VM_PAUSE_MUTATION,
{ id }
);
return data.vm.pause;
}
async resumeVm(id: string): Promise<{ id: string; state: string }> {
const data = await this.mutate<{ vm: { resume: { id: string; state: string } } }>(
VM_RESUME_MUTATION,
{ id }
);
return data.vm.resume;
}
async forceStopVm(id: string): Promise<{ id: string; state: string }> {
const data = await this.mutate<{ vm: { forceStop: { id: string; state: string } } }>(
VM_FORCE_STOP_MUTATION,
{ id }
);
return data.vm.forceStop;
}
async rebootVm(id: string): Promise<{ id: string; state: string }> {
const data = await this.mutate<{ vm: { reboot: { id: string; state: string } } }>(
VM_REBOOT_MUTATION,
{ id }
);
return data.vm.reboot;
}
// ============================================================================
// NOTIFICATION MUTATIONS
// ============================================================================
async deleteNotification(id: string): Promise<boolean> {
const data = await this.mutate<{ notification: { delete: boolean } }>(
NOTIFICATION_DELETE_MUTATION,
{ id }
);
return data.notification.delete;
}
async archiveNotification(id: string): Promise<{ id: string; archived: boolean }> {
const data = await this.mutate<{
notification: { archive: { id: string; archived: boolean } };
}>(NOTIFICATION_ARCHIVE_MUTATION, { id });
return data.notification.archive;
}
// ============================================================================
// HEALTH CHECK
// ============================================================================
async healthCheck(): Promise<boolean> {
try {
await this.getVars();
return true;
} catch {
return false;
}
}
}
// ============================================================================
// SINGLETON INSTANCE
// ============================================================================
let unraidClient: UnraidClient | null = null;
export function getUnraidClient(): UnraidClient {
if (!unraidClient) {
const host = process.env.UNRAID_HOST;
const apiKey = process.env.UNRAID_API_KEY;
if (!host || !apiKey) {
throw new Error('UNRAID_HOST and UNRAID_API_KEY environment variables are required');
}
unraidClient = new UnraidClient({
host,
apiKey,
useSsl: process.env.UNRAID_USE_SSL === 'true',
port: process.env.UNRAID_PORT ? parseInt(process.env.UNRAID_PORT, 10) : undefined,
});
}
return unraidClient;
}
export function createUnraidClient(config: UnraidClientConfig): UnraidClient {
return new UnraidClient(config);
}

19
src/lib/unraid/index.ts Normal file
View File

@@ -0,0 +1,19 @@
/**
* Unraid API Integration
* ======================
* Type-safe client for the Unraid GraphQL API
*
* Usage:
* ```typescript
* import { getUnraidClient } from '~/lib/unraid';
*
* const client = getUnraidClient();
* const dashboard = await client.getDashboard();
* ```
*/
export * from './client';
export * from './types';
export * from './queries';
export * from './queries/mutations';
export * from './queries/subscriptions';

View File

@@ -0,0 +1,536 @@
/**
* Unraid GraphQL Queries
* Based on Unraid API v4.29.2
*/
// ============================================================================
// SYSTEM QUERIES
// ============================================================================
export const INFO_QUERY = `
query Info {
info {
cpu {
manufacturer
brand
cores
threads
speed
speedMax
cache {
l1d
l1i
l2
l3
}
}
memory {
total
free
used
active
available
buffers
cached
slab
swapTotal
swapUsed
swapFree
}
os {
platform
distro
release
kernel
arch
hostname
uptime
}
baseboard {
manufacturer
model
version
serial
}
versions {
unraid
api
}
}
}
`;
export const VARS_QUERY = `
query Vars {
vars {
version
name
timezone
description
model
protocol
port
localTld
csrf
uptime
}
}
`;
export const SERVER_QUERY = `
query Server {
server {
owner
guid
wanip
lanip
localurl
remoteurl
}
}
`;
export const REGISTRATION_QUERY = `
query Registration {
registration {
type
state
keyFile
expiration
}
}
`;
export const FLASH_QUERY = `
query Flash {
flash {
guid
vendor
product
}
}
`;
// ============================================================================
// ARRAY QUERIES
// ============================================================================
export const ARRAY_QUERY = `
query Array {
array {
state
capacity {
total
used
free
disks {
total
used
free
}
}
disks {
id
name
device
size
status
type
temp
numReads
numWrites
numErrors
fsType
fsFree
fsUsed
fsSize
color
spunDown
transport
rotational
serial
model
}
parities {
id
name
device
size
status
type
temp
numReads
numWrites
numErrors
color
spunDown
transport
rotational
serial
model
}
caches {
id
name
fsType
fsFree
fsUsed
fsSize
devices {
id
name
device
size
status
temp
spunDown
serial
model
}
}
parityCheckStatus {
running
progress
errors
elapsed
eta
speed
mode
}
}
}
`;
export const DISKS_QUERY = `
query Disks {
disks {
id
name
device
size
vendor
model
serial
firmware
type
interfaceType
rotational
temp
smartStatus
}
}
`;
export const DISK_QUERY = `
query Disk($id: String!) {
disk(id: $id) {
id
name
device
size
vendor
model
serial
firmware
type
interfaceType
rotational
temp
smartStatus
}
}
`;
// ============================================================================
// DOCKER QUERIES
// ============================================================================
export const DOCKER_QUERY = `
query Docker {
docker {
containers {
id
names
image
state
status
created
ports {
privatePort
publicPort
type
ip
}
autoStart
networkMode
}
networks {
id
name
driver
scope
}
}
}
`;
// ============================================================================
// VM QUERIES
// ============================================================================
export const VMS_QUERY = `
query Vms {
vms {
id
name
state
uuid
description
cpus
memory
autoStart
icon
}
}
`;
// ============================================================================
// SHARES QUERIES
// ============================================================================
export const SHARES_QUERY = `
query Shares {
shares {
name
comment
free
used
size
include
exclude
cache
color
floor
splitLevel
allocator
export
security
}
}
`;
// ============================================================================
// NOTIFICATIONS QUERIES
// ============================================================================
export const NOTIFICATIONS_QUERY = `
query Notifications {
notifications {
id
title
subject
description
importance
type
timestamp
read
archived
}
}
`;
// ============================================================================
// SERVICES QUERIES
// ============================================================================
export const SERVICES_QUERY = `
query Services {
services {
name
online
uptime
version
}
}
`;
// ============================================================================
// NETWORK QUERIES
// ============================================================================
export const NETWORK_QUERY = `
query Network {
network {
accessUrls {
type
name
ipv4
ipv6
url
}
}
}
`;
// ============================================================================
// UPS QUERIES
// ============================================================================
export const UPS_DEVICES_QUERY = `
query UpsDevices {
upsDevices {
id
name
model
status
batteryCharge
batteryRuntime
load
inputVoltage
outputVoltage
}
}
`;
// ============================================================================
// PLUGINS QUERIES
// ============================================================================
export const PLUGINS_QUERY = `
query Plugins {
plugins {
name
version
author
url
icon
updateAvailable
}
}
`;
// ============================================================================
// CUSTOMIZATION QUERIES
// ============================================================================
export const CUSTOMIZATION_QUERY = `
query Customization {
customization {
theme
}
}
`;
// ============================================================================
// DASHBOARD COMPOSITE QUERY
// ============================================================================
export const DASHBOARD_QUERY = `
query Dashboard {
info {
cpu {
manufacturer
brand
cores
threads
speed
}
memory {
total
free
used
available
}
os {
hostname
uptime
kernel
}
baseboard {
manufacturer
model
}
versions {
unraid
api
}
}
vars {
name
version
description
}
registration {
type
state
}
array {
state
capacity {
total
used
free
}
disks {
id
name
status
temp
size
fsUsed
fsFree
spunDown
}
parities {
id
name
status
temp
size
}
caches {
id
name
fsUsed
fsFree
fsSize
}
parityCheckStatus {
running
progress
errors
eta
}
}
docker {
containers {
id
names
state
status
autoStart
}
}
vms {
id
name
state
autoStart
}
shares {
name
size
used
free
}
services {
name
online
}
notifications {
id
importance
read
}
}
`;

View File

@@ -0,0 +1,328 @@
/**
* Unraid GraphQL Mutations
* Based on Unraid API v4.29.2
*/
// ============================================================================
// ARRAY MUTATIONS
// ============================================================================
export const ARRAY_SET_STATE_MUTATION = `
mutation ArraySetState($state: String!) {
array {
setState(state: $state) {
state
}
}
}
`;
export const ARRAY_ADD_DISK_MUTATION = `
mutation ArrayAddDisk($slot: String!, $id: String!) {
array {
addDiskToArray(slot: $slot, id: $id) {
state
}
}
}
`;
export const ARRAY_REMOVE_DISK_MUTATION = `
mutation ArrayRemoveDisk($slot: String!) {
array {
removeDiskFromArray(slot: $slot) {
state
}
}
}
`;
export const ARRAY_MOUNT_DISK_MUTATION = `
mutation ArrayMountDisk($id: String!) {
array {
mountArrayDisk(id: $id) {
state
}
}
}
`;
export const ARRAY_UNMOUNT_DISK_MUTATION = `
mutation ArrayUnmountDisk($id: String!) {
array {
unmountArrayDisk(id: $id) {
state
}
}
}
`;
// ============================================================================
// PARITY CHECK MUTATIONS
// ============================================================================
export const PARITY_CHECK_START_MUTATION = `
mutation ParityCheckStart($correct: Boolean) {
parityCheck {
start(correct: $correct) {
running
progress
}
}
}
`;
export const PARITY_CHECK_PAUSE_MUTATION = `
mutation ParityCheckPause {
parityCheck {
pause {
running
progress
}
}
}
`;
export const PARITY_CHECK_RESUME_MUTATION = `
mutation ParityCheckResume {
parityCheck {
resume {
running
progress
}
}
}
`;
export const PARITY_CHECK_CANCEL_MUTATION = `
mutation ParityCheckCancel {
parityCheck {
cancel {
running
}
}
}
`;
// ============================================================================
// DOCKER MUTATIONS
// ============================================================================
export const DOCKER_START_MUTATION = `
mutation DockerStart($id: String!) {
docker {
start(id: $id) {
id
state
status
}
}
}
`;
export const DOCKER_STOP_MUTATION = `
mutation DockerStop($id: String!) {
docker {
stop(id: $id) {
id
state
status
}
}
}
`;
// ============================================================================
// VM MUTATIONS
// ============================================================================
export const VM_START_MUTATION = `
mutation VmStart($id: String!) {
vm {
start(id: $id) {
id
state
}
}
}
`;
export const VM_STOP_MUTATION = `
mutation VmStop($id: String!) {
vm {
stop(id: $id) {
id
state
}
}
}
`;
export const VM_PAUSE_MUTATION = `
mutation VmPause($id: String!) {
vm {
pause(id: $id) {
id
state
}
}
}
`;
export const VM_RESUME_MUTATION = `
mutation VmResume($id: String!) {
vm {
resume(id: $id) {
id
state
}
}
}
`;
export const VM_FORCE_STOP_MUTATION = `
mutation VmForceStop($id: String!) {
vm {
forceStop(id: $id) {
id
state
}
}
}
`;
export const VM_REBOOT_MUTATION = `
mutation VmReboot($id: String!) {
vm {
reboot(id: $id) {
id
state
}
}
}
`;
export const VM_RESET_MUTATION = `
mutation VmReset($id: String!) {
vm {
reset(id: $id) {
id
state
}
}
}
`;
// ============================================================================
// NOTIFICATION MUTATIONS
// ============================================================================
export const NOTIFICATION_CREATE_MUTATION = `
mutation NotificationCreate($input: CreateNotificationInput!) {
notification {
create(input: $input) {
id
title
subject
description
importance
}
}
}
`;
export const NOTIFICATION_DELETE_MUTATION = `
mutation NotificationDelete($id: String!) {
notification {
delete(id: $id)
}
}
`;
export const NOTIFICATION_ARCHIVE_MUTATION = `
mutation NotificationArchive($id: String!) {
notification {
archive(id: $id) {
id
archived
}
}
}
`;
export const NOTIFICATION_UNREAD_MUTATION = `
mutation NotificationUnread($id: String!) {
notification {
unread(id: $id) {
id
read
}
}
}
`;
// ============================================================================
// CUSTOMIZATION MUTATIONS
// ============================================================================
export const SET_THEME_MUTATION = `
mutation SetTheme($theme: ThemeName!) {
customization {
setTheme(theme: $theme) {
theme
}
}
}
`;
// ============================================================================
// API KEY MUTATIONS
// ============================================================================
export const API_KEY_CREATE_MUTATION = `
mutation ApiKeyCreate($name: String!, $description: String) {
apiKey {
create(name: $name, description: $description) {
id
name
key
}
}
}
`;
export const API_KEY_DELETE_MUTATION = `
mutation ApiKeyDelete($id: String!) {
apiKey {
delete(id: $id)
}
}
`;
// ============================================================================
// CONNECT MUTATIONS
// ============================================================================
export const CONNECT_SIGN_IN_MUTATION = `
mutation ConnectSignIn {
connectSignIn {
success
}
}
`;
export const CONNECT_SIGN_OUT_MUTATION = `
mutation ConnectSignOut {
connectSignOut {
success
}
}
`;
export const SETUP_REMOTE_ACCESS_MUTATION = `
mutation SetupRemoteAccess($enable: Boolean!) {
setupRemoteAccess(enable: $enable) {
success
}
}
`;

View File

@@ -0,0 +1,164 @@
/**
* Unraid GraphQL Subscriptions
* For real-time updates via WebSocket
*/
// ============================================================================
// SYSTEM METRICS SUBSCRIPTIONS
// ============================================================================
export const CPU_METRICS_SUBSCRIPTION = `
subscription CpuMetrics {
systemMetricsCpu {
cores
average
temperature
}
}
`;
export const CPU_TELEMETRY_SUBSCRIPTION = `
subscription CpuTelemetry {
systemMetricsCpuTelemetry {
cores
average
temperature
}
}
`;
export const MEMORY_METRICS_SUBSCRIPTION = `
subscription MemoryMetrics {
systemMetricsMemory {
total
used
free
cached
buffers
percent
}
}
`;
// ============================================================================
// ARRAY SUBSCRIPTIONS
// ============================================================================
export const ARRAY_SUBSCRIPTION = `
subscription ArrayUpdates {
arraySubscription {
state
capacity {
total
used
free
}
disks {
id
name
status
temp
spunDown
}
parityCheckStatus {
running
progress
errors
eta
}
}
}
`;
export const PARITY_HISTORY_SUBSCRIPTION = `
subscription ParityHistory {
parityHistorySubscription {
date
duration
errors
speed
status
}
}
`;
// ============================================================================
// NOTIFICATION SUBSCRIPTIONS
// ============================================================================
export const NOTIFICATION_ADDED_SUBSCRIPTION = `
subscription NotificationAdded {
notificationAdded {
id
title
subject
description
importance
type
timestamp
}
}
`;
export const NOTIFICATIONS_OVERVIEW_SUBSCRIPTION = `
subscription NotificationsOverview {
notificationsOverview {
total
unread
alerts
warnings
}
}
`;
// ============================================================================
// LOG SUBSCRIPTIONS
// ============================================================================
export const LOG_FILE_SUBSCRIPTION = `
subscription LogFile($path: String!) {
logFile(path: $path) {
line
timestamp
}
}
`;
// ============================================================================
// UPS SUBSCRIPTIONS
// ============================================================================
export const UPS_UPDATES_SUBSCRIPTION = `
subscription UpsUpdates {
upsUpdates {
id
status
batteryCharge
batteryRuntime
load
}
}
`;
// ============================================================================
// SERVER SUBSCRIPTIONS
// ============================================================================
export const OWNER_SUBSCRIPTION = `
subscription OwnerUpdates {
ownerSubscription {
email
username
}
}
`;
export const SERVERS_SUBSCRIPTION = `
subscription ServersUpdates {
serversSubscription {
guid
name
status
}
}
`;

510
src/lib/unraid/types.ts Normal file
View File

@@ -0,0 +1,510 @@
/**
* Unraid GraphQL API Types
* Based on live introspection from Unraid API v4.29.2
*/
// ============================================================================
// ENUMS
// ============================================================================
export enum ArrayState {
STARTED = 'STARTED',
STOPPED = 'STOPPED',
NEW_ARRAY = 'NEW_ARRAY',
RECON_DISK = 'RECON_DISK',
DISABLE_DISK = 'DISABLE_DISK',
SWAP_DSBL = 'SWAP_DSBL',
INVALID_EXPANSION = 'INVALID_EXPANSION',
}
export enum ArrayDiskStatus {
DISK_NP = 'DISK_NP',
DISK_OK = 'DISK_OK',
DISK_INVALID = 'DISK_INVALID',
DISK_WRONG = 'DISK_WRONG',
DISK_DSBL = 'DISK_DSBL',
DISK_DSBL_NEW = 'DISK_DSBL_NEW',
DISK_NEW = 'DISK_NEW',
}
export enum ArrayDiskType {
DATA = 'DATA',
PARITY = 'PARITY',
FLASH = 'FLASH',
CACHE = 'CACHE',
}
export enum ContainerState {
RUNNING = 'RUNNING',
EXITED = 'EXITED',
PAUSED = 'PAUSED',
RESTARTING = 'RESTARTING',
CREATED = 'CREATED',
DEAD = 'DEAD',
}
export enum VmState {
NOSTATE = 'NOSTATE',
RUNNING = 'RUNNING',
IDLE = 'IDLE',
PAUSED = 'PAUSED',
SHUTDOWN = 'SHUTDOWN',
SHUTOFF = 'SHUTOFF',
CRASHED = 'CRASHED',
PMSUSPENDED = 'PMSUSPENDED',
}
export enum DiskFsType {
XFS = 'XFS',
BTRFS = 'BTRFS',
VFAT = 'VFAT',
ZFS = 'ZFS',
EXT4 = 'EXT4',
NTFS = 'NTFS',
}
export enum DiskInterfaceType {
SAS = 'SAS',
SATA = 'SATA',
USB = 'USB',
PCIE = 'PCIE',
UNKNOWN = 'UNKNOWN',
}
export enum ThemeName {
AZURE = 'azure',
BLACK = 'black',
GRAY = 'gray',
WHITE = 'white',
}
export enum NotificationImportance {
NORMAL = 'NORMAL',
WARNING = 'WARNING',
ALERT = 'ALERT',
}
// ============================================================================
// SYSTEM INFO TYPES
// ============================================================================
export interface SystemInfo {
cpu: CpuInfo;
memory: MemoryInfo;
os: OsInfo;
baseboard: BaseboardInfo;
versions: VersionInfo;
}
export interface CpuInfo {
manufacturer: string;
brand: string;
cores: number;
threads: number;
speed: number;
speedMax: number;
cache: CpuCache;
}
export interface CpuCache {
l1d: number;
l1i: number;
l2: number;
l3: number;
}
export interface MemoryInfo {
total: number;
free: number;
used: number;
active: number;
available: number;
buffers: number;
cached: number;
slab: number;
swapTotal: number;
swapUsed: number;
swapFree: number;
}
export interface OsInfo {
platform: string;
distro: string;
release: string;
kernel: string;
arch: string;
hostname: string;
uptime: number;
}
export interface BaseboardInfo {
manufacturer: string;
model: string;
version: string;
serial: string;
}
export interface VersionInfo {
unraid: string;
api: string;
}
// ============================================================================
// ARRAY TYPES
// ============================================================================
export interface UnraidArray {
state: ArrayState;
capacity: ArrayCapacity;
disks: ArrayDisk[];
parities: ArrayDisk[];
caches: ArrayCache[];
parityCheckStatus: ParityCheckStatus | null;
}
export interface ArrayCapacity {
total: number;
used: number;
free: number;
disks: {
total: number;
used: number;
free: number;
};
}
export interface ArrayDisk {
id: string;
name: string;
device: string;
size: number;
status: ArrayDiskStatus;
type: ArrayDiskType;
temp: number | null;
numReads: number;
numWrites: number;
numErrors: number;
fsType: DiskFsType | null;
fsFree: number | null;
fsUsed: number | null;
fsSize: number | null;
color: string;
spunDown: boolean;
transport: DiskInterfaceType;
rotational: boolean;
serial: string;
model: string;
}
export interface ArrayCache {
id: string;
name: string;
devices: ArrayDisk[];
fsType: DiskFsType;
fsFree: number;
fsUsed: number;
fsSize: number;
}
export interface ParityCheckStatus {
running: boolean;
progress: number;
errors: number;
elapsed: number;
eta: number;
speed: number;
mode: 'check' | 'correct';
}
// ============================================================================
// DOCKER TYPES
// ============================================================================
export interface DockerContainer {
id: string;
names: string[];
image: string;
state: ContainerState;
status: string;
created: number;
ports: ContainerPort[];
autoStart: boolean;
networkMode: string;
cpuPercent?: number;
memoryUsage?: number;
memoryLimit?: number;
}
export interface ContainerPort {
privatePort: number;
publicPort?: number;
type: 'tcp' | 'udp';
ip?: string;
}
export interface DockerNetwork {
id: string;
name: string;
driver: string;
scope: string;
}
export interface Docker {
containers: DockerContainer[];
networks: DockerNetwork[];
}
// ============================================================================
// VM TYPES
// ============================================================================
export interface VirtualMachine {
id: string;
name: string;
state: VmState;
uuid: string;
description?: string;
cpus: number;
memory: number;
autoStart: boolean;
icon?: string;
}
export interface Vms {
vms: VirtualMachine[];
}
// ============================================================================
// SHARES TYPES
// ============================================================================
export interface Share {
name: string;
comment: string;
free: number;
used: number;
size: number;
include: string[];
exclude: string[];
cache: string;
color: string;
floor: number;
splitLevel: number;
allocator: 'highwater' | 'fillup' | 'mostfree';
export: string;
security: string;
}
// ============================================================================
// NOTIFICATION TYPES
// ============================================================================
export interface Notification {
id: string;
title: string;
subject: string;
description: string;
importance: NotificationImportance;
type: string;
timestamp: string;
read: boolean;
archived: boolean;
}
// ============================================================================
// DISK TYPES
// ============================================================================
export interface Disk {
id: string;
name: string;
device: string;
size: number;
vendor: string;
model: string;
serial: string;
firmware: string;
type: string;
interfaceType: DiskInterfaceType;
rotational: boolean;
temp: number | null;
smartStatus: string;
}
// ============================================================================
// SERVICES TYPES
// ============================================================================
export interface Service {
name: string;
online: boolean;
uptime: number | null;
version: string | null;
}
// ============================================================================
// SERVER VARIABLES
// ============================================================================
export interface ServerVars {
version: string;
name: string;
timezone: string;
description: string;
model: string;
protocol: string;
port: number;
localTld: string;
csrf: string;
uptime: number;
}
// ============================================================================
// FLASH DRIVE
// ============================================================================
export interface Flash {
guid: string;
vendor: string;
product: string;
}
// ============================================================================
// REGISTRATION / LICENSE
// ============================================================================
export interface Registration {
type: 'Basic' | 'Plus' | 'Pro' | 'Lifetime' | 'Trial' | 'Expired';
state: string;
keyFile: string | null;
expiration: string | null;
}
// ============================================================================
// NETWORK
// ============================================================================
export interface Network {
accessUrls: AccessUrl[];
}
export interface AccessUrl {
type: string;
name: string;
ipv4: string | null;
ipv6: string | null;
url: string;
}
// ============================================================================
// SERVER
// ============================================================================
export interface Server {
owner: string | null;
guid: string;
wanip: string | null;
lanip: string;
localurl: string;
remoteurl: string | null;
}
// ============================================================================
// UPS
// ============================================================================
export interface UpsDevice {
id: string;
name: string;
model: string;
status: string;
batteryCharge: number;
batteryRuntime: number;
load: number;
inputVoltage: number;
outputVoltage: number;
}
// ============================================================================
// PLUGINS
// ============================================================================
export interface Plugin {
name: string;
version: string;
author: string;
url: string;
icon: string | null;
updateAvailable: boolean;
}
// ============================================================================
// CUSTOMIZATION
// ============================================================================
export interface Customization {
theme: ThemeName;
}
// ============================================================================
// API RESPONSE WRAPPERS
// ============================================================================
export interface UnraidApiResponse<T> {
data: T;
errors?: Array<{
message: string;
path?: string[];
}>;
}
// ============================================================================
// REAL-TIME METRICS (Subscriptions)
// ============================================================================
export interface CpuMetrics {
cores: number[];
average: number;
temperature?: number;
}
export interface MemoryMetrics {
total: number;
used: number;
free: number;
cached: number;
buffers: number;
percent: number;
}
// ============================================================================
// MUTATION INPUTS
// ============================================================================
export interface ArraySetStateInput {
state: 'start' | 'stop';
}
export interface DockerControlInput {
id: string;
}
export interface VmPowerInput {
id: string;
}
export interface ParityCheckInput {
action: 'start' | 'pause' | 'resume' | 'cancel';
mode?: 'check' | 'correct';
}
export interface NotificationInput {
id: string;
}
export interface CreateNotificationInput {
title: string;
subject: string;
description: string;
importance: NotificationImportance;
}

346
src/pages/unraid/index.tsx Normal file
View File

@@ -0,0 +1,346 @@
/**
* Unraid Dashboard Page
* Main overview page for Unraid server management
*/
import { useState } from 'react';
import {
Alert,
Container,
Grid,
Group,
Loader,
Stack,
Text,
ThemeIcon,
Title,
useMantineTheme,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import { IconServer, IconAlertCircle, IconCheck } from '@tabler/icons-react';
import { GetServerSidePropsContext } from 'next';
import { SystemInfoCard, ArrayCard, DockerCard, VmsCard } from '~/components/Unraid/Dashboard';
import { getServerAuthSession } from '~/server/auth';
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
import { api } from '~/utils/api';
export default function UnraidDashboardPage() {
const theme = useMantineTheme();
const [loadingContainers, setLoadingContainers] = useState<string[]>([]);
const [loadingVms, setLoadingVms] = useState<string[]>([]);
const [arrayLoading, setArrayLoading] = useState(false);
// Fetch dashboard data
const {
data: dashboard,
isLoading,
error,
refetch,
} = api.unraid.dashboard.useQuery(undefined, {
refetchInterval: 10000, // Refresh every 10 seconds
});
// Mutations
const startContainer = api.unraid.startContainer.useMutation({
onMutate: ({ id }) => setLoadingContainers((prev) => [...prev, id]),
onSettled: (_, __, { id }) =>
setLoadingContainers((prev) => prev.filter((cid) => cid !== id)),
onSuccess: () => {
notifications.show({
title: 'Container Started',
message: 'Container started successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const stopContainer = api.unraid.stopContainer.useMutation({
onMutate: ({ id }) => setLoadingContainers((prev) => [...prev, id]),
onSettled: (_, __, { id }) =>
setLoadingContainers((prev) => prev.filter((cid) => cid !== id)),
onSuccess: () => {
notifications.show({
title: 'Container Stopped',
message: 'Container stopped successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const startVm = api.unraid.startVm.useMutation({
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
onSuccess: () => {
notifications.show({
title: 'VM Started',
message: 'Virtual machine started successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const stopVm = api.unraid.stopVm.useMutation({
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
onSuccess: () => {
notifications.show({
title: 'VM Stopped',
message: 'Virtual machine stopped successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const pauseVm = api.unraid.pauseVm.useMutation({
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
onSuccess: () => {
notifications.show({
title: 'VM Paused',
message: 'Virtual machine paused successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
});
const resumeVm = api.unraid.resumeVm.useMutation({
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
onSuccess: () => {
notifications.show({
title: 'VM Resumed',
message: 'Virtual machine resumed successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
});
const rebootVm = api.unraid.rebootVm.useMutation({
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
onSuccess: () => {
notifications.show({
title: 'VM Rebooting',
message: 'Virtual machine is rebooting',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
});
const startArray = api.unraid.startArray.useMutation({
onMutate: () => setArrayLoading(true),
onSettled: () => setArrayLoading(false),
onSuccess: () => {
notifications.show({
title: 'Array Starting',
message: 'Array is starting...',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const stopArray = api.unraid.stopArray.useMutation({
onMutate: () => setArrayLoading(true),
onSettled: () => setArrayLoading(false),
onSuccess: () => {
notifications.show({
title: 'Array Stopping',
message: 'Array is stopping...',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
// Loading state
if (isLoading) {
return (
<Container size="xl" py="xl">
<Stack align="center" spacing="md">
<Loader size="xl" />
<Text color="dimmed">Connecting to Unraid server...</Text>
</Stack>
</Container>
);
}
// Error state
if (error) {
return (
<Container size="xl" py="xl">
<Alert
icon={<IconAlertCircle size={16} />}
title="Connection Error"
color="red"
variant="filled"
>
{error.message}
</Alert>
</Container>
);
}
// No data
if (!dashboard) {
return (
<Container size="xl" py="xl">
<Alert
icon={<IconAlertCircle size={16} />}
title="No Data"
color="yellow"
>
No data received from Unraid server. Please check your configuration.
</Alert>
</Container>
);
}
return (
<Container size="xl" py="xl">
<Stack spacing="xl">
{/* Header */}
<Group position="apart">
<Group>
<ThemeIcon size={48} radius="md" variant="gradient" gradient={{ from: 'blue', to: 'cyan' }}>
<IconServer size={28} />
</ThemeIcon>
<div>
<Title order={1}>Unraid Dashboard</Title>
<Text color="dimmed" size="sm">
{dashboard.vars.name} - Unraid {dashboard.info.versions.unraid}
</Text>
</div>
</Group>
</Group>
{/* Dashboard Grid */}
<Grid>
{/* System Info */}
<Grid.Col md={6} lg={4}>
<SystemInfoCard
info={dashboard.info}
vars={dashboard.vars}
registration={dashboard.registration}
/>
</Grid.Col>
{/* Array */}
<Grid.Col md={6} lg={8}>
<ArrayCard
array={dashboard.array}
onStartArray={() => startArray.mutate()}
onStopArray={() => stopArray.mutate()}
isLoading={arrayLoading}
/>
</Grid.Col>
{/* Docker */}
<Grid.Col md={6}>
<DockerCard
docker={dashboard.docker}
onStartContainer={(id) => startContainer.mutate({ id })}
onStopContainer={(id) => stopContainer.mutate({ id })}
loadingContainers={loadingContainers}
/>
</Grid.Col>
{/* VMs */}
<Grid.Col md={6}>
<VmsCard
vms={dashboard.vms}
onStartVm={(id) => startVm.mutate({ id })}
onStopVm={(id) => stopVm.mutate({ id })}
onPauseVm={(id) => pauseVm.mutate({ id })}
onResumeVm={(id) => resumeVm.mutate({ id })}
onRebootVm={(id) => rebootVm.mutate({ id })}
loadingVms={loadingVms}
/>
</Grid.Col>
</Grid>
</Stack>
</Container>
);
}
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getServerAuthSession(context);
const translations = await getServerSideTranslations(['common'], context.locale, context.req, context.res);
const result = checkForSessionOrAskForLogin(context, session, () => session?.user != undefined);
if (result) {
return result;
}
return {
props: {
...translations,
},
};
};

View File

@@ -1,4 +1,5 @@
import { tdarrRouter } from '~/server/api/routers/tdarr';
import { unraidRouter } from '~/server/api/routers/unraid/router';
import { createTRPCRouter } from '~/server/api/trpc';
import { appRouter } from './routers/app';
@@ -55,6 +56,7 @@ export const rootRouter = createTRPCRouter({
healthMonitoring: healthMonitoringRouter,
tdarr: tdarrRouter,
migrate: migrateRouter,
unraid: unraidRouter,
});
// export type definition of API

View File

@@ -0,0 +1,399 @@
/**
* Unraid tRPC Router
* Provides API endpoints for Unraid management
*/
import { z } from 'zod';
import { createTRPCRouter, protectedProcedure, publicProcedure } from '~/server/api/trpc';
import { getUnraidClient, createUnraidClient } from '~/lib/unraid';
import type { UnraidClientConfig } from '~/lib/unraid';
// Input schemas
const containerIdSchema = z.object({
id: z.string(),
});
const vmIdSchema = z.object({
id: z.string(),
});
const parityCheckSchema = z.object({
correct: z.boolean().optional().default(false),
});
const notificationIdSchema = z.object({
id: z.string(),
});
const diskIdSchema = z.object({
id: z.string(),
});
const connectionTestSchema = z.object({
host: z.string(),
apiKey: z.string(),
useSsl: z.boolean().optional().default(false),
port: z.number().optional(),
});
export const unraidRouter = createTRPCRouter({
// ============================================================================
// CONNECTION
// ============================================================================
/**
* Test connection to Unraid server
*/
testConnection: publicProcedure.input(connectionTestSchema).mutation(async ({ input }) => {
try {
const client = createUnraidClient(input as UnraidClientConfig);
const healthy = await client.healthCheck();
return { success: healthy, error: null };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
}),
/**
* Check if Unraid is configured
*/
isConfigured: publicProcedure.query(() => {
return {
configured: !!(process.env.UNRAID_HOST && process.env.UNRAID_API_KEY),
};
}),
// ============================================================================
// DASHBOARD / OVERVIEW
// ============================================================================
/**
* Get complete dashboard data
*/
dashboard: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getDashboard();
}),
// ============================================================================
// SYSTEM INFO
// ============================================================================
/**
* Get system information
*/
info: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getInfo();
}),
/**
* Get server variables
*/
vars: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getVars();
}),
/**
* Get server details
*/
server: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getServer();
}),
/**
* Get registration/license info
*/
registration: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getRegistration();
}),
/**
* Get flash drive info
*/
flash: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getFlash();
}),
// ============================================================================
// ARRAY
// ============================================================================
/**
* Get array status
*/
array: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getArray();
}),
/**
* Start array
*/
startArray: protectedProcedure.mutation(async () => {
const client = getUnraidClient();
return client.startArray();
}),
/**
* Stop array
*/
stopArray: protectedProcedure.mutation(async () => {
const client = getUnraidClient();
return client.stopArray();
}),
// ============================================================================
// DISKS
// ============================================================================
/**
* Get all disks
*/
disks: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getDisks();
}),
/**
* Get single disk by ID
*/
disk: protectedProcedure.input(diskIdSchema).query(async ({ input }) => {
const client = getUnraidClient();
return client.getDisk(input.id);
}),
// ============================================================================
// PARITY CHECK
// ============================================================================
/**
* Start parity check
*/
startParityCheck: protectedProcedure.input(parityCheckSchema).mutation(async ({ input }) => {
const client = getUnraidClient();
return client.startParityCheck(input.correct);
}),
/**
* Pause parity check
*/
pauseParityCheck: protectedProcedure.mutation(async () => {
const client = getUnraidClient();
return client.pauseParityCheck();
}),
/**
* Resume parity check
*/
resumeParityCheck: protectedProcedure.mutation(async () => {
const client = getUnraidClient();
return client.resumeParityCheck();
}),
/**
* Cancel parity check
*/
cancelParityCheck: protectedProcedure.mutation(async () => {
const client = getUnraidClient();
return client.cancelParityCheck();
}),
// ============================================================================
// DOCKER
// ============================================================================
/**
* Get Docker containers and networks
*/
docker: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getDocker();
}),
/**
* Start container
*/
startContainer: protectedProcedure.input(containerIdSchema).mutation(async ({ input }) => {
const client = getUnraidClient();
return client.startContainer(input.id);
}),
/**
* Stop container
*/
stopContainer: protectedProcedure.input(containerIdSchema).mutation(async ({ input }) => {
const client = getUnraidClient();
return client.stopContainer(input.id);
}),
// ============================================================================
// VMS
// ============================================================================
/**
* Get virtual machines
*/
vms: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getVms();
}),
/**
* Start VM
*/
startVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => {
const client = getUnraidClient();
return client.startVm(input.id);
}),
/**
* Stop VM (graceful)
*/
stopVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => {
const client = getUnraidClient();
return client.stopVm(input.id);
}),
/**
* Pause VM
*/
pauseVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => {
const client = getUnraidClient();
return client.pauseVm(input.id);
}),
/**
* Resume VM
*/
resumeVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => {
const client = getUnraidClient();
return client.resumeVm(input.id);
}),
/**
* Force stop VM
*/
forceStopVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => {
const client = getUnraidClient();
return client.forceStopVm(input.id);
}),
/**
* Reboot VM
*/
rebootVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => {
const client = getUnraidClient();
return client.rebootVm(input.id);
}),
// ============================================================================
// SHARES
// ============================================================================
/**
* Get shares
*/
shares: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getShares();
}),
// ============================================================================
// NOTIFICATIONS
// ============================================================================
/**
* Get notifications
*/
notifications: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getNotifications();
}),
/**
* Delete notification
*/
deleteNotification: protectedProcedure.input(notificationIdSchema).mutation(async ({ input }) => {
const client = getUnraidClient();
return client.deleteNotification(input.id);
}),
/**
* Archive notification
*/
archiveNotification: protectedProcedure
.input(notificationIdSchema)
.mutation(async ({ input }) => {
const client = getUnraidClient();
return client.archiveNotification(input.id);
}),
// ============================================================================
// SERVICES
// ============================================================================
/**
* Get services
*/
services: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getServices();
}),
// ============================================================================
// NETWORK
// ============================================================================
/**
* Get network info
*/
network: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getNetwork();
}),
// ============================================================================
// UPS
// ============================================================================
/**
* Get UPS devices
*/
upsDevices: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getUpsDevices();
}),
// ============================================================================
// PLUGINS
// ============================================================================
/**
* Get installed plugins
*/
plugins: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getPlugins();
}),
// ============================================================================
// CUSTOMIZATION
// ============================================================================
/**
* Get customization settings
*/
customization: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getCustomization();
}),
});

View File

@@ -1,4 +1,5 @@
@import 'fily-publish-gridstack/dist/gridstack.min.css';
@import './orchis/variables.css';
:root {
--gridstack-widget-width: 64;

518
src/styles/orchis/theme.ts Normal file
View File

@@ -0,0 +1,518 @@
/**
* Orchis Theme for Mantine
* Based on the Orchis GTK Theme by vinceliuice
* https://github.com/vinceliuice/Orchis-theme
*/
import { MantineProviderProps, MantineThemeColors, Tuple } from '@mantine/core';
// ============================================================================
// ORCHIS COLOR PALETTE
// ============================================================================
/**
* Orchis primary blue - the default accent color
* Matches Google's Material Design blue
*/
const orchisBlue: Tuple<string, 10> = [
'#E3F2FD', // 0 - lightest
'#BBDEFB', // 1
'#90CAF9', // 2
'#64B5F6', // 3
'#42A5F5', // 4
'#2196F3', // 5
'#1E88E5', // 6 - primary dark
'#1976D2', // 7
'#1565C0', // 8
'#1A73E8', // 9 - Orchis primary
];
/**
* Orchis grey scale for surfaces
*/
const orchisGrey: Tuple<string, 10> = [
'#FAFAFA', // 0 - grey-050
'#F2F2F2', // 1 - grey-100
'#EEEEEE', // 2 - grey-150
'#DDDDDD', // 3 - grey-200
'#BFBFBF', // 4 - grey-300
'#9E9E9E', // 5 - grey-400
'#727272', // 6 - grey-500
'#464646', // 7 - grey-600
'#2C2C2C', // 8 - grey-700
'#212121', // 9 - grey-800
];
/**
* Orchis dark grey for dark mode
*/
const orchisDark: Tuple<string, 10> = [
'#3C3C3C', // 0 - surface
'#333333', // 1
'#2C2C2C', // 2 - base
'#262626', // 3
'#242424', // 4 - base-alt
'#212121', // 5 - background
'#1A1A1A', // 6
'#121212', // 7
'#0F0F0F', // 8
'#030303', // 9
];
/**
* Orchis red for errors/destructive
*/
const orchisRed: Tuple<string, 10> = [
'#FFEBEE',
'#FFCDD2',
'#EF9A9A',
'#E57373',
'#EF5350',
'#F44336',
'#E53935', // Orchis error
'#D32F2F',
'#C62828',
'#B71C1C',
];
/**
* Orchis green for success
*/
const orchisGreen: Tuple<string, 10> = [
'#E8F5E9',
'#C8E6C9',
'#A5D6A7',
'#81C995', // Sea light
'#66BB6A',
'#4CAF50',
'#43A047',
'#388E3C',
'#0F9D58', // Sea dark (success)
'#1B5E20',
];
/**
* Orchis yellow for warnings
*/
const orchisYellow: Tuple<string, 10> = [
'#FFFDE7',
'#FFF9C4',
'#FFF59D',
'#FFF176',
'#FFEE58',
'#FFEB3B',
'#FDD835',
'#FBC02D', // Yellow light
'#F9A825',
'#FFD600', // Yellow dark (warning)
];
/**
* Orchis purple for accents
*/
const orchisPurple: Tuple<string, 10> = [
'#F3E5F5',
'#E1BEE7',
'#CE93D8',
'#BA68C8', // Purple light
'#AB47BC', // Purple dark
'#9C27B0',
'#8E24AA',
'#7B1FA2',
'#6A1B9A',
'#4A148C',
];
/**
* Orchis teal for secondary accents
*/
const orchisTeal: Tuple<string, 10> = [
'#E0F2F1',
'#B2DFDB',
'#80CBC4',
'#4DB6AC',
'#26A69A',
'#009688',
'#00897B',
'#00796B',
'#00695C',
'#004D40',
];
/**
* Orchis orange for highlights
*/
const orchisOrange: Tuple<string, 10> = [
'#FFF3E0',
'#FFE0B2',
'#FFCC80',
'#FFB74D',
'#FFA726',
'#FF9800',
'#FB8C00',
'#F57C00',
'#EF6C00',
'#E65100',
];
// ============================================================================
// MANTINE THEME CONFIGURATION
// ============================================================================
export const orchisColors: MantineThemeColors = {
// Override default colors with Orchis palette
blue: orchisBlue,
gray: orchisGrey,
dark: orchisDark,
red: orchisRed,
green: orchisGreen,
yellow: orchisYellow,
violet: orchisPurple,
teal: orchisTeal,
orange: orchisOrange,
// Custom Orchis-specific colors
orchis: orchisBlue,
surface: orchisGrey,
};
export const orchisTheme: MantineProviderProps['theme'] = {
// Color configuration
colors: orchisColors,
primaryColor: 'blue',
primaryShade: { light: 9, dark: 5 },
// Typography
fontFamily: '"M+ 1c", Roboto, Cantarell, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontFamilyMonospace: '"JetBrains Mono", "Fira Code", Consolas, monospace',
headings: {
fontFamily: 'Roboto, "M+ 1c", Cantarell, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
fontWeight: 700,
},
// Border radius - Orchis uses 12px as default
radius: {
xs: 4,
sm: 6,
md: 12, // Default Orchis corner radius
lg: 18, // Window radius
xl: 24,
},
defaultRadius: 'md',
// Spacing - Orchis base is 6px
spacing: {
xs: 4,
sm: 6,
md: 12,
lg: 18,
xl: 24,
},
// Shadows - Material Design elevation
shadows: {
xs: '0 1px 2px rgba(0,0,0,0.17)',
sm: '0 2px 2px -2px rgba(0,0,0,0.3), 0 1px 2px -1px rgba(0,0,0,0.24), 0 1px 2px -1px rgba(0,0,0,0.17)',
md: '0 3px 3px -2px rgba(0,0,0,0.2), 0 3px 3px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12)',
lg: '0 3px 3px -1px rgba(0,0,0,0.2), 0 6px 6px 0 rgba(0,0,0,0.14), 0 1px 11px 0 rgba(0,0,0,0.12)',
xl: '0 8px 6px -5px rgba(0,0,0,0.2), 0 16px 16px 2px rgba(0,0,0,0.14), 0 6px 18px 5px rgba(0,0,0,0.12)',
},
// Transitions - Orchis uses 75ms base duration
transitionTimingFunction: 'cubic-bezier(0.0, 0.0, 0.2, 1)',
// Other
cursorType: 'pointer',
focusRing: 'auto',
respectReducedMotion: true,
white: '#FFFFFF',
black: '#000000',
// Global styles
globalStyles: (theme) => ({
'*, *::before, *::after': {
boxSizing: 'border-box',
},
body: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
color: theme.colorScheme === 'dark' ? theme.white : 'rgba(0, 0, 0, 0.87)',
lineHeight: 1.5,
},
// Orchis-style focus ring
':focus-visible': {
outline: `2px solid ${theme.colors.blue[theme.colorScheme === 'dark' ? 5 : 9]}`,
outlineOffset: 2,
},
}),
// Component overrides for Orchis styling
components: {
Button: {
defaultProps: {
radius: 'md',
},
styles: (theme) => ({
root: {
fontWeight: 500,
transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)',
},
}),
},
Card: {
defaultProps: {
radius: 'md',
p: 'md',
},
styles: (theme) => ({
root: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.white,
border: `1px solid ${
theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
}`,
},
}),
},
Paper: {
defaultProps: {
radius: 'md',
},
styles: (theme) => ({
root: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.white,
},
}),
},
Input: {
defaultProps: {
radius: 'md',
},
styles: (theme) => ({
input: {
backgroundColor:
theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)',
border: 'none',
transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)',
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
},
'&:focus': {
outline: `2px solid ${theme.colors.blue[theme.colorScheme === 'dark' ? 5 : 9]}`,
outlineOffset: -2,
},
},
}),
},
TextInput: {
defaultProps: {
radius: 'md',
},
},
Select: {
defaultProps: {
radius: 'md',
},
},
Modal: {
defaultProps: {
radius: 'lg',
overlayProps: {
blur: 3,
},
},
styles: (theme) => ({
content: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.white,
},
header: {
backgroundColor: 'transparent',
},
}),
},
Menu: {
defaultProps: {
radius: 'md',
shadow: 'lg',
},
styles: (theme) => ({
dropdown: {
backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.white,
border: 'none',
},
item: {
borderRadius: 5, // menuitem-radius
transition: 'background-color 75ms cubic-bezier(0.0, 0.0, 0.2, 1)',
},
}),
},
Tooltip: {
defaultProps: {
radius: 6,
},
styles: () => ({
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.9)',
color: '#FFFFFF',
},
}),
},
Tabs: {
styles: (theme) => ({
tab: {
fontWeight: 500,
transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)',
'&[data-active]': {
borderColor: theme.colors.blue[theme.colorScheme === 'dark' ? 5 : 9],
},
},
}),
},
Switch: {
styles: (theme) => ({
track: {
backgroundColor:
theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.26)',
transition: 'background-color 75ms cubic-bezier(0.0, 0.0, 0.2, 1)',
},
thumb: {
boxShadow:
'0 2px 2px -2px rgba(0,0,0,0.3), 0 1px 2px -1px rgba(0,0,0,0.24), 0 1px 2px -1px rgba(0,0,0,0.17)',
},
}),
},
Progress: {
defaultProps: {
radius: 'md',
size: 6, // Orchis bar-size
},
styles: (theme) => ({
root: {
backgroundColor:
theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)',
},
}),
},
NavLink: {
styles: (theme) => ({
root: {
borderRadius: '0 9999px 9999px 0', // Orchis sidebar row style
fontWeight: 500,
transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)',
'&[data-active]': {
backgroundColor:
theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
color: theme.colors.blue[theme.colorScheme === 'dark' ? 5 : 9],
},
},
}),
},
Notification: {
defaultProps: {
radius: 'md',
},
},
Badge: {
defaultProps: {
radius: 'md',
},
},
Checkbox: {
styles: (theme) => ({
input: {
cursor: 'pointer',
borderRadius: 4,
transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)',
},
label: {
cursor: 'pointer',
},
}),
},
Divider: {
styles: (theme) => ({
root: {
borderColor:
theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)',
},
}),
},
Table: {
styles: (theme) => ({
root: {
'& thead tr th': {
color: theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.6)',
fontWeight: 'normal',
borderBottom: `1px solid ${
theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
}`,
},
'& tbody tr td': {
borderBottom: `1px solid ${
theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)'
}`,
},
'& tbody tr:hover': {
backgroundColor:
theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
},
},
}),
},
ActionIcon: {
defaultProps: {
radius: 'md',
},
styles: () => ({
root: {
transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)',
},
}),
},
Accordion: {
defaultProps: {
radius: 'md',
},
},
Alert: {
defaultProps: {
radius: 'md',
},
},
Popover: {
defaultProps: {
radius: 'md',
shadow: 'lg',
},
},
HoverCard: {
defaultProps: {
radius: 'md',
shadow: 'lg',
},
},
},
};
export default orchisTheme;

View File

@@ -0,0 +1,155 @@
/**
* Orchis Theme CSS Variables
* Based on the Orchis GTK Theme by vinceliuice
* https://github.com/vinceliuice/Orchis-theme
*/
:root {
/* ---- Primary / Accent ---- */
--orchis-primary: #1A73E8;
--orchis-primary-light: #3281EA;
--orchis-on-primary: #FFFFFF;
--orchis-on-primary-secondary: rgba(255, 255, 255, 0.7);
/* ---- Backgrounds (Light Mode) ---- */
--orchis-background: #F2F2F2;
--orchis-surface: #FFFFFF;
--orchis-base: #FFFFFF;
--orchis-base-alt: #FAFAFA;
/* ---- Text (Light Mode) ---- */
--orchis-text: rgba(0, 0, 0, 0.87);
--orchis-text-secondary: rgba(0, 0, 0, 0.6);
--orchis-text-disabled: rgba(0, 0, 0, 0.38);
/* ---- Semantic Colors ---- */
--orchis-error: #E53935;
--orchis-warning: #FFD600;
--orchis-success: #0F9D58;
--orchis-info: #1A73E8;
--orchis-link: #1A73E8;
--orchis-link-visited: #AB47BC;
/* ---- Borders ---- */
--orchis-border: rgba(0, 0, 0, 0.12);
--orchis-border-solid: #E2E2E2;
--orchis-divider: rgba(0, 0, 0, 0.12);
/* ---- Overlay States ---- */
--orchis-overlay-hover: rgba(0, 0, 0, 0.08);
--orchis-overlay-focus: rgba(0, 0, 0, 0.08);
--orchis-overlay-active: rgba(0, 0, 0, 0.12);
--orchis-overlay-checked: rgba(0, 0, 0, 0.10);
--orchis-overlay-selected: rgba(0, 0, 0, 0.06);
/* ---- Track / Fill ---- */
--orchis-track: rgba(0, 0, 0, 0.26);
--orchis-track-disabled: rgba(0, 0, 0, 0.15);
--orchis-fill: rgba(0, 0, 0, 0.04);
--orchis-secondary-fill: rgba(0, 0, 0, 0.08);
/* ---- Tooltip ---- */
--orchis-tooltip-bg: rgba(0, 0, 0, 0.9);
/* ---- Titlebar / Sidebar ---- */
--orchis-titlebar: #FFFFFF;
--orchis-titlebar-backdrop: #FAFAFA;
--orchis-sidebar: #FAFAFA;
/* ---- Window Buttons ---- */
--orchis-btn-close: #fd5f51;
--orchis-btn-maximize: #38c76a;
--orchis-btn-minimize: #fdbe04;
/* ---- Typography ---- */
--orchis-font-family: "M+ 1c", Roboto, Cantarell, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--orchis-font-size: 14px;
--orchis-font-weight-button: 500;
/* ---- Spacing ---- */
--orchis-space: 6px;
--orchis-space-xs: 2px;
--orchis-space-sm: 4px;
--orchis-space-md: 6px;
--orchis-space-lg: 12px;
--orchis-space-xl: 18px;
--orchis-space-xxl: 24px;
/* ---- Border Radius ---- */
--orchis-radius: 12px;
--orchis-radius-window: 18px;
--orchis-radius-corner: 12px;
--orchis-radius-menu: 11px;
--orchis-radius-card: 11px;
--orchis-radius-tooltip: 6px;
--orchis-radius-menuitem: 5px;
--orchis-radius-circular: 9999px;
/* ---- Shadows ---- */
--orchis-shadow-z1: 0 2px 2px -2px rgba(0,0,0,0.3), 0 1px 2px -1px rgba(0,0,0,0.24), 0 1px 2px -1px rgba(0,0,0,0.17);
--orchis-shadow-z2: 0 3px 2px -3px rgba(0,0,0,0.3), 0 2px 2px -1px rgba(0,0,0,0.24), 0 1px 3px 0 rgba(0,0,0,0.12);
--orchis-shadow-z3: 0 3px 3px -2px rgba(0,0,0,0.2), 0 3px 3px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12);
--orchis-shadow-z4: 0 2px 2px -1px rgba(0,0,0,0.2), 0 4px 4px 0 rgba(0,0,0,0.14), 0 1px 6px 0 rgba(0,0,0,0.12);
--orchis-shadow-z6: 0 3px 3px -1px rgba(0,0,0,0.2), 0 6px 6px 0 rgba(0,0,0,0.14), 0 1px 11px 0 rgba(0,0,0,0.12);
--orchis-shadow-z8: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 8px 1px rgba(0,0,0,0.14), 0 3px 9px 2px rgba(0,0,0,0.12);
--orchis-shadow-z12: 0 7px 7px -4px rgba(0,0,0,0.2), 0 12px 12px 2px rgba(0,0,0,0.14), 0 5px 13px 4px rgba(0,0,0,0.12);
--orchis-shadow-z16: 0 8px 6px -5px rgba(0,0,0,0.2), 0 16px 16px 2px rgba(0,0,0,0.14), 0 6px 18px 5px rgba(0,0,0,0.12);
--orchis-shadow-z24: 0 11px 11px -7px rgba(0,0,0,0.2), 0 24px 24px 3px rgba(0,0,0,0.14), 0 9px 28px 8px rgba(0,0,0,0.12);
/* ---- Transitions ---- */
--orchis-duration: 75ms;
--orchis-duration-short: 150ms;
--orchis-duration-ripple: 225ms;
--orchis-ease: cubic-bezier(0.4, 0.0, 0.2, 1);
--orchis-ease-out: cubic-bezier(0.0, 0.0, 0.2, 1);
--orchis-ease-in: cubic-bezier(0.4, 0.0, 1, 1);
--orchis-transition: all 75ms cubic-bezier(0.0, 0.0, 0.2, 1);
/* ---- Component Sizes ---- */
--orchis-size-small: 24px;
--orchis-size-medium: 36px;
--orchis-size-large: 48px;
--orchis-icon-size: 16px;
--orchis-icon-size-md: 24px;
--orchis-icon-size-lg: 32px;
--orchis-bar-size: 6px;
}
/* ---- Dark Mode Overrides ---- */
[data-mantine-color-scheme="dark"],
.mantine-ColorScheme-root[data-mantine-color-scheme="dark"] {
--orchis-primary: #3281EA;
--orchis-background: #212121;
--orchis-surface: #3C3C3C;
--orchis-base: #2C2C2C;
--orchis-base-alt: #242424;
--orchis-text: #FFFFFF;
--orchis-text-secondary: rgba(255, 255, 255, 0.7);
--orchis-text-disabled: rgba(255, 255, 255, 0.5);
--orchis-error: #F44336;
--orchis-warning: #FBC02D;
--orchis-success: #81C995;
--orchis-link: #3281EA;
--orchis-link-visited: #BA68C8;
--orchis-border: rgba(255, 255, 255, 0.12);
--orchis-border-solid: #3D3D3D;
--orchis-divider: rgba(255, 255, 255, 0.12);
--orchis-overlay-hover: rgba(255, 255, 255, 0.08);
--orchis-overlay-focus: rgba(255, 255, 255, 0.08);
--orchis-overlay-active: rgba(255, 255, 255, 0.12);
--orchis-overlay-checked: rgba(255, 255, 255, 0.10);
--orchis-overlay-selected: rgba(255, 255, 255, 0.06);
--orchis-track: rgba(255, 255, 255, 0.3);
--orchis-track-disabled: rgba(255, 255, 255, 0.15);
--orchis-fill: rgba(255, 255, 255, 0.04);
--orchis-secondary-fill: rgba(255, 255, 255, 0.08);
--orchis-titlebar: #2C2C2C;
--orchis-titlebar-backdrop: #3C3C3C;
--orchis-sidebar: #242424;
}

View File

@@ -1,3 +1,6 @@
import { MantineProviderProps } from '@mantine/core';
export const theme: MantineProviderProps['theme'] = {};
import { orchisTheme } from '~/styles/orchis/theme';
// Use Orchis theme as the base theme
export const theme: MantineProviderProps['theme'] = orchisTheme;