Phase 4: Add Unraid management pages with sidebar layout
Some checks failed
Master CI / yarn_install_and_build (push) Has been cancelled
Some checks failed
Master CI / yarn_install_and_build (push) Has been cancelled
- Add UnraidLayout component with full sidebar navigation - Add Array management page with disk tables and parity check controls - Add Docker management page with container cards and filtering - Add VMs management page with power controls (start/stop/pause/resume/reboot) - Add Shares page with security levels and storage usage - Add Users page with admin/user roles display - Add Settings index with links to all settings pages - Add Identification settings page with system info - Add Notifications settings page with notification history - Add Tools index with links to all tools - Add Syslog page with live log viewing and filtering - Add Diagnostics page with system health checks - Update dashboard to use UnraidLayout Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
279
src/components/Unraid/Layout/UnraidLayout.tsx
Normal file
279
src/components/Unraid/Layout/UnraidLayout.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* Unraid Layout Component
|
||||
* Main layout wrapper with sidebar navigation for Unraid pages
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AppShell,
|
||||
Burger,
|
||||
Group,
|
||||
Header,
|
||||
MediaQuery,
|
||||
Navbar,
|
||||
NavLink,
|
||||
ScrollArea,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
useMantineColorScheme,
|
||||
useMantineTheme,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Divider,
|
||||
Box,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconDashboard,
|
||||
IconDatabase,
|
||||
IconBrandDocker,
|
||||
IconServer2,
|
||||
IconFolders,
|
||||
IconSettings,
|
||||
IconTools,
|
||||
IconBell,
|
||||
IconMoon,
|
||||
IconSun,
|
||||
IconChevronRight,
|
||||
IconServer,
|
||||
IconUsers,
|
||||
IconNetwork,
|
||||
IconShield,
|
||||
IconCpu,
|
||||
IconPlug,
|
||||
IconFileText,
|
||||
IconInfoCircle,
|
||||
IconTerminal2,
|
||||
} from '@tabler/icons-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
interface UnraidLayoutProps {
|
||||
children: React.ReactNode;
|
||||
notifications?: number;
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
icon: React.FC<{ size?: number }>;
|
||||
label: string;
|
||||
href: string;
|
||||
badge?: number | string;
|
||||
children?: NavItem[];
|
||||
}
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{ icon: IconDashboard, label: 'Dashboard', href: '/unraid' },
|
||||
{ icon: IconDatabase, label: 'Array', href: '/unraid/array' },
|
||||
{ icon: IconBrandDocker, label: 'Docker', href: '/unraid/docker' },
|
||||
{ icon: IconServer2, label: 'VMs', href: '/unraid/vms' },
|
||||
{ icon: IconFolders, label: 'Shares', href: '/unraid/shares' },
|
||||
{ icon: IconUsers, label: 'Users', href: '/unraid/users' },
|
||||
];
|
||||
|
||||
const settingsNavItems: NavItem[] = [
|
||||
{ icon: IconServer, label: 'Identification', href: '/unraid/settings/identification' },
|
||||
{ icon: IconDatabase, label: 'Disk Settings', href: '/unraid/settings/disk' },
|
||||
{ icon: IconNetwork, label: 'Network', href: '/unraid/settings/network' },
|
||||
{ icon: IconBrandDocker, label: 'Docker', href: '/unraid/settings/docker' },
|
||||
{ icon: IconServer2, label: 'VM Manager', href: '/unraid/settings/vm' },
|
||||
{ icon: IconShield, label: 'Management Access', href: '/unraid/settings/management' },
|
||||
{ icon: IconCpu, label: 'CPU Pinning', href: '/unraid/settings/cpu' },
|
||||
{ icon: IconBell, label: 'Notifications', href: '/unraid/settings/notifications' },
|
||||
];
|
||||
|
||||
const toolsNavItems: NavItem[] = [
|
||||
{ icon: IconFileText, label: 'System Log', href: '/unraid/tools/syslog' },
|
||||
{ icon: IconInfoCircle, label: 'Diagnostics', href: '/unraid/tools/diagnostics' },
|
||||
{ icon: IconCpu, label: 'System Devices', href: '/unraid/tools/devices' },
|
||||
{ icon: IconTerminal2, label: 'Terminal', href: '/unraid/tools/terminal' },
|
||||
{ icon: IconPlug, label: 'Plugins', href: '/unraid/tools/plugins' },
|
||||
];
|
||||
|
||||
function NavSection({
|
||||
title,
|
||||
items,
|
||||
currentPath,
|
||||
}: {
|
||||
title?: string;
|
||||
items: NavItem[];
|
||||
currentPath: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{title && (
|
||||
<Text size="xs" weight={500} color="dimmed" px="md" py="xs" transform="uppercase">
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
component={Link}
|
||||
href={item.href}
|
||||
label={item.label}
|
||||
icon={
|
||||
<ThemeIcon variant="light" size="sm">
|
||||
<item.icon size={14} />
|
||||
</ThemeIcon>
|
||||
}
|
||||
active={currentPath === item.href}
|
||||
rightSection={
|
||||
item.badge ? (
|
||||
<Badge size="xs" variant="filled" color="red">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
) : (
|
||||
<IconChevronRight size={14} stroke={1.5} />
|
||||
)
|
||||
}
|
||||
sx={(theme) => ({
|
||||
borderRadius: '0 9999px 9999px 0',
|
||||
marginRight: theme.spacing.sm,
|
||||
'&[data-active]': {
|
||||
backgroundColor:
|
||||
theme.colorScheme === 'dark'
|
||||
? 'rgba(255, 255, 255, 0.1)'
|
||||
: 'rgba(0, 0, 0, 0.05)',
|
||||
color: theme.colors.blue[theme.colorScheme === 'dark' ? 4 : 7],
|
||||
fontWeight: 500,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function UnraidLayout({ children, notifications = 0 }: UnraidLayoutProps) {
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
styles={{
|
||||
main: {
|
||||
background: colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0],
|
||||
minHeight: '100vh',
|
||||
},
|
||||
}}
|
||||
navbarOffsetBreakpoint="sm"
|
||||
navbar={
|
||||
<Navbar
|
||||
p="xs"
|
||||
hiddenBreakpoint="sm"
|
||||
hidden={!opened}
|
||||
width={{ sm: 240, lg: 280 }}
|
||||
sx={(theme) => ({
|
||||
backgroundColor:
|
||||
colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[0],
|
||||
borderRight: `1px solid ${
|
||||
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2]
|
||||
}`,
|
||||
})}
|
||||
>
|
||||
<Navbar.Section grow component={ScrollArea}>
|
||||
<NavSection items={mainNavItems} currentPath={router.pathname} />
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<NavSection title="Settings" items={settingsNavItems} currentPath={router.pathname} />
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<NavSection title="Tools" items={toolsNavItems} currentPath={router.pathname} />
|
||||
</Navbar.Section>
|
||||
|
||||
<Navbar.Section>
|
||||
<Divider my="sm" />
|
||||
<Box px="md" py="xs">
|
||||
<Text size="xs" color="dimmed">
|
||||
Unraid Custom UI v0.1.0
|
||||
</Text>
|
||||
</Box>
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
}
|
||||
header={
|
||||
<Header
|
||||
height={60}
|
||||
px="md"
|
||||
sx={(theme) => ({
|
||||
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[7] : theme.white,
|
||||
borderBottom: `1px solid ${
|
||||
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2]
|
||||
}`,
|
||||
})}
|
||||
>
|
||||
<Group position="apart" sx={{ height: '100%' }}>
|
||||
<Group>
|
||||
<MediaQuery largerThan="sm" styles={{ display: 'none' }}>
|
||||
<Burger
|
||||
opened={opened}
|
||||
onClick={() => setOpened((o) => !o)}
|
||||
size="sm"
|
||||
color={theme.colors.gray[6]}
|
||||
/>
|
||||
</MediaQuery>
|
||||
|
||||
<Group spacing="xs">
|
||||
<ThemeIcon
|
||||
size="lg"
|
||||
radius="md"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'blue', to: 'cyan' }}
|
||||
>
|
||||
<IconServer size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={4}>Unraid</Title>
|
||||
</div>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group spacing="xs">
|
||||
<Tooltip label={`${notifications} notifications`}>
|
||||
<ActionIcon variant="subtle" size="lg">
|
||||
<IconBell size={20} />
|
||||
{notifications > 0 && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="filled"
|
||||
color="red"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
padding: '0 4px',
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
}}
|
||||
>
|
||||
{notifications > 99 ? '99+' : notifications}
|
||||
</Badge>
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={() => toggleColorScheme()}
|
||||
>
|
||||
{colorScheme === 'dark' ? <IconSun size={20} /> : <IconMoon size={20} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default UnraidLayout;
|
||||
1
src/components/Unraid/Layout/index.ts
Normal file
1
src/components/Unraid/Layout/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { UnraidLayout } from './UnraidLayout';
|
||||
726
src/pages/unraid/array/index.tsx
Normal file
726
src/pages/unraid/array/index.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
/**
|
||||
* Array Management Page
|
||||
* Detailed view of array devices, parity, and pools
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconDatabase,
|
||||
IconHardDrive,
|
||||
IconTemperature,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconPlayerPause,
|
||||
IconRefresh,
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconChevronDown,
|
||||
IconShield,
|
||||
IconCpu,
|
||||
IconArrowsUpDown,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { api } from '~/utils/api';
|
||||
import type { ArrayDisk } from '~/lib/unraid/types';
|
||||
|
||||
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 DiskDetailsRow({ 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' : getStatusColor(disk.status)}
|
||||
>
|
||||
<IconHardDrive size={14} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text size="sm" weight={500}>
|
||||
{disk.name}
|
||||
</Text>
|
||||
<Text size="xs" color="dimmed">
|
||||
{disk.device}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm" lineClamp={1}>
|
||||
{disk.model}
|
||||
</Text>
|
||||
<Text size="xs" color="dimmed">
|
||||
{disk.serial}
|
||||
</Text>
|
||||
</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>
|
||||
<Badge size="xs" color={disk.fsType ? 'blue' : 'gray'} variant="outline">
|
||||
{disk.fsType || 'N/A'}
|
||||
</Badge>
|
||||
</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 ? 'Standby' : '-'}
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Group spacing="xs">
|
||||
<Tooltip label="Reads">
|
||||
<Text size="xs" color="dimmed">
|
||||
R: {disk.numReads.toLocaleString()}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Tooltip label="Writes">
|
||||
<Text size="xs" color="dimmed">
|
||||
W: {disk.numWrites.toLocaleString()}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</td>
|
||||
<td>
|
||||
{disk.numErrors > 0 ? (
|
||||
<Badge color="red" size="xs">
|
||||
{disk.numErrors}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="xs" color="dimmed">
|
||||
0
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ width: 120 }}>
|
||||
{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="xs" color="dimmed">
|
||||
-
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ArrayPage() {
|
||||
const [arrayLoading, setArrayLoading] = useState(false);
|
||||
const [parityLoading, setParityLoading] = useState(false);
|
||||
|
||||
const {
|
||||
data: array,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.unraid.array.useQuery(undefined, {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
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: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message,
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
const startParityCheck = api.unraid.startParityCheck.useMutation({
|
||||
onMutate: () => setParityLoading(true),
|
||||
onSettled: () => setParityLoading(false),
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
title: 'Parity Check Started',
|
||||
message: 'Parity check has started',
|
||||
color: 'green',
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const pauseParityCheck = api.unraid.pauseParityCheck.useMutation({
|
||||
onMutate: () => setParityLoading(true),
|
||||
onSettled: () => setParityLoading(false),
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
title: 'Parity Check Paused',
|
||||
message: 'Parity check has been paused',
|
||||
color: 'yellow',
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const cancelParityCheck = api.unraid.cancelParityCheck.useMutation({
|
||||
onMutate: () => setParityLoading(true),
|
||||
onSettled: () => setParityLoading(false),
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
title: 'Parity Check Cancelled',
|
||||
message: 'Parity check has been cancelled',
|
||||
color: 'orange',
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<Loader size="xl" />
|
||||
<Text color="dimmed">Loading array data...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error.message}
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!array) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="No Data" color="yellow">
|
||||
No array data available
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const isStarted = array.state === 'STARTED';
|
||||
const usedPercent = ((array.capacity.used / array.capacity.total) * 100).toFixed(1);
|
||||
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack spacing="xl">
|
||||
{/* Header */}
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="blue">
|
||||
<IconDatabase size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>Array Devices</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
Manage array, parity, and cache pools
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Badge
|
||||
size="lg"
|
||||
color={array.state === 'STARTED' ? 'green' : 'red'}
|
||||
variant="filled"
|
||||
>
|
||||
{array.state}
|
||||
</Badge>
|
||||
|
||||
{isStarted ? (
|
||||
<Button
|
||||
color="red"
|
||||
leftIcon={<IconPlayerStop size={16} />}
|
||||
onClick={() => stopArray.mutate()}
|
||||
loading={arrayLoading}
|
||||
>
|
||||
Stop Array
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="green"
|
||||
leftIcon={<IconPlayerPlay size={16} />}
|
||||
onClick={() => startArray.mutate()}
|
||||
loading={arrayLoading}
|
||||
>
|
||||
Start Array
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Capacity Overview */}
|
||||
<Grid>
|
||||
<Grid.Col md={4}>
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Group position="apart">
|
||||
<div>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Total Capacity
|
||||
</Text>
|
||||
<Text size="xl" weight={700}>
|
||||
{formatBytes(array.capacity.total)}
|
||||
</Text>
|
||||
</div>
|
||||
<RingProgress
|
||||
size={80}
|
||||
thickness={8}
|
||||
roundCaps
|
||||
sections={[
|
||||
{
|
||||
value: parseFloat(usedPercent),
|
||||
color:
|
||||
parseFloat(usedPercent) > 90
|
||||
? 'red'
|
||||
: parseFloat(usedPercent) > 75
|
||||
? 'orange'
|
||||
: 'blue',
|
||||
},
|
||||
]}
|
||||
label={
|
||||
<Text size="xs" align="center" weight={500}>
|
||||
{usedPercent}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col md={4}>
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Used Space
|
||||
</Text>
|
||||
<Text size="xl" weight={700}>
|
||||
{formatBytes(array.capacity.used)}
|
||||
</Text>
|
||||
<Text size="sm" color="dimmed">
|
||||
across {array.disks.length} data disks
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col md={4}>
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Free Space
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="green">
|
||||
{formatBytes(array.capacity.free)}
|
||||
</Text>
|
||||
<Text size="sm" color="dimmed">
|
||||
available for new data
|
||||
</Text>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{/* Parity Check Status */}
|
||||
{array.parityCheckStatus && (
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Group position="apart" mb="md">
|
||||
<Group>
|
||||
<ThemeIcon size="lg" variant="light" color="orange">
|
||||
<IconShield size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text weight={600}>Parity Check {array.parityCheckStatus.running ? 'In Progress' : 'Status'}</Text>
|
||||
<Text size="sm" color="dimmed">
|
||||
{array.parityCheckStatus.errors} errors found
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
{array.parityCheckStatus.running ? (
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
color="yellow"
|
||||
leftIcon={<IconPlayerPause size={16} />}
|
||||
onClick={() => pauseParityCheck.mutate()}
|
||||
loading={parityLoading}
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => cancelParityCheck.mutate()}
|
||||
loading={parityLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
rightIcon={<IconChevronDown size={16} />}
|
||||
loading={parityLoading}
|
||||
>
|
||||
Start Parity Check
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => startParityCheck.mutate({ correct: false })}>
|
||||
Check Only
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => startParityCheck.mutate({ correct: true })}>
|
||||
Check + Correct
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{array.parityCheckStatus.running && (
|
||||
<div>
|
||||
<Group position="apart" mb={5}>
|
||||
<Text size="sm">
|
||||
Progress: {array.parityCheckStatus.progress.toFixed(1)}%
|
||||
</Text>
|
||||
<Text size="sm" color="dimmed">
|
||||
ETA:{' '}
|
||||
{array.parityCheckStatus.eta > 0
|
||||
? `${Math.floor(array.parityCheckStatus.eta / 3600)}h ${Math.floor((array.parityCheckStatus.eta % 3600) / 60)}m`
|
||||
: 'Calculating...'}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={array.parityCheckStatus.progress}
|
||||
size="lg"
|
||||
radius="md"
|
||||
color="orange"
|
||||
animate
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Disk Tables */}
|
||||
<Tabs defaultValue="parity">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="parity" icon={<IconShield size={14} />}>
|
||||
Parity ({array.parities.length})
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="data" icon={<IconHardDrive size={14} />}>
|
||||
Data Disks ({array.disks.length})
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="cache" icon={<IconCpu size={14} />}>
|
||||
Cache Pools ({array.caches.length})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="parity" pt="md">
|
||||
<Paper shadow="xs" radius="md" withBorder>
|
||||
<Table fontSize="sm" verticalSpacing="sm" highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
<th>Size</th>
|
||||
<th>FS</th>
|
||||
<th>Temp</th>
|
||||
<th>I/O</th>
|
||||
<th>Errors</th>
|
||||
<th>Usage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{array.parities.map((disk) => (
|
||||
<DiskDetailsRow key={disk.id} disk={disk} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="data" pt="md">
|
||||
<Paper shadow="xs" radius="md" withBorder>
|
||||
<Table fontSize="sm" verticalSpacing="sm" highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Model</th>
|
||||
<th>Status</th>
|
||||
<th>Size</th>
|
||||
<th>FS</th>
|
||||
<th>Temp</th>
|
||||
<th>I/O</th>
|
||||
<th>Errors</th>
|
||||
<th>Usage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{array.disks.map((disk) => (
|
||||
<DiskDetailsRow key={disk.id} disk={disk} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="cache" pt="md">
|
||||
<Stack spacing="md">
|
||||
{array.caches.map((cache) => {
|
||||
const cacheUsedPercent = ((cache.fsUsed / cache.fsSize) * 100).toFixed(1);
|
||||
return (
|
||||
<Card key={cache.id} shadow="sm" radius="md" withBorder>
|
||||
<Group position="apart" mb="md">
|
||||
<div>
|
||||
<Text weight={600}>{cache.name}</Text>
|
||||
<Text size="sm" color="dimmed">
|
||||
{cache.fsType} • {cache.devices.length} device(s)
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
<Text size="sm">
|
||||
{formatBytes(cache.fsUsed)} / {formatBytes(cache.fsSize)}
|
||||
</Text>
|
||||
<Badge
|
||||
color={
|
||||
parseFloat(cacheUsedPercent) > 90
|
||||
? 'red'
|
||||
: parseFloat(cacheUsedPercent) > 75
|
||||
? 'orange'
|
||||
: 'teal'
|
||||
}
|
||||
>
|
||||
{cacheUsedPercent}%
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Progress
|
||||
value={parseFloat(cacheUsedPercent)}
|
||||
size="md"
|
||||
radius="md"
|
||||
color={
|
||||
parseFloat(cacheUsedPercent) > 90
|
||||
? 'red'
|
||||
: parseFloat(cacheUsedPercent) > 75
|
||||
? 'orange'
|
||||
: 'teal'
|
||||
}
|
||||
/>
|
||||
|
||||
{cache.devices.length > 0 && (
|
||||
<Table fontSize="sm" mt="md" verticalSpacing={4}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Model</th>
|
||||
<th>Size</th>
|
||||
<th>Temp</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cache.devices.map((device) => (
|
||||
<tr key={device.id}>
|
||||
<td>
|
||||
<Group spacing="xs">
|
||||
<IconHardDrive size={14} />
|
||||
<Text size="sm">{device.name}</Text>
|
||||
</Group>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm">{device.model}</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm">{formatBytes(device.size)}</Text>
|
||||
</td>
|
||||
<td>
|
||||
{device.temp !== null ? (
|
||||
<Text
|
||||
size="sm"
|
||||
color={
|
||||
device.temp > 50
|
||||
? 'red'
|
||||
: device.temp > 40
|
||||
? 'orange'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{device.temp}°C
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm" color="dimmed">
|
||||
{device.spunDown ? 'Standby' : '-'}
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{array.caches.length === 0 && (
|
||||
<Text color="dimmed" align="center" py="xl">
|
||||
No cache pools configured
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
476
src/pages/unraid/docker/index.tsx
Normal file
476
src/pages/unraid/docker/index.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Docker Management Page
|
||||
* Full Docker container management with details
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconBrandDocker,
|
||||
IconBox,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconDots,
|
||||
IconSearch,
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconNetwork,
|
||||
IconRefresh,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { api } from '~/utils/api';
|
||||
import type { DockerContainer, ContainerState } from '~/lib/unraid/types';
|
||||
|
||||
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 formatDate(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function ContainerCard({
|
||||
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 (
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Card.Section withBorder inheritPadding py="xs">
|
||||
<Group position="apart">
|
||||
<Group spacing="sm">
|
||||
<ThemeIcon size="lg" variant="light" color={getStateColor(container.state)}>
|
||||
<IconBox size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text weight={600} lineClamp={1}>
|
||||
{containerName}
|
||||
</Text>
|
||||
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||
{container.image}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group spacing="xs">
|
||||
<Badge color={getStateColor(container.state)} variant="light">
|
||||
{container.state}
|
||||
</Badge>
|
||||
|
||||
{isRunning ? (
|
||||
<Tooltip label="Stop Container">
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={onStop}
|
||||
loading={isLoading}
|
||||
>
|
||||
<IconPlayerStop size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip label="Start Container">
|
||||
<ActionIcon
|
||||
color="green"
|
||||
variant="light"
|
||||
onClick={onStart}
|
||||
loading={isLoading}
|
||||
>
|
||||
<IconPlayerPlay size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Menu shadow="md" width={150} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item disabled>View Logs</Menu.Item>
|
||||
<Menu.Item disabled>Console</Menu.Item>
|
||||
<Menu.Item disabled>Edit</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item color="red" disabled>
|
||||
Remove
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Stack spacing="xs" mt="sm">
|
||||
{/* Network */}
|
||||
<Group spacing="xs">
|
||||
<IconNetwork size={14} />
|
||||
<Text size="sm" color="dimmed">
|
||||
{container.networkMode}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Ports */}
|
||||
{container.ports.length > 0 && (
|
||||
<Group spacing="xs">
|
||||
<Text size="xs" weight={500}>
|
||||
Ports:
|
||||
</Text>
|
||||
{container.ports.slice(0, 3).map((port, idx) => (
|
||||
<Badge key={idx} size="xs" variant="outline">
|
||||
{port.publicPort ? `${port.publicPort}:` : ''}
|
||||
{port.privatePort}/{port.type}
|
||||
</Badge>
|
||||
))}
|
||||
{container.ports.length > 3 && (
|
||||
<Badge size="xs" variant="outline" color="gray">
|
||||
+{container.ports.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
<Group position="apart">
|
||||
<Text size="xs" color="dimmed">
|
||||
{container.status}
|
||||
</Text>
|
||||
{container.autoStart && (
|
||||
<Badge size="xs" color="blue" variant="dot">
|
||||
Auto-start
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DockerPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 200);
|
||||
const [filter, setFilter] = useState<'all' | 'running' | 'stopped'>('all');
|
||||
const [loadingContainers, setLoadingContainers] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
data: docker,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.unraid.docker.useQuery(undefined, {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
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: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message,
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
const filteredContainers = docker?.containers.filter((container) => {
|
||||
const name = container.names[0]?.replace(/^\//, '') || '';
|
||||
const matchesSearch =
|
||||
name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
container.image.toLowerCase().includes(debouncedSearch.toLowerCase());
|
||||
|
||||
const matchesFilter =
|
||||
filter === 'all' ||
|
||||
(filter === 'running' && container.state === 'RUNNING') ||
|
||||
(filter === 'stopped' && container.state !== 'RUNNING');
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
const runningCount = docker?.containers.filter((c) => c.state === 'RUNNING').length || 0;
|
||||
const stoppedCount = (docker?.containers.length || 0) - runningCount;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<Loader size="xl" />
|
||||
<Text color="dimmed">Loading Docker data...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error.message}
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack spacing="xl">
|
||||
{/* Header */}
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="cyan">
|
||||
<IconBrandDocker size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>Docker Containers</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
{runningCount} running, {stoppedCount} stopped
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconRefresh size={16} />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={3}>
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Total Containers
|
||||
</Text>
|
||||
<Text size="xl" weight={700}>
|
||||
{docker?.containers.length || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Running
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="green">
|
||||
{runningCount}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Networks
|
||||
</Text>
|
||||
<Text size="xl" weight={700}>
|
||||
{docker?.networks.length || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Filters */}
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Search containers..."
|
||||
icon={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1, maxWidth: 300 }}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as any)}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Running', value: 'running' },
|
||||
{ label: 'Stopped', value: 'stopped' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Container Grid */}
|
||||
<Grid>
|
||||
{filteredContainers?.map((container) => (
|
||||
<Grid.Col key={container.id} sm={6} lg={4}>
|
||||
<ContainerCard
|
||||
container={container}
|
||||
onStart={() => startContainer.mutate({ id: container.id })}
|
||||
onStop={() => stopContainer.mutate({ id: container.id })}
|
||||
isLoading={loadingContainers.includes(container.id)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{filteredContainers?.length === 0 && (
|
||||
<Text color="dimmed" align="center" py="xl">
|
||||
No containers found
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Networks */}
|
||||
{docker?.networks && docker.networks.length > 0 && (
|
||||
<div>
|
||||
<Title order={4} mb="md">
|
||||
Networks
|
||||
</Title>
|
||||
<Paper shadow="xs" radius="md" withBorder>
|
||||
<Table fontSize="sm" verticalSpacing="sm" highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Driver</th>
|
||||
<th>Scope</th>
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docker.networks.map((network) => (
|
||||
<tr key={network.id}>
|
||||
<td>
|
||||
<Group spacing="xs">
|
||||
<IconNetwork size={14} />
|
||||
<Text weight={500}>{network.name}</Text>
|
||||
</Group>
|
||||
</td>
|
||||
<td>
|
||||
<Badge size="sm" variant="outline">
|
||||
{network.driver}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm" color="dimmed">
|
||||
{network.scope}
|
||||
</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="xs" color="dimmed" style={{ fontFamily: 'monospace' }}>
|
||||
{network.id.substring(0, 12)}
|
||||
</Text>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -21,6 +21,7 @@ import { IconServer, IconAlertCircle, IconCheck } from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { SystemInfoCard, ArrayCard, DockerCard, VmsCard } from '~/components/Unraid/Dashboard';
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
@@ -219,66 +220,75 @@ export default function UnraidDashboardPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const unreadNotifications = dashboard?.notifications?.filter((n) => !n.read).length || 0;
|
||||
|
||||
// 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>
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<Loader size="xl" />
|
||||
<Text color="dimmed">Connecting to Unraid server...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Alert
|
||||
icon={<IconAlertCircle size={16} />}
|
||||
title="Connection Error"
|
||||
color="red"
|
||||
variant="filled"
|
||||
>
|
||||
{error.message}
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
<UnraidLayout>
|
||||
<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>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<UnraidLayout notifications={unreadNotifications}>
|
||||
<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>
|
||||
</Group>
|
||||
|
||||
{/* Dashboard Grid */}
|
||||
<Grid>
|
||||
@@ -326,6 +336,7 @@ export default function UnraidDashboardPage() {
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
288
src/pages/unraid/settings/identification.tsx
Normal file
288
src/pages/unraid/settings/identification.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Identification Settings Page
|
||||
* Server name, description, and basic settings
|
||||
*/
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconServer,
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconDeviceFloppy,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
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 IdentificationSettingsPage() {
|
||||
const {
|
||||
data: vars,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.unraid.vars.useQuery();
|
||||
|
||||
const {
|
||||
data: info,
|
||||
} = api.unraid.info.useQuery();
|
||||
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: '',
|
||||
description: '',
|
||||
model: '',
|
||||
timezone: '',
|
||||
},
|
||||
});
|
||||
|
||||
// Update form when data loads
|
||||
if (vars && !form.isTouched()) {
|
||||
form.setValues({
|
||||
name: vars.name || '',
|
||||
description: vars.comment || '',
|
||||
model: vars.flashProduct || '',
|
||||
timezone: vars.timezone || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="md" py="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<Loader size="xl" />
|
||||
<Text color="dimmed">Loading settings...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="md" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error.message}
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="md" py="xl">
|
||||
<Stack spacing="xl">
|
||||
{/* Header */}
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="blue">
|
||||
<IconServer size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>Identification</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
Server name and basic settings
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Server Identity */}
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
Server Identity
|
||||
</Title>
|
||||
|
||||
<Stack spacing="md">
|
||||
<TextInput
|
||||
label="Server Name"
|
||||
description="The name of your Unraid server"
|
||||
placeholder="Tower"
|
||||
{...form.getInputProps('name')}
|
||||
disabled
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
label="Description"
|
||||
description="A brief description of this server"
|
||||
placeholder="Home media server"
|
||||
{...form.getInputProps('description')}
|
||||
disabled
|
||||
/>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* System Information */}
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
System Information
|
||||
</Title>
|
||||
|
||||
<Stack spacing="md">
|
||||
<Group position="apart">
|
||||
<Text size="sm" color="dimmed">
|
||||
Unraid Version
|
||||
</Text>
|
||||
<Text size="sm" weight={500}>
|
||||
{info?.versions.unraid || 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group position="apart">
|
||||
<Text size="sm" color="dimmed">
|
||||
Linux Kernel
|
||||
</Text>
|
||||
<Text size="sm" weight={500}>
|
||||
{info?.versions.linux || 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group position="apart">
|
||||
<Text size="sm" color="dimmed">
|
||||
CPU
|
||||
</Text>
|
||||
<Text size="sm" weight={500}>
|
||||
{info?.cpu.model || 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group position="apart">
|
||||
<Text size="sm" color="dimmed">
|
||||
Motherboard
|
||||
</Text>
|
||||
<Text size="sm" weight={500}>
|
||||
{info?.motherboard?.product || 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group position="apart">
|
||||
<Text size="sm" color="dimmed">
|
||||
Total RAM
|
||||
</Text>
|
||||
<Text size="sm" weight={500}>
|
||||
{info?.memory?.total
|
||||
? `${Math.round(info.memory.total / 1024 / 1024 / 1024)} GB`
|
||||
: 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group position="apart">
|
||||
<Text size="sm" color="dimmed">
|
||||
Timezone
|
||||
</Text>
|
||||
<Text size="sm" weight={500}>
|
||||
{vars?.timezone || 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Flash Drive */}
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
Flash Drive
|
||||
</Title>
|
||||
|
||||
<Stack spacing="md">
|
||||
<Group position="apart">
|
||||
<Text size="sm" color="dimmed">
|
||||
Product
|
||||
</Text>
|
||||
<Text size="sm" weight={500}>
|
||||
{vars?.flashProduct || 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group position="apart">
|
||||
<Text size="sm" color="dimmed">
|
||||
Vendor
|
||||
</Text>
|
||||
<Text size="sm" weight={500}>
|
||||
{vars?.flashVendor || 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group position="apart">
|
||||
<Text size="sm" color="dimmed">
|
||||
GUID
|
||||
</Text>
|
||||
<Text size="sm" weight={500} style={{ fontFamily: 'monospace' }}>
|
||||
{vars?.flashGuid || 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Save Button (disabled for now) */}
|
||||
<Group position="right">
|
||||
<Button
|
||||
leftIcon={<IconDeviceFloppy size={16} />}
|
||||
disabled
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
183
src/pages/unraid/settings/index.tsx
Normal file
183
src/pages/unraid/settings/index.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Settings Index Page
|
||||
* Overview of all Unraid settings
|
||||
*/
|
||||
|
||||
import {
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconSettings,
|
||||
IconServer,
|
||||
IconDatabase,
|
||||
IconNetwork,
|
||||
IconBrandDocker,
|
||||
IconServer2,
|
||||
IconTool,
|
||||
IconCpu,
|
||||
IconBell,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
|
||||
interface SettingItem {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
description: string;
|
||||
href: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const settingsItems: SettingItem[] = [
|
||||
{
|
||||
icon: IconServer,
|
||||
label: 'Identification',
|
||||
description: 'Server name, description, and basic settings',
|
||||
href: '/unraid/settings/identification',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
icon: IconDatabase,
|
||||
label: 'Disk Settings',
|
||||
description: 'Disk tuning, spin down, and power settings',
|
||||
href: '/unraid/settings/disk',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: IconNetwork,
|
||||
label: 'Network Settings',
|
||||
description: 'Network interfaces, bonding, and bridging',
|
||||
href: '/unraid/settings/network',
|
||||
color: 'cyan',
|
||||
},
|
||||
{
|
||||
icon: IconBrandDocker,
|
||||
label: 'Docker',
|
||||
description: 'Docker daemon configuration and settings',
|
||||
href: '/unraid/settings/docker',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
icon: IconServer2,
|
||||
label: 'VM Manager',
|
||||
description: 'Virtualization settings and IOMMU groups',
|
||||
href: '/unraid/settings/vm',
|
||||
color: 'violet',
|
||||
},
|
||||
{
|
||||
icon: IconTool,
|
||||
label: 'Management Access',
|
||||
description: 'SSH, Telnet, HTTPS, and access control',
|
||||
href: '/unraid/settings/management',
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
icon: IconCpu,
|
||||
label: 'CPU Pinning',
|
||||
description: 'CPU isolation and core assignment',
|
||||
href: '/unraid/settings/cpu',
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
icon: IconBell,
|
||||
label: 'Notifications',
|
||||
description: 'Email, Slack, and notification settings',
|
||||
href: '/unraid/settings/notifications',
|
||||
color: 'yellow',
|
||||
},
|
||||
];
|
||||
|
||||
function SettingCard({ item }: { item: SettingItem }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => router.push(item.href)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Card shadow="sm" radius="md" withBorder style={{ height: '100%' }}>
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color={item.color}>
|
||||
<item.icon size={24} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text weight={600}>{item.label}</Text>
|
||||
<Text size="sm" color="dimmed">
|
||||
{item.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsIndexPage() {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack spacing="xl">
|
||||
{/* Header */}
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="gray">
|
||||
<IconSettings size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>Settings</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
Configure your Unraid server
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Settings Grid */}
|
||||
<Grid>
|
||||
{settingsItems.map((item) => (
|
||||
<Grid.Col key={item.href} sm={6} lg={4}>
|
||||
<SettingCard item={item} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
459
src/pages/unraid/settings/notifications.tsx
Normal file
459
src/pages/unraid/settings/notifications.tsx
Normal file
@@ -0,0 +1,459 @@
|
||||
/**
|
||||
* Notifications Settings Page
|
||||
* Configure notification preferences and view notification history
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { notifications as mantineNotifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconBell,
|
||||
IconBellOff,
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconTrash,
|
||||
IconDots,
|
||||
IconRefresh,
|
||||
IconMail,
|
||||
IconBrandSlack,
|
||||
IconBrandDiscord,
|
||||
IconAlertTriangle,
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { api } from '~/utils/api';
|
||||
import type { Notification, NotificationImportance } from '~/lib/unraid/types';
|
||||
|
||||
function getImportanceColor(importance: NotificationImportance): string {
|
||||
switch (importance) {
|
||||
case 'ALERT':
|
||||
return 'red';
|
||||
case 'WARNING':
|
||||
return 'yellow';
|
||||
case 'NORMAL':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
function getImportanceIcon(importance: NotificationImportance) {
|
||||
switch (importance) {
|
||||
case 'ALERT':
|
||||
return <IconAlertCircle size={14} />;
|
||||
case 'WARNING':
|
||||
return <IconAlertTriangle size={14} />;
|
||||
case 'NORMAL':
|
||||
return <IconInfoCircle size={14} />;
|
||||
default:
|
||||
return <IconBell size={14} />;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function NotificationItem({
|
||||
notification,
|
||||
onMarkRead,
|
||||
onDelete,
|
||||
}: {
|
||||
notification: Notification;
|
||||
onMarkRead: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper
|
||||
p="md"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
opacity: notification.read ? 0.7 : 1,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: `var(--mantine-color-${getImportanceColor(notification.importance)}-6)`,
|
||||
}}
|
||||
>
|
||||
<Group position="apart" noWrap>
|
||||
<Group spacing="sm" noWrap style={{ flex: 1 }}>
|
||||
<ThemeIcon
|
||||
size="md"
|
||||
variant="light"
|
||||
color={getImportanceColor(notification.importance)}
|
||||
>
|
||||
{getImportanceIcon(notification.importance)}
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group spacing="xs" noWrap>
|
||||
<Text weight={600} lineClamp={1}>
|
||||
{notification.subject}
|
||||
</Text>
|
||||
{!notification.read && (
|
||||
<Badge size="xs" color="blue" variant="filled">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="sm" color="dimmed" lineClamp={2}>
|
||||
{notification.description}
|
||||
</Text>
|
||||
<Group spacing="xs" mt="xs">
|
||||
<Badge size="xs" variant="outline">
|
||||
{notification.type}
|
||||
</Badge>
|
||||
<Text size="xs" color="dimmed">
|
||||
{formatDate(notification.timestamp)}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Menu shadow="md" width={150} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{!notification.read && (
|
||||
<Menu.Item
|
||||
icon={<IconCheck size={14} />}
|
||||
onClick={onMarkRead}
|
||||
>
|
||||
Mark as Read
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
color="red"
|
||||
icon={<IconTrash size={14} />}
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationsSettingsPage() {
|
||||
const [filter, setFilter] = useState<'all' | 'unread'>('all');
|
||||
|
||||
const {
|
||||
data: notificationsList,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.unraid.notifications.useQuery();
|
||||
|
||||
const markRead = api.unraid.markNotificationRead.useMutation({
|
||||
onSuccess: () => {
|
||||
mantineNotifications.show({
|
||||
title: 'Marked as Read',
|
||||
message: 'Notification marked as read',
|
||||
color: 'green',
|
||||
icon: <IconCheck size={16} />,
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteNotification = api.unraid.deleteNotification.useMutation({
|
||||
onSuccess: () => {
|
||||
mantineNotifications.show({
|
||||
title: 'Deleted',
|
||||
message: 'Notification deleted',
|
||||
color: 'green',
|
||||
icon: <IconCheck size={16} />,
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const markAllRead = api.unraid.markAllNotificationsRead.useMutation({
|
||||
onSuccess: () => {
|
||||
mantineNotifications.show({
|
||||
title: 'All Read',
|
||||
message: 'All notifications marked as read',
|
||||
color: 'green',
|
||||
icon: <IconCheck size={16} />,
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const filteredNotifications = notificationsList?.filter((n) =>
|
||||
filter === 'all' ? true : !n.read
|
||||
);
|
||||
|
||||
const unreadCount = notificationsList?.filter((n) => !n.read).length || 0;
|
||||
const alertCount = notificationsList?.filter((n) => n.importance === 'ALERT').length || 0;
|
||||
const warningCount = notificationsList?.filter((n) => n.importance === 'WARNING').length || 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="lg" py="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<Loader size="xl" />
|
||||
<Text color="dimmed">Loading notifications...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="lg" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error.message}
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="lg" py="xl">
|
||||
<Stack spacing="xl">
|
||||
{/* Header */}
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="yellow">
|
||||
<IconBell size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>Notifications</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
{unreadCount} unread notification{unreadCount !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
{unreadCount > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconCheck size={16} />}
|
||||
onClick={() => markAllRead.mutate()}
|
||||
loading={markAllRead.isLoading}
|
||||
>
|
||||
Mark All Read
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconRefresh size={16} />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Stats */}
|
||||
<Group>
|
||||
<Card shadow="sm" radius="md" withBorder style={{ flex: 1 }}>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Unread
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="blue">
|
||||
{unreadCount}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder style={{ flex: 1 }}>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Alerts
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="red">
|
||||
{alertCount}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder style={{ flex: 1 }}>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Warnings
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="yellow">
|
||||
{warningCount}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder style={{ flex: 1 }}>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Total
|
||||
</Text>
|
||||
<Text size="xl" weight={700}>
|
||||
{notificationsList?.length || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
</Group>
|
||||
|
||||
{/* Notification Settings (placeholder) */}
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
Notification Channels
|
||||
</Title>
|
||||
|
||||
<Stack spacing="md">
|
||||
<Group position="apart">
|
||||
<Group spacing="sm">
|
||||
<ThemeIcon size="md" variant="light" color="blue">
|
||||
<IconMail size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text weight={500}>Email Notifications</Text>
|
||||
<Text size="xs" color="dimmed">
|
||||
Send notifications via email
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Switch disabled />
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group position="apart">
|
||||
<Group spacing="sm">
|
||||
<ThemeIcon size="md" variant="light" color="grape">
|
||||
<IconBrandSlack size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text weight={500}>Slack Notifications</Text>
|
||||
<Text size="xs" color="dimmed">
|
||||
Send notifications to Slack
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Switch disabled />
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Group position="apart">
|
||||
<Group spacing="sm">
|
||||
<ThemeIcon size="md" variant="light" color="indigo">
|
||||
<IconBrandDiscord size={16} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text weight={500}>Discord Notifications</Text>
|
||||
<Text size="xs" color="dimmed">
|
||||
Send notifications to Discord
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Switch disabled />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Filter */}
|
||||
<Group>
|
||||
<Button
|
||||
variant={filter === 'all' ? 'filled' : 'light'}
|
||||
onClick={() => setFilter('all')}
|
||||
size="sm"
|
||||
>
|
||||
All ({notificationsList?.length || 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'unread' ? 'filled' : 'light'}
|
||||
onClick={() => setFilter('unread')}
|
||||
size="sm"
|
||||
color="blue"
|
||||
>
|
||||
Unread ({unreadCount})
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Notifications List */}
|
||||
<Stack spacing="sm">
|
||||
{filteredNotifications?.map((notification) => (
|
||||
<NotificationItem
|
||||
key={notification.id}
|
||||
notification={notification}
|
||||
onMarkRead={() => markRead.mutate({ id: notification.id })}
|
||||
onDelete={() => deleteNotification.mutate({ id: notification.id })}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filteredNotifications?.length === 0 && (
|
||||
<Card shadow="sm" radius="md" withBorder p="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<ThemeIcon size={48} variant="light" color="gray">
|
||||
<IconBellOff size={24} />
|
||||
</ThemeIcon>
|
||||
<Text color="dimmed">
|
||||
{filter === 'unread'
|
||||
? 'No unread notifications'
|
||||
: 'No notifications'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
411
src/pages/unraid/shares/index.tsx
Normal file
411
src/pages/unraid/shares/index.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* Shares Management Page
|
||||
* View and manage Unraid shares
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
Progress,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
IconFolders,
|
||||
IconFolder,
|
||||
IconSearch,
|
||||
IconAlertCircle,
|
||||
IconRefresh,
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconDots,
|
||||
IconUsers,
|
||||
IconWorld,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { api } from '~/utils/api';
|
||||
import type { Share, ShareSecurityLevel } from '~/lib/unraid/types';
|
||||
|
||||
function getSecurityColor(security: ShareSecurityLevel): string {
|
||||
switch (security) {
|
||||
case 'PUBLIC':
|
||||
return 'green';
|
||||
case 'SECURE':
|
||||
return 'blue';
|
||||
case 'PRIVATE':
|
||||
return 'red';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
}
|
||||
|
||||
function getSecurityIcon(security: ShareSecurityLevel) {
|
||||
switch (security) {
|
||||
case 'PUBLIC':
|
||||
return <IconWorld size={14} />;
|
||||
case 'SECURE':
|
||||
return <IconUsers size={14} />;
|
||||
case 'PRIVATE':
|
||||
return <IconLock size={14} />;
|
||||
default:
|
||||
return <IconLockOpen size={14} />;
|
||||
}
|
||||
}
|
||||
|
||||
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 ShareCard({ share }: { share: Share }) {
|
||||
const usedPercent = share.size > 0 ? (share.used / share.size) * 100 : 0;
|
||||
const usedColor = usedPercent > 90 ? 'red' : usedPercent > 75 ? 'yellow' : 'green';
|
||||
|
||||
return (
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Card.Section withBorder inheritPadding py="xs">
|
||||
<Group position="apart">
|
||||
<Group spacing="sm">
|
||||
<ThemeIcon size="lg" variant="light" color="orange">
|
||||
<IconFolder size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text weight={600} lineClamp={1}>
|
||||
{share.name}
|
||||
</Text>
|
||||
{share.comment && (
|
||||
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||
{share.comment}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group spacing="xs">
|
||||
<Badge
|
||||
color={getSecurityColor(share.security)}
|
||||
variant="light"
|
||||
leftSection={getSecurityIcon(share.security)}
|
||||
>
|
||||
{share.security}
|
||||
</Badge>
|
||||
|
||||
<Menu shadow="md" width={150} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item disabled>Browse</Menu.Item>
|
||||
<Menu.Item disabled>Edit</Menu.Item>
|
||||
<Menu.Item disabled>Permissions</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item color="red" disabled>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Stack spacing="xs" mt="sm">
|
||||
{/* Storage usage */}
|
||||
{share.size > 0 && (
|
||||
<>
|
||||
<Group position="apart">
|
||||
<Text size="sm" color="dimmed">
|
||||
Storage
|
||||
</Text>
|
||||
<Text size="sm" weight={500}>
|
||||
{formatBytes(share.used)} / {formatBytes(share.size)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress value={usedPercent} color={usedColor} size="sm" radius="xl" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Allocation method */}
|
||||
<Group position="apart">
|
||||
<Text size="xs" color="dimmed">
|
||||
Allocation
|
||||
</Text>
|
||||
<Badge size="xs" variant="outline">
|
||||
{share.allocator}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Include/Exclude disks */}
|
||||
<Group position="apart">
|
||||
<Text size="xs" color="dimmed">
|
||||
Disks
|
||||
</Text>
|
||||
<Text size="xs">
|
||||
{share.include?.length ? `Include: ${share.include.join(', ')}` : 'All disks'}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Floor and split level */}
|
||||
<SimpleGrid cols={2}>
|
||||
<Group spacing="xs">
|
||||
<Text size="xs" color="dimmed">
|
||||
Floor:
|
||||
</Text>
|
||||
<Text size="xs" weight={500}>
|
||||
{formatBytes(share.floor)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group spacing="xs">
|
||||
<Text size="xs" color="dimmed">
|
||||
Split:
|
||||
</Text>
|
||||
<Text size="xs" weight={500}>
|
||||
Level {share.splitLevel}
|
||||
</Text>
|
||||
</Group>
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SharesPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 200);
|
||||
const [filter, setFilter] = useState<'all' | 'public' | 'secure' | 'private'>('all');
|
||||
|
||||
const {
|
||||
data: shares,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.unraid.shares.useQuery(undefined, {
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
const filteredShares = shares?.filter((share) => {
|
||||
const matchesSearch =
|
||||
share.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
share.comment?.toLowerCase().includes(debouncedSearch.toLowerCase());
|
||||
|
||||
const matchesFilter =
|
||||
filter === 'all' ||
|
||||
(filter === 'public' && share.security === 'PUBLIC') ||
|
||||
(filter === 'secure' && share.security === 'SECURE') ||
|
||||
(filter === 'private' && share.security === 'PRIVATE');
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
const publicCount = shares?.filter((s) => s.security === 'PUBLIC').length || 0;
|
||||
const secureCount = shares?.filter((s) => s.security === 'SECURE').length || 0;
|
||||
const privateCount = shares?.filter((s) => s.security === 'PRIVATE').length || 0;
|
||||
|
||||
const totalUsed = shares?.reduce((sum, s) => sum + s.used, 0) || 0;
|
||||
const totalSize = shares?.reduce((sum, s) => sum + s.size, 0) || 0;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<Loader size="xl" />
|
||||
<Text color="dimmed">Loading shares...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error.message}
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack spacing="xl">
|
||||
{/* Header */}
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="orange">
|
||||
<IconFolders size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>Shares</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
{shares?.length || 0} shares configured
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconRefresh size={16} />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={4}>
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Total Shares
|
||||
</Text>
|
||||
<Text size="xl" weight={700}>
|
||||
{shares?.length || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Public
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="green">
|
||||
{publicCount}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Secure
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="blue">
|
||||
{secureCount}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Private
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="red">
|
||||
{privateCount}
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Total storage */}
|
||||
{totalSize > 0 && (
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Group position="apart" mb="xs">
|
||||
<Text size="sm" weight={500}>
|
||||
Total Storage Usage
|
||||
</Text>
|
||||
<Text size="sm" color="dimmed">
|
||||
{formatBytes(totalUsed)} / {formatBytes(totalSize)} (
|
||||
{((totalUsed / totalSize) * 100).toFixed(1)}%)
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={(totalUsed / totalSize) * 100}
|
||||
color={(totalUsed / totalSize) * 100 > 90 ? 'red' : 'green'}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Search shares..."
|
||||
icon={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1, maxWidth: 300 }}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as any)}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Public', value: 'public' },
|
||||
{ label: 'Secure', value: 'secure' },
|
||||
{ label: 'Private', value: 'private' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Shares Grid */}
|
||||
<Grid>
|
||||
{filteredShares?.map((share) => (
|
||||
<Grid.Col key={share.name} sm={6} lg={4}>
|
||||
<ShareCard share={share} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{filteredShares?.length === 0 && (
|
||||
<Text color="dimmed" align="center" py="xl">
|
||||
No shares found
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
383
src/pages/unraid/tools/diagnostics.tsx
Normal file
383
src/pages/unraid/tools/diagnostics.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Diagnostics Page
|
||||
* Generate and download Unraid diagnostic reports
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconBug,
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconCpu,
|
||||
IconDatabase,
|
||||
IconServer,
|
||||
IconNetwork,
|
||||
IconBrandDocker,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
interface DiagnosticCheck {
|
||||
name: string;
|
||||
icon: React.ElementType;
|
||||
status: 'pending' | 'running' | 'success' | 'warning' | 'error';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export default function DiagnosticsPage() {
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [checks, setChecks] = useState<DiagnosticCheck[]>([
|
||||
{ name: 'System Information', icon: IconServer, status: 'pending' },
|
||||
{ name: 'CPU & Memory', icon: IconCpu, status: 'pending' },
|
||||
{ name: 'Array Status', icon: IconDatabase, status: 'pending' },
|
||||
{ name: 'Network Configuration', icon: IconNetwork, status: 'pending' },
|
||||
{ name: 'Docker Containers', icon: IconBrandDocker, status: 'pending' },
|
||||
]);
|
||||
|
||||
const {
|
||||
data: info,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.unraid.info.useQuery();
|
||||
|
||||
const {
|
||||
data: array,
|
||||
} = api.unraid.array.useQuery();
|
||||
|
||||
const {
|
||||
data: docker,
|
||||
} = api.unraid.docker.useQuery();
|
||||
|
||||
const runDiagnostics = async () => {
|
||||
setIsGenerating(true);
|
||||
setProgress(0);
|
||||
|
||||
// Simulate running diagnostics
|
||||
const steps = checks.length;
|
||||
for (let i = 0; i < steps; i++) {
|
||||
setChecks((prev) =>
|
||||
prev.map((check, idx) =>
|
||||
idx === i ? { ...check, status: 'running' } : check
|
||||
)
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Simulate random results
|
||||
const statuses: Array<'success' | 'warning' | 'error'> = ['success', 'success', 'success', 'warning'];
|
||||
const status = statuses[Math.floor(Math.random() * statuses.length)];
|
||||
|
||||
setChecks((prev) =>
|
||||
prev.map((check, idx) =>
|
||||
idx === i
|
||||
? {
|
||||
...check,
|
||||
status,
|
||||
message:
|
||||
status === 'warning'
|
||||
? 'Minor issues detected'
|
||||
: status === 'error'
|
||||
? 'Problems found'
|
||||
: 'All checks passed',
|
||||
}
|
||||
: check
|
||||
)
|
||||
);
|
||||
|
||||
setProgress(((i + 1) / steps) * 100);
|
||||
}
|
||||
|
||||
setIsGenerating(false);
|
||||
notifications.show({
|
||||
title: 'Diagnostics Complete',
|
||||
message: 'Diagnostic report has been generated',
|
||||
color: 'green',
|
||||
icon: <IconCheck size={16} />,
|
||||
});
|
||||
};
|
||||
|
||||
const resetDiagnostics = () => {
|
||||
setProgress(0);
|
||||
setChecks((prev) =>
|
||||
prev.map((check) => ({
|
||||
...check,
|
||||
status: 'pending',
|
||||
message: undefined,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusColor = (status: DiagnosticCheck['status']) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'green';
|
||||
case 'warning':
|
||||
return 'yellow';
|
||||
case 'error':
|
||||
return 'red';
|
||||
case 'running':
|
||||
return 'blue';
|
||||
default:
|
||||
return 'gray';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="lg" py="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<Loader size="xl" />
|
||||
<Text color="dimmed">Loading system information...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="lg" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error.message}
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="lg" py="xl">
|
||||
<Stack spacing="xl">
|
||||
{/* Header */}
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="red">
|
||||
<IconBug size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>Diagnostics</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
System health checks and diagnostic reports
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconRefresh size={16} />}
|
||||
onClick={resetDiagnostics}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<IconBug size={16} />}
|
||||
onClick={runDiagnostics}
|
||||
loading={isGenerating}
|
||||
>
|
||||
Run Diagnostics
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Progress */}
|
||||
{progress > 0 && (
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Group position="apart" mb="xs">
|
||||
<Text weight={500}>Diagnostic Progress</Text>
|
||||
<Text size="sm" color="dimmed">
|
||||
{Math.round(progress)}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={progress}
|
||||
color={progress === 100 ? 'green' : 'blue'}
|
||||
size="lg"
|
||||
radius="xl"
|
||||
animate={isGenerating}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Diagnostic Checks */}
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
System Checks
|
||||
</Title>
|
||||
|
||||
<Stack spacing="md">
|
||||
{checks.map((check, idx) => (
|
||||
<div key={check.name}>
|
||||
<Group position="apart">
|
||||
<Group spacing="sm">
|
||||
<ThemeIcon
|
||||
size="md"
|
||||
variant="light"
|
||||
color={getStatusColor(check.status)}
|
||||
>
|
||||
{check.status === 'running' ? (
|
||||
<Loader size={14} color="blue" />
|
||||
) : (
|
||||
<check.icon size={16} />
|
||||
)}
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text weight={500}>{check.name}</Text>
|
||||
{check.message && (
|
||||
<Text size="xs" color="dimmed">
|
||||
{check.message}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Text
|
||||
size="sm"
|
||||
weight={500}
|
||||
color={getStatusColor(check.status)}
|
||||
transform="uppercase"
|
||||
>
|
||||
{check.status}
|
||||
</Text>
|
||||
</Group>
|
||||
{idx < checks.length - 1 && <Divider mt="md" />}
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* System Summary */}
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
System Summary
|
||||
</Title>
|
||||
|
||||
<Stack spacing="md">
|
||||
<Group position="apart">
|
||||
<Text color="dimmed">Unraid Version</Text>
|
||||
<Text weight={500}>{info?.versions.unraid || 'Unknown'}</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Group position="apart">
|
||||
<Text color="dimmed">Linux Kernel</Text>
|
||||
<Text weight={500}>{info?.versions.linux || 'Unknown'}</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Group position="apart">
|
||||
<Text color="dimmed">Array Status</Text>
|
||||
<Text weight={500} color={array?.state === 'STARTED' ? 'green' : 'red'}>
|
||||
{array?.state || 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Group position="apart">
|
||||
<Text color="dimmed">Docker Containers</Text>
|
||||
<Text weight={500}>
|
||||
{docker?.containers.filter((c) => c.state === 'RUNNING').length || 0} running /{' '}
|
||||
{docker?.containers.length || 0} total
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Group position="apart">
|
||||
<Text color="dimmed">CPU</Text>
|
||||
<Text weight={500}>{info?.cpu.model || 'Unknown'}</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
<Group position="apart">
|
||||
<Text color="dimmed">Total RAM</Text>
|
||||
<Text weight={500}>
|
||||
{info?.memory?.total
|
||||
? `${Math.round(info.memory.total / 1024 / 1024 / 1024)} GB`
|
||||
: 'Unknown'}
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
{/* Download Report */}
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
Diagnostic Report
|
||||
</Title>
|
||||
|
||||
<Text size="sm" color="dimmed" mb="md">
|
||||
Generate a comprehensive diagnostic report for troubleshooting. This includes system
|
||||
logs, configuration files, and hardware information.
|
||||
</Text>
|
||||
|
||||
<List size="sm" spacing="xs" mb="md">
|
||||
<List.Item>System configuration and settings</List.Item>
|
||||
<List.Item>Hardware information (CPU, RAM, disks)</List.Item>
|
||||
<List.Item>Network configuration</List.Item>
|
||||
<List.Item>Docker container status</List.Item>
|
||||
<List.Item>Recent system logs</List.Item>
|
||||
<List.Item>Plugin information</List.Item>
|
||||
</List>
|
||||
|
||||
<Button
|
||||
leftIcon={<IconDownload size={16} />}
|
||||
variant="light"
|
||||
disabled
|
||||
>
|
||||
Download Diagnostic Report
|
||||
</Button>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
175
src/pages/unraid/tools/index.tsx
Normal file
175
src/pages/unraid/tools/index.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Tools Index Page
|
||||
* Overview of all Unraid tools
|
||||
*/
|
||||
|
||||
import {
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
UnstyledButton,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconTools,
|
||||
IconFileText,
|
||||
IconBug,
|
||||
IconDevices,
|
||||
IconTerminal2,
|
||||
IconPuzzle,
|
||||
IconDatabase,
|
||||
IconHistory,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
|
||||
interface ToolItem {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
description: string;
|
||||
href: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const toolItems: ToolItem[] = [
|
||||
{
|
||||
icon: IconFileText,
|
||||
label: 'System Log',
|
||||
description: 'View and search the syslog',
|
||||
href: '/unraid/tools/syslog',
|
||||
color: 'blue',
|
||||
},
|
||||
{
|
||||
icon: IconBug,
|
||||
label: 'Diagnostics',
|
||||
description: 'Generate diagnostic reports',
|
||||
href: '/unraid/tools/diagnostics',
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
icon: IconDevices,
|
||||
label: 'System Devices',
|
||||
description: 'View PCI and USB devices',
|
||||
href: '/unraid/tools/devices',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
icon: IconTerminal2,
|
||||
label: 'Terminal',
|
||||
description: 'Web-based terminal access',
|
||||
href: '/unraid/tools/terminal',
|
||||
color: 'gray',
|
||||
},
|
||||
{
|
||||
icon: IconPuzzle,
|
||||
label: 'Plugins',
|
||||
description: 'Manage Unraid plugins',
|
||||
href: '/unraid/tools/plugins',
|
||||
color: 'violet',
|
||||
},
|
||||
{
|
||||
icon: IconDatabase,
|
||||
label: 'Disk Log',
|
||||
description: 'View disk activity logs',
|
||||
href: '/unraid/tools/disklog',
|
||||
color: 'orange',
|
||||
},
|
||||
{
|
||||
icon: IconHistory,
|
||||
label: 'Update History',
|
||||
description: 'View update and change history',
|
||||
href: '/unraid/tools/history',
|
||||
color: 'cyan',
|
||||
},
|
||||
];
|
||||
|
||||
function ToolCard({ item }: { item: ToolItem }) {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={() => router.push(item.href)}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Card shadow="sm" radius="md" withBorder style={{ height: '100%' }}>
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color={item.color}>
|
||||
<item.icon size={24} />
|
||||
</ThemeIcon>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text weight={600}>{item.label}</Text>
|
||||
<Text size="sm" color="dimmed">
|
||||
{item.description}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ToolsIndexPage() {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack spacing="xl">
|
||||
{/* Header */}
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="gray">
|
||||
<IconTools size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>Tools</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
System utilities and diagnostics
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{/* Tools Grid */}
|
||||
<Grid>
|
||||
{toolItems.map((item) => (
|
||||
<Grid.Col key={item.href} sm={6} lg={4}>
|
||||
<ToolCard item={item} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
373
src/pages/unraid/tools/syslog.tsx
Normal file
373
src/pages/unraid/tools/syslog.tsx
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* System Log Page
|
||||
* View and search the Unraid syslog
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Code,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
IconFileText,
|
||||
IconSearch,
|
||||
IconAlertCircle,
|
||||
IconRefresh,
|
||||
IconDownload,
|
||||
IconPlayerPlay,
|
||||
IconPlayerPause,
|
||||
IconArrowDown,
|
||||
IconFilter,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { api } from '~/utils/api';
|
||||
|
||||
interface LogLine {
|
||||
timestamp: string;
|
||||
host: string;
|
||||
process: string;
|
||||
message: string;
|
||||
level: 'info' | 'warning' | 'error' | 'debug';
|
||||
}
|
||||
|
||||
function parseLogLine(line: string): LogLine | null {
|
||||
// Parse syslog format: "Jan 1 00:00:00 hostname process[pid]: message"
|
||||
const match = line.match(/^(\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2})\s+(\S+)\s+(\S+?)(?:\[\d+\])?:\s*(.*)$/);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
timestamp: '',
|
||||
host: '',
|
||||
process: '',
|
||||
message: line,
|
||||
level: 'info',
|
||||
};
|
||||
}
|
||||
|
||||
const [, timestamp, host, process, message] = match;
|
||||
|
||||
// Determine log level based on message content
|
||||
let level: LogLine['level'] = 'info';
|
||||
if (/error|fail|critical/i.test(message)) {
|
||||
level = 'error';
|
||||
} else if (/warn|warning/i.test(message)) {
|
||||
level = 'warning';
|
||||
} else if (/debug/i.test(message)) {
|
||||
level = 'debug';
|
||||
}
|
||||
|
||||
return { timestamp, host, process, message, level };
|
||||
}
|
||||
|
||||
function getLevelColor(level: LogLine['level']): string {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
return 'red';
|
||||
case 'warning':
|
||||
return 'yellow';
|
||||
case 'debug':
|
||||
return 'gray';
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
}
|
||||
|
||||
export default function SyslogPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||
const [levelFilter, setLevelFilter] = useState<'all' | 'error' | 'warning'>('all');
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const {
|
||||
data: syslog,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.unraid.syslog.useQuery(
|
||||
{ lines: 500 },
|
||||
{
|
||||
refetchInterval: autoScroll ? 5000 : false,
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (syslog) {
|
||||
setLines(syslog.lines || []);
|
||||
}
|
||||
}, [syslog]);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && scrollRef.current) {
|
||||
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
}, [lines, autoScroll]);
|
||||
|
||||
const parsedLines = lines
|
||||
.map(parseLogLine)
|
||||
.filter((line): line is LogLine => line !== null)
|
||||
.filter((line) => {
|
||||
const matchesSearch = !debouncedSearch ||
|
||||
line.message.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
line.process.toLowerCase().includes(debouncedSearch.toLowerCase());
|
||||
|
||||
const matchesLevel =
|
||||
levelFilter === 'all' ||
|
||||
(levelFilter === 'error' && line.level === 'error') ||
|
||||
(levelFilter === 'warning' && (line.level === 'warning' || line.level === 'error'));
|
||||
|
||||
return matchesSearch && matchesLevel;
|
||||
});
|
||||
|
||||
const errorCount = lines
|
||||
.map(parseLogLine)
|
||||
.filter((l) => l?.level === 'error').length;
|
||||
|
||||
const warningCount = lines
|
||||
.map(parseLogLine)
|
||||
.filter((l) => l?.level === 'warning').length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<Loader size="xl" />
|
||||
<Text color="dimmed">Loading system log...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error.message}
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack spacing="lg">
|
||||
{/* Header */}
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="blue">
|
||||
<IconFileText size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>System Log</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
Showing last {lines.length} lines
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Tooltip label={autoScroll ? 'Pause auto-refresh' : 'Resume auto-refresh'}>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color={autoScroll ? 'green' : 'gray'}
|
||||
size="lg"
|
||||
onClick={() => setAutoScroll(!autoScroll)}
|
||||
>
|
||||
{autoScroll ? <IconPlayerPause size={18} /> : <IconPlayerPlay size={18} />}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconRefresh size={16} />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconDownload size={16} />}
|
||||
disabled
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Stats */}
|
||||
<Group>
|
||||
<Card shadow="sm" radius="md" withBorder p="sm">
|
||||
<Group spacing="xs">
|
||||
<Text size="sm" color="dimmed">
|
||||
Total Lines:
|
||||
</Text>
|
||||
<Badge size="lg" variant="light">
|
||||
{lines.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder p="sm">
|
||||
<Group spacing="xs">
|
||||
<Text size="sm" color="dimmed">
|
||||
Errors:
|
||||
</Text>
|
||||
<Badge size="lg" color="red" variant="light">
|
||||
{errorCount}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder p="sm">
|
||||
<Group spacing="xs">
|
||||
<Text size="sm" color="dimmed">
|
||||
Warnings:
|
||||
</Text>
|
||||
<Badge size="lg" color="yellow" variant="light">
|
||||
{warningCount}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Card>
|
||||
</Group>
|
||||
|
||||
{/* Filters */}
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Search logs..."
|
||||
icon={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1, maxWidth: 400 }}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
value={levelFilter}
|
||||
onChange={(value) => setLevelFilter(value as any)}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Warnings+', value: 'warning' },
|
||||
{ label: 'Errors', value: 'error' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{autoScroll && (
|
||||
<Badge color="green" variant="dot">
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Log Viewer */}
|
||||
<Card shadow="sm" radius="md" withBorder p={0} style={{ overflow: 'hidden' }}>
|
||||
<ScrollArea
|
||||
h={600}
|
||||
viewportRef={scrollRef}
|
||||
style={{ backgroundColor: 'var(--mantine-color-dark-8)' }}
|
||||
>
|
||||
<Code
|
||||
block
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
fontSize: '12px',
|
||||
lineHeight: 1.6,
|
||||
padding: '16px',
|
||||
}}
|
||||
>
|
||||
{parsedLines.map((line, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
color:
|
||||
line.level === 'error'
|
||||
? 'var(--mantine-color-red-5)'
|
||||
: line.level === 'warning'
|
||||
? 'var(--mantine-color-yellow-5)'
|
||||
: 'inherit',
|
||||
}}
|
||||
>
|
||||
<span style={{ color: 'var(--mantine-color-dimmed)', minWidth: '140px' }}>
|
||||
{line.timestamp}
|
||||
</span>
|
||||
<span style={{ color: 'var(--mantine-color-cyan-5)', minWidth: '100px' }}>
|
||||
{line.process}
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>{line.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</Code>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
|
||||
{/* Scroll to bottom */}
|
||||
<Group position="center">
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftIcon={<IconArrowDown size={16} />}
|
||||
onClick={() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Scroll to Bottom
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
384
src/pages/unraid/users/index.tsx
Normal file
384
src/pages/unraid/users/index.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* Users Management Page
|
||||
* View and manage Unraid users
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import {
|
||||
IconUsers,
|
||||
IconUser,
|
||||
IconUserShield,
|
||||
IconSearch,
|
||||
IconAlertCircle,
|
||||
IconRefresh,
|
||||
IconDots,
|
||||
IconKey,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconUserPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { api } from '~/utils/api';
|
||||
import type { User } from '~/lib/unraid/types';
|
||||
|
||||
function UserCard({ user }: { user: User }) {
|
||||
const isAdmin = user.name === 'root';
|
||||
const initials = user.name.slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Card.Section withBorder inheritPadding py="xs">
|
||||
<Group position="apart">
|
||||
<Group spacing="sm">
|
||||
<Avatar color={isAdmin ? 'red' : 'blue'} radius="xl">
|
||||
{initials}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Group spacing="xs">
|
||||
<Text weight={600}>{user.name}</Text>
|
||||
{isAdmin && (
|
||||
<Badge size="xs" color="red" variant="filled">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
{user.description && (
|
||||
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||
{user.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Menu shadow="md" width={150} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item icon={<IconKey size={14} />} disabled>
|
||||
Change Password
|
||||
</Menu.Item>
|
||||
<Menu.Item icon={<IconEdit size={14} />} disabled>
|
||||
Edit
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
icon={<IconTrash size={14} />}
|
||||
disabled={isAdmin}
|
||||
>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Stack spacing="xs" mt="sm">
|
||||
{/* UID */}
|
||||
<Group position="apart">
|
||||
<Text size="xs" color="dimmed">
|
||||
UID
|
||||
</Text>
|
||||
<Text size="xs" weight={500} style={{ fontFamily: 'monospace' }}>
|
||||
{user.id}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Role indicator */}
|
||||
<Group position="apart">
|
||||
<Text size="xs" color="dimmed">
|
||||
Role
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={isAdmin ? 'red' : 'blue'}
|
||||
variant="light"
|
||||
leftSection={isAdmin ? <IconUserShield size={12} /> : <IconUser size={12} />}
|
||||
>
|
||||
{isAdmin ? 'Administrator' : 'User'}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 200);
|
||||
|
||||
const {
|
||||
data: users,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.unraid.users.useQuery(undefined, {
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const filteredUsers = users?.filter((user) =>
|
||||
user.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||
user.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||
);
|
||||
|
||||
const adminCount = users?.filter((u) => u.name === 'root').length || 0;
|
||||
const userCount = (users?.length || 0) - adminCount;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<Loader size="xl" />
|
||||
<Text color="dimmed">Loading users...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error.message}
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack spacing="xl">
|
||||
{/* Header */}
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="teal">
|
||||
<IconUsers size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>Users</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
{users?.length || 0} users configured
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconUserPlus size={16} />}
|
||||
disabled
|
||||
>
|
||||
Add User
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconRefresh size={16} />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={3}>
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Total Users
|
||||
</Text>
|
||||
<Text size="xl" weight={700}>
|
||||
{users?.length || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Administrators
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="red">
|
||||
{adminCount}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Regular Users
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="blue">
|
||||
{userCount}
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Search */}
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Search users..."
|
||||
icon={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1, maxWidth: 300 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Users Grid */}
|
||||
<Grid>
|
||||
{filteredUsers?.map((user) => (
|
||||
<Grid.Col key={user.id} sm={6} lg={4}>
|
||||
<UserCard user={user} />
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{filteredUsers?.length === 0 && (
|
||||
<Text color="dimmed" align="center" py="xl">
|
||||
No users found
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Users Table (alternative view) */}
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Title order={4} mb="md">
|
||||
User List
|
||||
</Title>
|
||||
<Paper shadow="xs" radius="md" withBorder>
|
||||
<Table fontSize="sm" verticalSpacing="sm" highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>UID</th>
|
||||
<th>Description</th>
|
||||
<th>Role</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users?.map((user) => {
|
||||
const isAdmin = user.name === 'root';
|
||||
return (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<Group spacing="xs">
|
||||
<Avatar size="sm" color={isAdmin ? 'red' : 'blue'} radius="xl">
|
||||
{user.name.slice(0, 2).toUpperCase()}
|
||||
</Avatar>
|
||||
<Text weight={500}>{user.name}</Text>
|
||||
</Group>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm" style={{ fontFamily: 'monospace' }}>
|
||||
{user.id}
|
||||
</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Text size="sm" color="dimmed">
|
||||
{user.description || '-'}
|
||||
</Text>
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={isAdmin ? 'red' : 'blue'}
|
||||
variant="light"
|
||||
>
|
||||
{isAdmin ? 'Admin' : 'User'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<Group spacing="xs">
|
||||
<Tooltip label="Change Password">
|
||||
<ActionIcon size="sm" variant="light" disabled>
|
||||
<IconKey size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon size="sm" variant="light" disabled>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete">
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
disabled={isAdmin}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
528
src/pages/unraid/vms/index.tsx
Normal file
528
src/pages/unraid/vms/index.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
/**
|
||||
* Virtual Machines Management Page
|
||||
* Full VM management with power controls
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Menu,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconServer2,
|
||||
IconPlayerPlay,
|
||||
IconPlayerStop,
|
||||
IconPlayerPause,
|
||||
IconRefresh,
|
||||
IconDots,
|
||||
IconSearch,
|
||||
IconAlertCircle,
|
||||
IconCheck,
|
||||
IconCpu,
|
||||
IconDeviceDesktop,
|
||||
} from '@tabler/icons-react';
|
||||
import { GetServerSidePropsContext } from 'next';
|
||||
|
||||
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||
import { api } from '~/utils/api';
|
||||
import type { VirtualMachine, VmState } from '~/lib/unraid/types';
|
||||
|
||||
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 VmCard({
|
||||
vm,
|
||||
onStart,
|
||||
onStop,
|
||||
onPause,
|
||||
onResume,
|
||||
onReboot,
|
||||
onForceStop,
|
||||
isLoading,
|
||||
}: {
|
||||
vm: VirtualMachine;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
onPause: () => void;
|
||||
onResume: () => void;
|
||||
onReboot: () => void;
|
||||
onForceStop: () => 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 (
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Card.Section withBorder inheritPadding py="xs">
|
||||
<Group position="apart">
|
||||
<Group spacing="sm">
|
||||
<ThemeIcon size="lg" variant="light" color={getStateColor(vm.state)}>
|
||||
<IconServer2 size={20} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text weight={600} lineClamp={1}>
|
||||
{vm.name}
|
||||
</Text>
|
||||
{vm.description && (
|
||||
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||
{vm.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group spacing="xs">
|
||||
<Badge color={getStateColor(vm.state)} variant="light">
|
||||
{vm.state}
|
||||
</Badge>
|
||||
|
||||
{isPaused && (
|
||||
<Tooltip label="Resume">
|
||||
<ActionIcon
|
||||
color="green"
|
||||
variant="light"
|
||||
onClick={onResume}
|
||||
loading={isLoading}
|
||||
>
|
||||
<IconPlayerPlay size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<>
|
||||
<Tooltip label="Pause">
|
||||
<ActionIcon
|
||||
color="yellow"
|
||||
variant="light"
|
||||
onClick={onPause}
|
||||
loading={isLoading}
|
||||
>
|
||||
<IconPlayerPause size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Stop">
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="light"
|
||||
onClick={onStop}
|
||||
loading={isLoading}
|
||||
>
|
||||
<IconPlayerStop size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isStopped && (
|
||||
<Tooltip label="Start">
|
||||
<ActionIcon
|
||||
color="green"
|
||||
variant="light"
|
||||
onClick={onStart}
|
||||
loading={isLoading}
|
||||
>
|
||||
<IconPlayerPlay size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Menu shadow="md" width={150} position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{isRunning && (
|
||||
<Menu.Item icon={<IconRefresh size={14} />} onClick={onReboot}>
|
||||
Reboot
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item disabled icon={<IconDeviceDesktop size={14} />}>
|
||||
VNC Console
|
||||
</Menu.Item>
|
||||
<Menu.Item disabled>Edit</Menu.Item>
|
||||
<Menu.Item disabled>Clone</Menu.Item>
|
||||
<Menu.Divider />
|
||||
{isRunning && (
|
||||
<Menu.Item color="red" onClick={onForceStop}>
|
||||
Force Stop
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item color="red" disabled>
|
||||
Remove
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
|
||||
<Stack spacing="xs" mt="sm">
|
||||
{/* Resources */}
|
||||
<SimpleGrid cols={2}>
|
||||
<Group spacing="xs">
|
||||
<IconCpu size={14} />
|
||||
<Text size="sm">
|
||||
<Text span weight={500}>
|
||||
{vm.cpus}
|
||||
</Text>{' '}
|
||||
<Text span color="dimmed">
|
||||
vCPU
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Group spacing="xs">
|
||||
<IconServer2 size={14} />
|
||||
<Text size="sm">
|
||||
<Text span weight={500}>
|
||||
{formatMemory(vm.memory)}
|
||||
</Text>{' '}
|
||||
<Text span color="dimmed">
|
||||
RAM
|
||||
</Text>
|
||||
</Text>
|
||||
</Group>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Auto-start */}
|
||||
<Group position="apart">
|
||||
<Text size="xs" color="dimmed" style={{ fontFamily: 'monospace' }}>
|
||||
{vm.uuid.substring(0, 8)}...
|
||||
</Text>
|
||||
{vm.autoStart && (
|
||||
<Badge size="xs" color="blue" variant="dot">
|
||||
Auto-start
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VmsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch] = useDebouncedValue(search, 200);
|
||||
const [filter, setFilter] = useState<'all' | 'running' | 'stopped'>('all');
|
||||
const [loadingVms, setLoadingVms] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
data: vms,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.unraid.vms.useQuery(undefined, {
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
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: (err) => {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: err.message,
|
||||
color: 'red',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
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',
|
||||
color: 'yellow',
|
||||
});
|
||||
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',
|
||||
color: 'green',
|
||||
});
|
||||
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: 'blue',
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const forceStopVm = api.unraid.forceStopVm.useMutation({
|
||||
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
|
||||
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
|
||||
onSuccess: () => {
|
||||
notifications.show({
|
||||
title: 'VM Force Stopped',
|
||||
message: 'Virtual machine was force stopped',
|
||||
color: 'orange',
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const filteredVms = vms?.filter((vm) => {
|
||||
const matchesSearch = vm.name.toLowerCase().includes(debouncedSearch.toLowerCase());
|
||||
|
||||
const matchesFilter =
|
||||
filter === 'all' ||
|
||||
(filter === 'running' && vm.state === 'RUNNING') ||
|
||||
(filter === 'stopped' && vm.state !== 'RUNNING');
|
||||
|
||||
return matchesSearch && matchesFilter;
|
||||
});
|
||||
|
||||
const runningCount = vms?.filter((v) => v.state === 'RUNNING').length || 0;
|
||||
const stoppedCount = (vms?.length || 0) - runningCount;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack align="center" spacing="md">
|
||||
<Loader size="xl" />
|
||||
<Text color="dimmed">Loading VMs...</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||
{error.message}
|
||||
</Alert>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<UnraidLayout>
|
||||
<Container size="xl" py="xl">
|
||||
<Stack spacing="xl">
|
||||
{/* Header */}
|
||||
<Group position="apart">
|
||||
<Group>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="violet">
|
||||
<IconServer2 size={28} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Title order={2}>Virtual Machines</Title>
|
||||
<Text color="dimmed" size="sm">
|
||||
{runningCount} running, {stoppedCount} stopped
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
leftIcon={<IconRefresh size={16} />}
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Stats */}
|
||||
<SimpleGrid cols={3}>
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Total VMs
|
||||
</Text>
|
||||
<Text size="xl" weight={700}>
|
||||
{vms?.length || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Running
|
||||
</Text>
|
||||
<Text size="xl" weight={700} color="green">
|
||||
{runningCount}
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Card shadow="sm" radius="md" withBorder>
|
||||
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||
Total vCPUs Allocated
|
||||
</Text>
|
||||
<Text size="xl" weight={700}>
|
||||
{vms?.reduce((sum, vm) => sum + vm.cpus, 0) || 0}
|
||||
</Text>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Filters */}
|
||||
<Group>
|
||||
<TextInput
|
||||
placeholder="Search VMs..."
|
||||
icon={<IconSearch size={16} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
style={{ flex: 1, maxWidth: 300 }}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
value={filter}
|
||||
onChange={(value) => setFilter(value as any)}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Running', value: 'running' },
|
||||
{ label: 'Stopped', value: 'stopped' },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* VM Grid */}
|
||||
<Grid>
|
||||
{filteredVms?.map((vm) => (
|
||||
<Grid.Col key={vm.id} sm={6} lg={4}>
|
||||
<VmCard
|
||||
vm={vm}
|
||||
onStart={() => startVm.mutate({ id: vm.id })}
|
||||
onStop={() => stopVm.mutate({ id: vm.id })}
|
||||
onPause={() => pauseVm.mutate({ id: vm.id })}
|
||||
onResume={() => resumeVm.mutate({ id: vm.id })}
|
||||
onReboot={() => rebootVm.mutate({ id: vm.id })}
|
||||
onForceStop={() => forceStopVm.mutate({ id: vm.id })}
|
||||
isLoading={loadingVms.includes(vm.id)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{filteredVms?.length === 0 && (
|
||||
<Text color="dimmed" align="center" py="xl">
|
||||
No virtual machines found
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
</UnraidLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user