diff --git a/src/components/Unraid/Dashboard/ArrayCard.tsx b/src/components/Unraid/Dashboard/ArrayCard.tsx new file mode 100644 index 000000000..6a4f23b95 --- /dev/null +++ b/src/components/Unraid/Dashboard/ArrayCard.tsx @@ -0,0 +1,306 @@ +/** + * Array Card Component + * Displays Unraid array status and disk information + */ + +import { + ActionIcon, + Badge, + Card, + Group, + Progress, + Stack, + Table, + Text, + ThemeIcon, + Title, + Tooltip, +} from '@mantine/core'; +import { + IconDatabase, + IconPlayerPlay, + IconPlayerStop, + IconTemperature, + IconHardDrive, +} from '@tabler/icons-react'; + +import type { UnraidArray, ArrayDisk, ArrayState } from '~/lib/unraid/types'; + +interface ArrayCardProps { + array: UnraidArray; + onStartArray?: () => void; + onStopArray?: () => void; + isLoading?: boolean; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function getStatusColor(status: string): string { + switch (status) { + case 'DISK_OK': + return 'green'; + case 'DISK_INVALID': + case 'DISK_WRONG': + return 'red'; + case 'DISK_DSBL': + case 'DISK_DSBL_NEW': + return 'orange'; + case 'DISK_NEW': + return 'blue'; + case 'DISK_NP': + return 'gray'; + default: + return 'gray'; + } +} + +function getArrayStateColor(state: ArrayState): string { + switch (state) { + case 'STARTED': + return 'green'; + case 'STOPPED': + return 'red'; + default: + return 'orange'; + } +} + +function DiskRow({ disk }: { disk: ArrayDisk }) { + const usedPercent = disk.fsSize && disk.fsUsed + ? ((disk.fsUsed / disk.fsSize) * 100).toFixed(1) + : null; + + return ( + + + + + + + + {disk.name} + + + + + + {disk.status.replace('DISK_', '')} + + + + {formatBytes(disk.size)} + + + {disk.temp !== null ? ( + + + 50 ? 'red' : disk.temp > 40 ? 'orange' : undefined}> + {disk.temp}°C + + + ) : ( + + {disk.spunDown ? 'Spun down' : '-'} + + )} + + + {usedPercent ? ( + + 90 ? 'red' : parseFloat(usedPercent) > 75 ? 'orange' : 'blue'} + /> + + ) : ( + - + )} + + + ); +} + +export function ArrayCard({ array, onStartArray, onStopArray, isLoading }: ArrayCardProps) { + const usedPercent = ((array.capacity.used / array.capacity.total) * 100).toFixed(1); + const isStarted = array.state === 'STARTED'; + + return ( + + + + + + + +
+ Array + + {formatBytes(array.capacity.used)} / {formatBytes(array.capacity.total)} + +
+
+ + + {array.state} + + {isStarted && onStopArray && ( + + + + + + )} + {!isStarted && onStartArray && ( + + + + + + )} + +
+
+ + + {/* Array Capacity */} +
+ + + Total Capacity + + + {usedPercent}% used + + + 90 ? 'red' : parseFloat(usedPercent) > 75 ? 'orange' : 'blue'} + /> +
+ + {/* Parity Check Status */} + {array.parityCheckStatus?.running && ( +
+ + + Parity Check in Progress + + + {array.parityCheckStatus.progress.toFixed(1)}% - {array.parityCheckStatus.errors} errors + + + +
+ )} + + {/* Parity Disks */} + {array.parities.length > 0 && ( + <> + + Parity ({array.parities.length}) + + + + + + + + + + + + + {array.parities.map((disk) => ( + + ))} + +
DiskStatusSizeTempUsage
+ + )} + + {/* Data Disks */} + {array.disks.length > 0 && ( + <> + + Data Disks ({array.disks.length}) + + + + + + + + + + + + + {array.disks.map((disk) => ( + + ))} + +
DiskStatusSizeTempUsage
+ + )} + + {/* Cache Pools */} + {array.caches.length > 0 && ( + <> + + Cache Pools ({array.caches.length}) + + {array.caches.map((cache) => { + const cacheUsedPercent = ((cache.fsUsed / cache.fsSize) * 100).toFixed(1); + return ( +
+ + + {cache.name} ({cache.fsType}) + + + {formatBytes(cache.fsUsed)} / {formatBytes(cache.fsSize)} + + + 90 ? 'red' : parseFloat(cacheUsedPercent) > 75 ? 'orange' : 'teal'} + /> +
+ ); + })} + + )} +
+
+ ); +} + +export default ArrayCard; diff --git a/src/components/Unraid/Dashboard/DockerCard.tsx b/src/components/Unraid/Dashboard/DockerCard.tsx new file mode 100644 index 000000000..d28097ef7 --- /dev/null +++ b/src/components/Unraid/Dashboard/DockerCard.tsx @@ -0,0 +1,205 @@ +/** + * Docker Card Component + * Displays Docker containers with start/stop controls + */ + +import { + ActionIcon, + Badge, + Card, + Group, + Menu, + ScrollArea, + Stack, + Text, + ThemeIcon, + Title, + Tooltip, +} from '@mantine/core'; +import { + IconBrandDocker, + IconPlayerPlay, + IconPlayerStop, + IconDots, + IconBox, +} from '@tabler/icons-react'; + +import type { Docker, DockerContainer, ContainerState } from '~/lib/unraid/types'; + +interface DockerCardProps { + docker: Docker; + onStartContainer?: (id: string) => void; + onStopContainer?: (id: string) => void; + loadingContainers?: string[]; +} + +function getStateColor(state: ContainerState): string { + switch (state) { + case 'RUNNING': + return 'green'; + case 'EXITED': + return 'red'; + case 'PAUSED': + return 'yellow'; + case 'RESTARTING': + return 'orange'; + case 'CREATED': + return 'blue'; + case 'DEAD': + return 'gray'; + default: + return 'gray'; + } +} + +function ContainerRow({ + container, + onStart, + onStop, + isLoading, +}: { + container: DockerContainer; + onStart?: () => void; + onStop?: () => void; + isLoading?: boolean; +}) { + const isRunning = container.state === 'RUNNING'; + const containerName = container.names[0]?.replace(/^\//, '') || 'Unknown'; + + return ( + ({ + borderBottom: `1px solid ${ + theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2] + }`, + '&:last-child': { + borderBottom: 'none', + }, + })}> + + + + +
+ + {containerName} + + + {container.image} + +
+
+ + + + {container.state} + + + {isRunning && onStop && ( + + + + + + )} + + {!isRunning && onStart && ( + + + + + + )} + + + + + + + + + View Logs + Edit + + + Remove + + + + +
+ ); +} + +export function DockerCard({ + docker, + onStartContainer, + onStopContainer, + loadingContainers = [], +}: DockerCardProps) { + const runningCount = docker.containers.filter((c) => c.state === 'RUNNING').length; + const totalCount = docker.containers.length; + + // Sort containers: running first, then by name + const sortedContainers = [...docker.containers].sort((a, b) => { + if (a.state === 'RUNNING' && b.state !== 'RUNNING') return -1; + if (a.state !== 'RUNNING' && b.state === 'RUNNING') return 1; + return (a.names[0] || '').localeCompare(b.names[0] || ''); + }); + + return ( + + + + + + + +
+ Docker + + {runningCount} running / {totalCount} total + +
+
+ + {docker.networks.length} networks + +
+
+ + + + {sortedContainers.length === 0 ? ( + + No containers + + ) : ( + sortedContainers.map((container) => ( + onStartContainer(container.id) : undefined} + onStop={onStopContainer ? () => onStopContainer(container.id) : undefined} + isLoading={loadingContainers.includes(container.id)} + /> + )) + )} + + +
+ ); +} + +export default DockerCard; diff --git a/src/components/Unraid/Dashboard/SystemInfoCard.tsx b/src/components/Unraid/Dashboard/SystemInfoCard.tsx new file mode 100644 index 000000000..9c18391c2 --- /dev/null +++ b/src/components/Unraid/Dashboard/SystemInfoCard.tsx @@ -0,0 +1,192 @@ +/** + * System Info Card Component + * Displays Unraid system information in the dashboard + */ + +import { + Badge, + Card, + Group, + Progress, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Title, +} from '@mantine/core'; +import { + IconCpu, + IconDeviceDesktop, + IconServer, + IconClock, + IconBrandUbuntu, +} from '@tabler/icons-react'; + +import type { SystemInfo, ServerVars, Registration } from '~/lib/unraid/types'; + +interface SystemInfoCardProps { + info: SystemInfo; + vars: ServerVars; + registration: Registration; +} + +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) { + return `${days}d ${hours}h ${minutes}m`; + } + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + return `${minutes}m`; +} + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +export function SystemInfoCard({ info, vars, registration }: SystemInfoCardProps) { + const memoryUsedPercent = ((info.memory.used / info.memory.total) * 100).toFixed(1); + + return ( + + + + + + + +
+ {vars.name} + + {vars.description} + +
+
+ + {registration.type} + +
+
+ + + {/* CPU Info */} + + + + + + + CPU + + + + {info.cpu.brand} + + + + +
+ + Cores + + + {info.cpu.cores} + +
+
+ + Threads + + + {info.cpu.threads} + +
+
+ + Speed + + + {info.cpu.speed.toFixed(2)} GHz + +
+
+ + {/* Memory Info */} +
+ + + Memory + + + {formatBytes(info.memory.used)} / {formatBytes(info.memory.total)} ({memoryUsedPercent}%) + + + 80 ? 'red' : parseFloat(memoryUsedPercent) > 60 ? 'yellow' : 'blue'} + /> +
+ + {/* OS Info */} + + + + + + + OS + + + + Unraid {info.versions.unraid} + + + + {/* Motherboard */} + + + + + + + Motherboard + + + + {info.baseboard.manufacturer} {info.baseboard.model} + + + + {/* Uptime */} + + + + + + + Uptime + + + + {formatUptime(info.os.uptime)} + + +
+
+ ); +} + +export default SystemInfoCard; diff --git a/src/components/Unraid/Dashboard/VmsCard.tsx b/src/components/Unraid/Dashboard/VmsCard.tsx new file mode 100644 index 000000000..eee9e2a69 --- /dev/null +++ b/src/components/Unraid/Dashboard/VmsCard.tsx @@ -0,0 +1,277 @@ +/** + * VMs Card Component + * Displays Virtual Machines with power controls + */ + +import { + ActionIcon, + Badge, + Card, + Group, + Menu, + ScrollArea, + Stack, + Text, + ThemeIcon, + Title, + Tooltip, +} from '@mantine/core'; +import { + IconServer2, + IconPlayerPlay, + IconPlayerStop, + IconPlayerPause, + IconRefresh, + IconDots, +} from '@tabler/icons-react'; + +import type { VirtualMachine, VmState } from '~/lib/unraid/types'; + +interface VmsCardProps { + vms: VirtualMachine[]; + onStartVm?: (id: string) => void; + onStopVm?: (id: string) => void; + onPauseVm?: (id: string) => void; + onResumeVm?: (id: string) => void; + onRebootVm?: (id: string) => void; + loadingVms?: string[]; +} + +function getStateColor(state: VmState): string { + switch (state) { + case 'RUNNING': + return 'green'; + case 'SHUTOFF': + return 'red'; + case 'PAUSED': + case 'PMSUSPENDED': + return 'yellow'; + case 'SHUTDOWN': + return 'orange'; + case 'IDLE': + return 'blue'; + case 'CRASHED': + return 'red'; + case 'NOSTATE': + default: + return 'gray'; + } +} + +function formatMemory(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +function VmRow({ + vm, + onStart, + onStop, + onPause, + onResume, + onReboot, + isLoading, +}: { + vm: VirtualMachine; + onStart?: () => void; + onStop?: () => void; + onPause?: () => void; + onResume?: () => void; + onReboot?: () => void; + isLoading?: boolean; +}) { + const isRunning = vm.state === 'RUNNING'; + const isPaused = vm.state === 'PAUSED' || vm.state === 'PMSUSPENDED'; + const isStopped = vm.state === 'SHUTOFF' || vm.state === 'SHUTDOWN' || vm.state === 'NOSTATE'; + + return ( + ({ + borderBottom: `1px solid ${ + theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2] + }`, + '&:last-child': { + borderBottom: 'none', + }, + })} + > + + + + +
+ + {vm.name} + + + + {vm.cpus} vCPU + + + • + + + {formatMemory(vm.memory)} + + +
+
+ + + + {vm.state} + + + {isRunning && onPause && ( + + + + + + )} + + {isPaused && onResume && ( + + + + + + )} + + {isRunning && onStop && ( + + + + + + )} + + {isStopped && onStart && ( + + + + + + )} + + + + + + + + + {isRunning && onReboot && ( + } + onClick={onReboot} + > + Reboot + + )} + VNC Console + Edit + + + Force Stop + + + + +
+ ); +} + +export function VmsCard({ + vms, + onStartVm, + onStopVm, + onPauseVm, + onResumeVm, + onRebootVm, + loadingVms = [], +}: VmsCardProps) { + const runningCount = vms.filter((vm) => vm.state === 'RUNNING').length; + const totalCount = vms.length; + + // Sort VMs: running first, then by name + const sortedVms = [...vms].sort((a, b) => { + if (a.state === 'RUNNING' && b.state !== 'RUNNING') return -1; + if (a.state !== 'RUNNING' && b.state === 'RUNNING') return 1; + return a.name.localeCompare(b.name); + }); + + return ( + + + + + + + +
+ Virtual Machines + + {runningCount} running / {totalCount} total + +
+
+
+
+ + + + {sortedVms.length === 0 ? ( + + No virtual machines + + ) : ( + sortedVms.map((vm) => ( + onStartVm(vm.id) : undefined} + onStop={onStopVm ? () => onStopVm(vm.id) : undefined} + onPause={onPauseVm ? () => onPauseVm(vm.id) : undefined} + onResume={onResumeVm ? () => onResumeVm(vm.id) : undefined} + onReboot={onRebootVm ? () => onRebootVm(vm.id) : undefined} + isLoading={loadingVms.includes(vm.id)} + /> + )) + )} + + +
+ ); +} + +export default VmsCard; diff --git a/src/components/Unraid/Dashboard/index.ts b/src/components/Unraid/Dashboard/index.ts new file mode 100644 index 000000000..0eed4d04b --- /dev/null +++ b/src/components/Unraid/Dashboard/index.ts @@ -0,0 +1,8 @@ +/** + * Unraid Dashboard Components + */ + +export { SystemInfoCard } from './SystemInfoCard'; +export { ArrayCard } from './ArrayCard'; +export { DockerCard } from './DockerCard'; +export { VmsCard } from './VmsCard'; diff --git a/src/env.js b/src/env.js index d85282e62..766bfa91f 100644 --- a/src/env.js +++ b/src/env.js @@ -40,6 +40,12 @@ const env = createEnv({ DOCKER_HOST: z.string().optional(), DOCKER_PORT: portSchema, DEMO_MODE: z.string().optional(), + + // Unraid API + UNRAID_HOST: z.string().optional(), + UNRAID_API_KEY: z.string().optional(), + UNRAID_USE_SSL: zodParsedBoolean().default('false'), + UNRAID_PORT: portSchema, DISABLE_UPGRADE_MODAL: zodParsedBoolean().default('false'), HOSTNAME: z.string().optional(), @@ -167,6 +173,11 @@ const env = createEnv({ AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME, DEMO_MODE: process.env.DEMO_MODE, DISABLE_UPGRADE_MODAL: process.env.DISABLE_UPGRADE_MODAL, + // Unraid API + UNRAID_HOST: process.env.UNRAID_HOST, + UNRAID_API_KEY: process.env.UNRAID_API_KEY, + UNRAID_USE_SSL: process.env.UNRAID_USE_SSL, + UNRAID_PORT: process.env.UNRAID_PORT, }, skipValidation: !!process.env.SKIP_ENV_VALIDATION, }); diff --git a/src/lib/unraid/client.ts b/src/lib/unraid/client.ts new file mode 100644 index 000000000..88c6f5a56 --- /dev/null +++ b/src/lib/unraid/client.ts @@ -0,0 +1,450 @@ +/** + * Unraid GraphQL Client + * Provides type-safe access to the Unraid API + */ + +import axios, { AxiosInstance } from 'axios'; + +import type { + Customization, + Disk, + Docker, + Flash, + Network, + Notification, + Plugin, + Registration, + Server, + ServerVars, + Service, + Share, + SystemInfo, + UnraidApiResponse, + UnraidArray, + UpsDevice, + VirtualMachine, +} from './types'; + +import { + ARRAY_QUERY, + CUSTOMIZATION_QUERY, + DASHBOARD_QUERY, + DISK_QUERY, + DISKS_QUERY, + DOCKER_QUERY, + FLASH_QUERY, + INFO_QUERY, + NETWORK_QUERY, + NOTIFICATIONS_QUERY, + PLUGINS_QUERY, + REGISTRATION_QUERY, + SERVER_QUERY, + SERVICES_QUERY, + SHARES_QUERY, + UPS_DEVICES_QUERY, + VARS_QUERY, + VMS_QUERY, +} from './queries'; + +import { + ARRAY_SET_STATE_MUTATION, + DOCKER_START_MUTATION, + DOCKER_STOP_MUTATION, + NOTIFICATION_ARCHIVE_MUTATION, + NOTIFICATION_DELETE_MUTATION, + PARITY_CHECK_CANCEL_MUTATION, + PARITY_CHECK_PAUSE_MUTATION, + PARITY_CHECK_RESUME_MUTATION, + PARITY_CHECK_START_MUTATION, + VM_FORCE_STOP_MUTATION, + VM_PAUSE_MUTATION, + VM_REBOOT_MUTATION, + VM_RESUME_MUTATION, + VM_START_MUTATION, + VM_STOP_MUTATION, +} from './queries/mutations'; + +export interface UnraidClientConfig { + host: string; + apiKey: string; + useSsl?: boolean; + port?: number; +} + +export interface DashboardData { + info: SystemInfo; + vars: ServerVars; + registration: Registration; + array: UnraidArray; + docker: Docker; + vms: VirtualMachine[]; + shares: Share[]; + services: Service[]; + notifications: Notification[]; +} + +export class UnraidClient { + private client: AxiosInstance; + private config: UnraidClientConfig; + + constructor(config: UnraidClientConfig) { + this.config = config; + const protocol = config.useSsl ? 'https' : 'http'; + const port = config.port || (config.useSsl ? 443 : 80); + const baseURL = `${protocol}://${config.host}:${port}`; + + this.client = axios.create({ + baseURL, + headers: { + 'Content-Type': 'application/json', + 'x-api-key': config.apiKey, + }, + timeout: 30000, + }); + } + + /** + * Execute a GraphQL query + */ + private async query(query: string, variables?: Record): Promise { + const response = await this.client.post>('/graphql', { + query, + variables, + }); + + if (response.data.errors?.length) { + throw new Error(response.data.errors.map((e) => e.message).join(', ')); + } + + return response.data.data; + } + + /** + * Execute a GraphQL mutation + */ + private async mutate(mutation: string, variables?: Record): Promise { + return this.query(mutation, variables); + } + + // ============================================================================ + // SYSTEM QUERIES + // ============================================================================ + + async getInfo(): Promise { + const data = await this.query<{ info: SystemInfo }>(INFO_QUERY); + return data.info; + } + + async getVars(): Promise { + const data = await this.query<{ vars: ServerVars }>(VARS_QUERY); + return data.vars; + } + + async getServer(): Promise { + const data = await this.query<{ server: Server }>(SERVER_QUERY); + return data.server; + } + + async getRegistration(): Promise { + const data = await this.query<{ registration: Registration }>(REGISTRATION_QUERY); + return data.registration; + } + + async getFlash(): Promise { + const data = await this.query<{ flash: Flash }>(FLASH_QUERY); + return data.flash; + } + + // ============================================================================ + // ARRAY QUERIES + // ============================================================================ + + async getArray(): Promise { + const data = await this.query<{ array: UnraidArray }>(ARRAY_QUERY); + return data.array; + } + + async getDisks(): Promise { + const data = await this.query<{ disks: Disk[] }>(DISKS_QUERY); + return data.disks; + } + + async getDisk(id: string): Promise { + const data = await this.query<{ disk: Disk }>(DISK_QUERY, { id }); + return data.disk; + } + + // ============================================================================ + // DOCKER QUERIES + // ============================================================================ + + async getDocker(): Promise { + const data = await this.query<{ docker: Docker }>(DOCKER_QUERY); + return data.docker; + } + + // ============================================================================ + // VM QUERIES + // ============================================================================ + + async getVms(): Promise { + const data = await this.query<{ vms: VirtualMachine[] }>(VMS_QUERY); + return data.vms; + } + + // ============================================================================ + // SHARES QUERIES + // ============================================================================ + + async getShares(): Promise { + const data = await this.query<{ shares: Share[] }>(SHARES_QUERY); + return data.shares; + } + + // ============================================================================ + // NOTIFICATIONS QUERIES + // ============================================================================ + + async getNotifications(): Promise { + const data = await this.query<{ notifications: Notification[] }>(NOTIFICATIONS_QUERY); + return data.notifications; + } + + // ============================================================================ + // SERVICES QUERIES + // ============================================================================ + + async getServices(): Promise { + const data = await this.query<{ services: Service[] }>(SERVICES_QUERY); + return data.services; + } + + // ============================================================================ + // NETWORK QUERIES + // ============================================================================ + + async getNetwork(): Promise { + const data = await this.query<{ network: Network }>(NETWORK_QUERY); + return data.network; + } + + // ============================================================================ + // UPS QUERIES + // ============================================================================ + + async getUpsDevices(): Promise { + const data = await this.query<{ upsDevices: UpsDevice[] }>(UPS_DEVICES_QUERY); + return data.upsDevices; + } + + // ============================================================================ + // PLUGINS QUERIES + // ============================================================================ + + async getPlugins(): Promise { + const data = await this.query<{ plugins: Plugin[] }>(PLUGINS_QUERY); + return data.plugins; + } + + // ============================================================================ + // CUSTOMIZATION QUERIES + // ============================================================================ + + async getCustomization(): Promise { + const data = await this.query<{ customization: Customization }>(CUSTOMIZATION_QUERY); + return data.customization; + } + + // ============================================================================ + // DASHBOARD (COMPOSITE QUERY) + // ============================================================================ + + async getDashboard(): Promise { + const data = await this.query(DASHBOARD_QUERY); + return data; + } + + // ============================================================================ + // ARRAY MUTATIONS + // ============================================================================ + + async startArray(): Promise<{ state: string }> { + const data = await this.mutate<{ array: { setState: { state: string } } }>( + ARRAY_SET_STATE_MUTATION, + { state: 'start' } + ); + return data.array.setState; + } + + async stopArray(): Promise<{ state: string }> { + const data = await this.mutate<{ array: { setState: { state: string } } }>( + ARRAY_SET_STATE_MUTATION, + { state: 'stop' } + ); + return data.array.setState; + } + + // ============================================================================ + // PARITY CHECK MUTATIONS + // ============================================================================ + + async startParityCheck(correct = false): Promise<{ running: boolean; progress: number }> { + const data = await this.mutate<{ + parityCheck: { start: { running: boolean; progress: number } }; + }>(PARITY_CHECK_START_MUTATION, { correct }); + return data.parityCheck.start; + } + + async pauseParityCheck(): Promise<{ running: boolean; progress: number }> { + const data = await this.mutate<{ + parityCheck: { pause: { running: boolean; progress: number } }; + }>(PARITY_CHECK_PAUSE_MUTATION); + return data.parityCheck.pause; + } + + async resumeParityCheck(): Promise<{ running: boolean; progress: number }> { + const data = await this.mutate<{ + parityCheck: { resume: { running: boolean; progress: number } }; + }>(PARITY_CHECK_RESUME_MUTATION); + return data.parityCheck.resume; + } + + async cancelParityCheck(): Promise<{ running: boolean }> { + const data = await this.mutate<{ parityCheck: { cancel: { running: boolean } } }>( + PARITY_CHECK_CANCEL_MUTATION + ); + return data.parityCheck.cancel; + } + + // ============================================================================ + // DOCKER MUTATIONS + // ============================================================================ + + async startContainer(id: string): Promise<{ id: string; state: string; status: string }> { + const data = await this.mutate<{ + docker: { start: { id: string; state: string; status: string } }; + }>(DOCKER_START_MUTATION, { id }); + return data.docker.start; + } + + async stopContainer(id: string): Promise<{ id: string; state: string; status: string }> { + const data = await this.mutate<{ + docker: { stop: { id: string; state: string; status: string } }; + }>(DOCKER_STOP_MUTATION, { id }); + return data.docker.stop; + } + + // ============================================================================ + // VM MUTATIONS + // ============================================================================ + + async startVm(id: string): Promise<{ id: string; state: string }> { + const data = await this.mutate<{ vm: { start: { id: string; state: string } } }>( + VM_START_MUTATION, + { id } + ); + return data.vm.start; + } + + async stopVm(id: string): Promise<{ id: string; state: string }> { + const data = await this.mutate<{ vm: { stop: { id: string; state: string } } }>( + VM_STOP_MUTATION, + { id } + ); + return data.vm.stop; + } + + async pauseVm(id: string): Promise<{ id: string; state: string }> { + const data = await this.mutate<{ vm: { pause: { id: string; state: string } } }>( + VM_PAUSE_MUTATION, + { id } + ); + return data.vm.pause; + } + + async resumeVm(id: string): Promise<{ id: string; state: string }> { + const data = await this.mutate<{ vm: { resume: { id: string; state: string } } }>( + VM_RESUME_MUTATION, + { id } + ); + return data.vm.resume; + } + + async forceStopVm(id: string): Promise<{ id: string; state: string }> { + const data = await this.mutate<{ vm: { forceStop: { id: string; state: string } } }>( + VM_FORCE_STOP_MUTATION, + { id } + ); + return data.vm.forceStop; + } + + async rebootVm(id: string): Promise<{ id: string; state: string }> { + const data = await this.mutate<{ vm: { reboot: { id: string; state: string } } }>( + VM_REBOOT_MUTATION, + { id } + ); + return data.vm.reboot; + } + + // ============================================================================ + // NOTIFICATION MUTATIONS + // ============================================================================ + + async deleteNotification(id: string): Promise { + const data = await this.mutate<{ notification: { delete: boolean } }>( + NOTIFICATION_DELETE_MUTATION, + { id } + ); + return data.notification.delete; + } + + async archiveNotification(id: string): Promise<{ id: string; archived: boolean }> { + const data = await this.mutate<{ + notification: { archive: { id: string; archived: boolean } }; + }>(NOTIFICATION_ARCHIVE_MUTATION, { id }); + return data.notification.archive; + } + + // ============================================================================ + // HEALTH CHECK + // ============================================================================ + + async healthCheck(): Promise { + try { + await this.getVars(); + return true; + } catch { + return false; + } + } +} + +// ============================================================================ +// SINGLETON INSTANCE +// ============================================================================ + +let unraidClient: UnraidClient | null = null; + +export function getUnraidClient(): UnraidClient { + if (!unraidClient) { + const host = process.env.UNRAID_HOST; + const apiKey = process.env.UNRAID_API_KEY; + + if (!host || !apiKey) { + throw new Error('UNRAID_HOST and UNRAID_API_KEY environment variables are required'); + } + + unraidClient = new UnraidClient({ + host, + apiKey, + useSsl: process.env.UNRAID_USE_SSL === 'true', + port: process.env.UNRAID_PORT ? parseInt(process.env.UNRAID_PORT, 10) : undefined, + }); + } + + return unraidClient; +} + +export function createUnraidClient(config: UnraidClientConfig): UnraidClient { + return new UnraidClient(config); +} diff --git a/src/lib/unraid/index.ts b/src/lib/unraid/index.ts new file mode 100644 index 000000000..ddf36d738 --- /dev/null +++ b/src/lib/unraid/index.ts @@ -0,0 +1,19 @@ +/** + * Unraid API Integration + * ====================== + * Type-safe client for the Unraid GraphQL API + * + * Usage: + * ```typescript + * import { getUnraidClient } from '~/lib/unraid'; + * + * const client = getUnraidClient(); + * const dashboard = await client.getDashboard(); + * ``` + */ + +export * from './client'; +export * from './types'; +export * from './queries'; +export * from './queries/mutations'; +export * from './queries/subscriptions'; diff --git a/src/lib/unraid/queries/index.ts b/src/lib/unraid/queries/index.ts new file mode 100644 index 000000000..451fbd46a --- /dev/null +++ b/src/lib/unraid/queries/index.ts @@ -0,0 +1,536 @@ +/** + * Unraid GraphQL Queries + * Based on Unraid API v4.29.2 + */ + +// ============================================================================ +// SYSTEM QUERIES +// ============================================================================ + +export const INFO_QUERY = ` + query Info { + info { + cpu { + manufacturer + brand + cores + threads + speed + speedMax + cache { + l1d + l1i + l2 + l3 + } + } + memory { + total + free + used + active + available + buffers + cached + slab + swapTotal + swapUsed + swapFree + } + os { + platform + distro + release + kernel + arch + hostname + uptime + } + baseboard { + manufacturer + model + version + serial + } + versions { + unraid + api + } + } + } +`; + +export const VARS_QUERY = ` + query Vars { + vars { + version + name + timezone + description + model + protocol + port + localTld + csrf + uptime + } + } +`; + +export const SERVER_QUERY = ` + query Server { + server { + owner + guid + wanip + lanip + localurl + remoteurl + } + } +`; + +export const REGISTRATION_QUERY = ` + query Registration { + registration { + type + state + keyFile + expiration + } + } +`; + +export const FLASH_QUERY = ` + query Flash { + flash { + guid + vendor + product + } + } +`; + +// ============================================================================ +// ARRAY QUERIES +// ============================================================================ + +export const ARRAY_QUERY = ` + query Array { + array { + state + capacity { + total + used + free + disks { + total + used + free + } + } + disks { + id + name + device + size + status + type + temp + numReads + numWrites + numErrors + fsType + fsFree + fsUsed + fsSize + color + spunDown + transport + rotational + serial + model + } + parities { + id + name + device + size + status + type + temp + numReads + numWrites + numErrors + color + spunDown + transport + rotational + serial + model + } + caches { + id + name + fsType + fsFree + fsUsed + fsSize + devices { + id + name + device + size + status + temp + spunDown + serial + model + } + } + parityCheckStatus { + running + progress + errors + elapsed + eta + speed + mode + } + } + } +`; + +export const DISKS_QUERY = ` + query Disks { + disks { + id + name + device + size + vendor + model + serial + firmware + type + interfaceType + rotational + temp + smartStatus + } + } +`; + +export const DISK_QUERY = ` + query Disk($id: String!) { + disk(id: $id) { + id + name + device + size + vendor + model + serial + firmware + type + interfaceType + rotational + temp + smartStatus + } + } +`; + +// ============================================================================ +// DOCKER QUERIES +// ============================================================================ + +export const DOCKER_QUERY = ` + query Docker { + docker { + containers { + id + names + image + state + status + created + ports { + privatePort + publicPort + type + ip + } + autoStart + networkMode + } + networks { + id + name + driver + scope + } + } + } +`; + +// ============================================================================ +// VM QUERIES +// ============================================================================ + +export const VMS_QUERY = ` + query Vms { + vms { + id + name + state + uuid + description + cpus + memory + autoStart + icon + } + } +`; + +// ============================================================================ +// SHARES QUERIES +// ============================================================================ + +export const SHARES_QUERY = ` + query Shares { + shares { + name + comment + free + used + size + include + exclude + cache + color + floor + splitLevel + allocator + export + security + } + } +`; + +// ============================================================================ +// NOTIFICATIONS QUERIES +// ============================================================================ + +export const NOTIFICATIONS_QUERY = ` + query Notifications { + notifications { + id + title + subject + description + importance + type + timestamp + read + archived + } + } +`; + +// ============================================================================ +// SERVICES QUERIES +// ============================================================================ + +export const SERVICES_QUERY = ` + query Services { + services { + name + online + uptime + version + } + } +`; + +// ============================================================================ +// NETWORK QUERIES +// ============================================================================ + +export const NETWORK_QUERY = ` + query Network { + network { + accessUrls { + type + name + ipv4 + ipv6 + url + } + } + } +`; + +// ============================================================================ +// UPS QUERIES +// ============================================================================ + +export const UPS_DEVICES_QUERY = ` + query UpsDevices { + upsDevices { + id + name + model + status + batteryCharge + batteryRuntime + load + inputVoltage + outputVoltage + } + } +`; + +// ============================================================================ +// PLUGINS QUERIES +// ============================================================================ + +export const PLUGINS_QUERY = ` + query Plugins { + plugins { + name + version + author + url + icon + updateAvailable + } + } +`; + +// ============================================================================ +// CUSTOMIZATION QUERIES +// ============================================================================ + +export const CUSTOMIZATION_QUERY = ` + query Customization { + customization { + theme + } + } +`; + +// ============================================================================ +// DASHBOARD COMPOSITE QUERY +// ============================================================================ + +export const DASHBOARD_QUERY = ` + query Dashboard { + info { + cpu { + manufacturer + brand + cores + threads + speed + } + memory { + total + free + used + available + } + os { + hostname + uptime + kernel + } + baseboard { + manufacturer + model + } + versions { + unraid + api + } + } + vars { + name + version + description + } + registration { + type + state + } + array { + state + capacity { + total + used + free + } + disks { + id + name + status + temp + size + fsUsed + fsFree + spunDown + } + parities { + id + name + status + temp + size + } + caches { + id + name + fsUsed + fsFree + fsSize + } + parityCheckStatus { + running + progress + errors + eta + } + } + docker { + containers { + id + names + state + status + autoStart + } + } + vms { + id + name + state + autoStart + } + shares { + name + size + used + free + } + services { + name + online + } + notifications { + id + importance + read + } + } +`; diff --git a/src/lib/unraid/queries/mutations.ts b/src/lib/unraid/queries/mutations.ts new file mode 100644 index 000000000..7e452898f --- /dev/null +++ b/src/lib/unraid/queries/mutations.ts @@ -0,0 +1,328 @@ +/** + * Unraid GraphQL Mutations + * Based on Unraid API v4.29.2 + */ + +// ============================================================================ +// ARRAY MUTATIONS +// ============================================================================ + +export const ARRAY_SET_STATE_MUTATION = ` + mutation ArraySetState($state: String!) { + array { + setState(state: $state) { + state + } + } + } +`; + +export const ARRAY_ADD_DISK_MUTATION = ` + mutation ArrayAddDisk($slot: String!, $id: String!) { + array { + addDiskToArray(slot: $slot, id: $id) { + state + } + } + } +`; + +export const ARRAY_REMOVE_DISK_MUTATION = ` + mutation ArrayRemoveDisk($slot: String!) { + array { + removeDiskFromArray(slot: $slot) { + state + } + } + } +`; + +export const ARRAY_MOUNT_DISK_MUTATION = ` + mutation ArrayMountDisk($id: String!) { + array { + mountArrayDisk(id: $id) { + state + } + } + } +`; + +export const ARRAY_UNMOUNT_DISK_MUTATION = ` + mutation ArrayUnmountDisk($id: String!) { + array { + unmountArrayDisk(id: $id) { + state + } + } + } +`; + +// ============================================================================ +// PARITY CHECK MUTATIONS +// ============================================================================ + +export const PARITY_CHECK_START_MUTATION = ` + mutation ParityCheckStart($correct: Boolean) { + parityCheck { + start(correct: $correct) { + running + progress + } + } + } +`; + +export const PARITY_CHECK_PAUSE_MUTATION = ` + mutation ParityCheckPause { + parityCheck { + pause { + running + progress + } + } + } +`; + +export const PARITY_CHECK_RESUME_MUTATION = ` + mutation ParityCheckResume { + parityCheck { + resume { + running + progress + } + } + } +`; + +export const PARITY_CHECK_CANCEL_MUTATION = ` + mutation ParityCheckCancel { + parityCheck { + cancel { + running + } + } + } +`; + +// ============================================================================ +// DOCKER MUTATIONS +// ============================================================================ + +export const DOCKER_START_MUTATION = ` + mutation DockerStart($id: String!) { + docker { + start(id: $id) { + id + state + status + } + } + } +`; + +export const DOCKER_STOP_MUTATION = ` + mutation DockerStop($id: String!) { + docker { + stop(id: $id) { + id + state + status + } + } + } +`; + +// ============================================================================ +// VM MUTATIONS +// ============================================================================ + +export const VM_START_MUTATION = ` + mutation VmStart($id: String!) { + vm { + start(id: $id) { + id + state + } + } + } +`; + +export const VM_STOP_MUTATION = ` + mutation VmStop($id: String!) { + vm { + stop(id: $id) { + id + state + } + } + } +`; + +export const VM_PAUSE_MUTATION = ` + mutation VmPause($id: String!) { + vm { + pause(id: $id) { + id + state + } + } + } +`; + +export const VM_RESUME_MUTATION = ` + mutation VmResume($id: String!) { + vm { + resume(id: $id) { + id + state + } + } + } +`; + +export const VM_FORCE_STOP_MUTATION = ` + mutation VmForceStop($id: String!) { + vm { + forceStop(id: $id) { + id + state + } + } + } +`; + +export const VM_REBOOT_MUTATION = ` + mutation VmReboot($id: String!) { + vm { + reboot(id: $id) { + id + state + } + } + } +`; + +export const VM_RESET_MUTATION = ` + mutation VmReset($id: String!) { + vm { + reset(id: $id) { + id + state + } + } + } +`; + +// ============================================================================ +// NOTIFICATION MUTATIONS +// ============================================================================ + +export const NOTIFICATION_CREATE_MUTATION = ` + mutation NotificationCreate($input: CreateNotificationInput!) { + notification { + create(input: $input) { + id + title + subject + description + importance + } + } + } +`; + +export const NOTIFICATION_DELETE_MUTATION = ` + mutation NotificationDelete($id: String!) { + notification { + delete(id: $id) + } + } +`; + +export const NOTIFICATION_ARCHIVE_MUTATION = ` + mutation NotificationArchive($id: String!) { + notification { + archive(id: $id) { + id + archived + } + } + } +`; + +export const NOTIFICATION_UNREAD_MUTATION = ` + mutation NotificationUnread($id: String!) { + notification { + unread(id: $id) { + id + read + } + } + } +`; + +// ============================================================================ +// CUSTOMIZATION MUTATIONS +// ============================================================================ + +export const SET_THEME_MUTATION = ` + mutation SetTheme($theme: ThemeName!) { + customization { + setTheme(theme: $theme) { + theme + } + } + } +`; + +// ============================================================================ +// API KEY MUTATIONS +// ============================================================================ + +export const API_KEY_CREATE_MUTATION = ` + mutation ApiKeyCreate($name: String!, $description: String) { + apiKey { + create(name: $name, description: $description) { + id + name + key + } + } + } +`; + +export const API_KEY_DELETE_MUTATION = ` + mutation ApiKeyDelete($id: String!) { + apiKey { + delete(id: $id) + } + } +`; + +// ============================================================================ +// CONNECT MUTATIONS +// ============================================================================ + +export const CONNECT_SIGN_IN_MUTATION = ` + mutation ConnectSignIn { + connectSignIn { + success + } + } +`; + +export const CONNECT_SIGN_OUT_MUTATION = ` + mutation ConnectSignOut { + connectSignOut { + success + } + } +`; + +export const SETUP_REMOTE_ACCESS_MUTATION = ` + mutation SetupRemoteAccess($enable: Boolean!) { + setupRemoteAccess(enable: $enable) { + success + } + } +`; diff --git a/src/lib/unraid/queries/subscriptions.ts b/src/lib/unraid/queries/subscriptions.ts new file mode 100644 index 000000000..3eb961850 --- /dev/null +++ b/src/lib/unraid/queries/subscriptions.ts @@ -0,0 +1,164 @@ +/** + * Unraid GraphQL Subscriptions + * For real-time updates via WebSocket + */ + +// ============================================================================ +// SYSTEM METRICS SUBSCRIPTIONS +// ============================================================================ + +export const CPU_METRICS_SUBSCRIPTION = ` + subscription CpuMetrics { + systemMetricsCpu { + cores + average + temperature + } + } +`; + +export const CPU_TELEMETRY_SUBSCRIPTION = ` + subscription CpuTelemetry { + systemMetricsCpuTelemetry { + cores + average + temperature + } + } +`; + +export const MEMORY_METRICS_SUBSCRIPTION = ` + subscription MemoryMetrics { + systemMetricsMemory { + total + used + free + cached + buffers + percent + } + } +`; + +// ============================================================================ +// ARRAY SUBSCRIPTIONS +// ============================================================================ + +export const ARRAY_SUBSCRIPTION = ` + subscription ArrayUpdates { + arraySubscription { + state + capacity { + total + used + free + } + disks { + id + name + status + temp + spunDown + } + parityCheckStatus { + running + progress + errors + eta + } + } + } +`; + +export const PARITY_HISTORY_SUBSCRIPTION = ` + subscription ParityHistory { + parityHistorySubscription { + date + duration + errors + speed + status + } + } +`; + +// ============================================================================ +// NOTIFICATION SUBSCRIPTIONS +// ============================================================================ + +export const NOTIFICATION_ADDED_SUBSCRIPTION = ` + subscription NotificationAdded { + notificationAdded { + id + title + subject + description + importance + type + timestamp + } + } +`; + +export const NOTIFICATIONS_OVERVIEW_SUBSCRIPTION = ` + subscription NotificationsOverview { + notificationsOverview { + total + unread + alerts + warnings + } + } +`; + +// ============================================================================ +// LOG SUBSCRIPTIONS +// ============================================================================ + +export const LOG_FILE_SUBSCRIPTION = ` + subscription LogFile($path: String!) { + logFile(path: $path) { + line + timestamp + } + } +`; + +// ============================================================================ +// UPS SUBSCRIPTIONS +// ============================================================================ + +export const UPS_UPDATES_SUBSCRIPTION = ` + subscription UpsUpdates { + upsUpdates { + id + status + batteryCharge + batteryRuntime + load + } + } +`; + +// ============================================================================ +// SERVER SUBSCRIPTIONS +// ============================================================================ + +export const OWNER_SUBSCRIPTION = ` + subscription OwnerUpdates { + ownerSubscription { + email + username + } + } +`; + +export const SERVERS_SUBSCRIPTION = ` + subscription ServersUpdates { + serversSubscription { + guid + name + status + } + } +`; diff --git a/src/lib/unraid/types.ts b/src/lib/unraid/types.ts new file mode 100644 index 000000000..4b7600f03 --- /dev/null +++ b/src/lib/unraid/types.ts @@ -0,0 +1,510 @@ +/** + * Unraid GraphQL API Types + * Based on live introspection from Unraid API v4.29.2 + */ + +// ============================================================================ +// ENUMS +// ============================================================================ + +export enum ArrayState { + STARTED = 'STARTED', + STOPPED = 'STOPPED', + NEW_ARRAY = 'NEW_ARRAY', + RECON_DISK = 'RECON_DISK', + DISABLE_DISK = 'DISABLE_DISK', + SWAP_DSBL = 'SWAP_DSBL', + INVALID_EXPANSION = 'INVALID_EXPANSION', +} + +export enum ArrayDiskStatus { + DISK_NP = 'DISK_NP', + DISK_OK = 'DISK_OK', + DISK_INVALID = 'DISK_INVALID', + DISK_WRONG = 'DISK_WRONG', + DISK_DSBL = 'DISK_DSBL', + DISK_DSBL_NEW = 'DISK_DSBL_NEW', + DISK_NEW = 'DISK_NEW', +} + +export enum ArrayDiskType { + DATA = 'DATA', + PARITY = 'PARITY', + FLASH = 'FLASH', + CACHE = 'CACHE', +} + +export enum ContainerState { + RUNNING = 'RUNNING', + EXITED = 'EXITED', + PAUSED = 'PAUSED', + RESTARTING = 'RESTARTING', + CREATED = 'CREATED', + DEAD = 'DEAD', +} + +export enum VmState { + NOSTATE = 'NOSTATE', + RUNNING = 'RUNNING', + IDLE = 'IDLE', + PAUSED = 'PAUSED', + SHUTDOWN = 'SHUTDOWN', + SHUTOFF = 'SHUTOFF', + CRASHED = 'CRASHED', + PMSUSPENDED = 'PMSUSPENDED', +} + +export enum DiskFsType { + XFS = 'XFS', + BTRFS = 'BTRFS', + VFAT = 'VFAT', + ZFS = 'ZFS', + EXT4 = 'EXT4', + NTFS = 'NTFS', +} + +export enum DiskInterfaceType { + SAS = 'SAS', + SATA = 'SATA', + USB = 'USB', + PCIE = 'PCIE', + UNKNOWN = 'UNKNOWN', +} + +export enum ThemeName { + AZURE = 'azure', + BLACK = 'black', + GRAY = 'gray', + WHITE = 'white', +} + +export enum NotificationImportance { + NORMAL = 'NORMAL', + WARNING = 'WARNING', + ALERT = 'ALERT', +} + +// ============================================================================ +// SYSTEM INFO TYPES +// ============================================================================ + +export interface SystemInfo { + cpu: CpuInfo; + memory: MemoryInfo; + os: OsInfo; + baseboard: BaseboardInfo; + versions: VersionInfo; +} + +export interface CpuInfo { + manufacturer: string; + brand: string; + cores: number; + threads: number; + speed: number; + speedMax: number; + cache: CpuCache; +} + +export interface CpuCache { + l1d: number; + l1i: number; + l2: number; + l3: number; +} + +export interface MemoryInfo { + total: number; + free: number; + used: number; + active: number; + available: number; + buffers: number; + cached: number; + slab: number; + swapTotal: number; + swapUsed: number; + swapFree: number; +} + +export interface OsInfo { + platform: string; + distro: string; + release: string; + kernel: string; + arch: string; + hostname: string; + uptime: number; +} + +export interface BaseboardInfo { + manufacturer: string; + model: string; + version: string; + serial: string; +} + +export interface VersionInfo { + unraid: string; + api: string; +} + +// ============================================================================ +// ARRAY TYPES +// ============================================================================ + +export interface UnraidArray { + state: ArrayState; + capacity: ArrayCapacity; + disks: ArrayDisk[]; + parities: ArrayDisk[]; + caches: ArrayCache[]; + parityCheckStatus: ParityCheckStatus | null; +} + +export interface ArrayCapacity { + total: number; + used: number; + free: number; + disks: { + total: number; + used: number; + free: number; + }; +} + +export interface ArrayDisk { + id: string; + name: string; + device: string; + size: number; + status: ArrayDiskStatus; + type: ArrayDiskType; + temp: number | null; + numReads: number; + numWrites: number; + numErrors: number; + fsType: DiskFsType | null; + fsFree: number | null; + fsUsed: number | null; + fsSize: number | null; + color: string; + spunDown: boolean; + transport: DiskInterfaceType; + rotational: boolean; + serial: string; + model: string; +} + +export interface ArrayCache { + id: string; + name: string; + devices: ArrayDisk[]; + fsType: DiskFsType; + fsFree: number; + fsUsed: number; + fsSize: number; +} + +export interface ParityCheckStatus { + running: boolean; + progress: number; + errors: number; + elapsed: number; + eta: number; + speed: number; + mode: 'check' | 'correct'; +} + +// ============================================================================ +// DOCKER TYPES +// ============================================================================ + +export interface DockerContainer { + id: string; + names: string[]; + image: string; + state: ContainerState; + status: string; + created: number; + ports: ContainerPort[]; + autoStart: boolean; + networkMode: string; + cpuPercent?: number; + memoryUsage?: number; + memoryLimit?: number; +} + +export interface ContainerPort { + privatePort: number; + publicPort?: number; + type: 'tcp' | 'udp'; + ip?: string; +} + +export interface DockerNetwork { + id: string; + name: string; + driver: string; + scope: string; +} + +export interface Docker { + containers: DockerContainer[]; + networks: DockerNetwork[]; +} + +// ============================================================================ +// VM TYPES +// ============================================================================ + +export interface VirtualMachine { + id: string; + name: string; + state: VmState; + uuid: string; + description?: string; + cpus: number; + memory: number; + autoStart: boolean; + icon?: string; +} + +export interface Vms { + vms: VirtualMachine[]; +} + +// ============================================================================ +// SHARES TYPES +// ============================================================================ + +export interface Share { + name: string; + comment: string; + free: number; + used: number; + size: number; + include: string[]; + exclude: string[]; + cache: string; + color: string; + floor: number; + splitLevel: number; + allocator: 'highwater' | 'fillup' | 'mostfree'; + export: string; + security: string; +} + +// ============================================================================ +// NOTIFICATION TYPES +// ============================================================================ + +export interface Notification { + id: string; + title: string; + subject: string; + description: string; + importance: NotificationImportance; + type: string; + timestamp: string; + read: boolean; + archived: boolean; +} + +// ============================================================================ +// DISK TYPES +// ============================================================================ + +export interface Disk { + id: string; + name: string; + device: string; + size: number; + vendor: string; + model: string; + serial: string; + firmware: string; + type: string; + interfaceType: DiskInterfaceType; + rotational: boolean; + temp: number | null; + smartStatus: string; +} + +// ============================================================================ +// SERVICES TYPES +// ============================================================================ + +export interface Service { + name: string; + online: boolean; + uptime: number | null; + version: string | null; +} + +// ============================================================================ +// SERVER VARIABLES +// ============================================================================ + +export interface ServerVars { + version: string; + name: string; + timezone: string; + description: string; + model: string; + protocol: string; + port: number; + localTld: string; + csrf: string; + uptime: number; +} + +// ============================================================================ +// FLASH DRIVE +// ============================================================================ + +export interface Flash { + guid: string; + vendor: string; + product: string; +} + +// ============================================================================ +// REGISTRATION / LICENSE +// ============================================================================ + +export interface Registration { + type: 'Basic' | 'Plus' | 'Pro' | 'Lifetime' | 'Trial' | 'Expired'; + state: string; + keyFile: string | null; + expiration: string | null; +} + +// ============================================================================ +// NETWORK +// ============================================================================ + +export interface Network { + accessUrls: AccessUrl[]; +} + +export interface AccessUrl { + type: string; + name: string; + ipv4: string | null; + ipv6: string | null; + url: string; +} + +// ============================================================================ +// SERVER +// ============================================================================ + +export interface Server { + owner: string | null; + guid: string; + wanip: string | null; + lanip: string; + localurl: string; + remoteurl: string | null; +} + +// ============================================================================ +// UPS +// ============================================================================ + +export interface UpsDevice { + id: string; + name: string; + model: string; + status: string; + batteryCharge: number; + batteryRuntime: number; + load: number; + inputVoltage: number; + outputVoltage: number; +} + +// ============================================================================ +// PLUGINS +// ============================================================================ + +export interface Plugin { + name: string; + version: string; + author: string; + url: string; + icon: string | null; + updateAvailable: boolean; +} + +// ============================================================================ +// CUSTOMIZATION +// ============================================================================ + +export interface Customization { + theme: ThemeName; +} + +// ============================================================================ +// API RESPONSE WRAPPERS +// ============================================================================ + +export interface UnraidApiResponse { + data: T; + errors?: Array<{ + message: string; + path?: string[]; + }>; +} + +// ============================================================================ +// REAL-TIME METRICS (Subscriptions) +// ============================================================================ + +export interface CpuMetrics { + cores: number[]; + average: number; + temperature?: number; +} + +export interface MemoryMetrics { + total: number; + used: number; + free: number; + cached: number; + buffers: number; + percent: number; +} + +// ============================================================================ +// MUTATION INPUTS +// ============================================================================ + +export interface ArraySetStateInput { + state: 'start' | 'stop'; +} + +export interface DockerControlInput { + id: string; +} + +export interface VmPowerInput { + id: string; +} + +export interface ParityCheckInput { + action: 'start' | 'pause' | 'resume' | 'cancel'; + mode?: 'check' | 'correct'; +} + +export interface NotificationInput { + id: string; +} + +export interface CreateNotificationInput { + title: string; + subject: string; + description: string; + importance: NotificationImportance; +} diff --git a/src/pages/unraid/index.tsx b/src/pages/unraid/index.tsx new file mode 100644 index 000000000..4a4e38864 --- /dev/null +++ b/src/pages/unraid/index.tsx @@ -0,0 +1,346 @@ +/** + * Unraid Dashboard Page + * Main overview page for Unraid server management + */ + +import { useState } from 'react'; +import { + Alert, + Container, + Grid, + Group, + Loader, + Stack, + Text, + ThemeIcon, + Title, + useMantineTheme, +} from '@mantine/core'; +import { notifications } from '@mantine/notifications'; +import { IconServer, IconAlertCircle, IconCheck } from '@tabler/icons-react'; +import { GetServerSidePropsContext } from 'next'; + +import { SystemInfoCard, ArrayCard, DockerCard, VmsCard } from '~/components/Unraid/Dashboard'; +import { getServerAuthSession } from '~/server/auth'; +import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations'; +import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder'; +import { api } from '~/utils/api'; + +export default function UnraidDashboardPage() { + const theme = useMantineTheme(); + const [loadingContainers, setLoadingContainers] = useState([]); + const [loadingVms, setLoadingVms] = useState([]); + const [arrayLoading, setArrayLoading] = useState(false); + + // Fetch dashboard data + const { + data: dashboard, + isLoading, + error, + refetch, + } = api.unraid.dashboard.useQuery(undefined, { + refetchInterval: 10000, // Refresh every 10 seconds + }); + + // Mutations + const startContainer = api.unraid.startContainer.useMutation({ + onMutate: ({ id }) => setLoadingContainers((prev) => [...prev, id]), + onSettled: (_, __, { id }) => + setLoadingContainers((prev) => prev.filter((cid) => cid !== id)), + onSuccess: () => { + notifications.show({ + title: 'Container Started', + message: 'Container started successfully', + color: 'green', + icon: , + }); + refetch(); + }, + onError: (error) => { + notifications.show({ + title: 'Error', + message: error.message, + color: 'red', + icon: , + }); + }, + }); + + 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: , + }); + refetch(); + }, + onError: (error) => { + notifications.show({ + title: 'Error', + message: error.message, + color: 'red', + icon: , + }); + }, + }); + + 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: , + }); + refetch(); + }, + onError: (error) => { + notifications.show({ + title: 'Error', + message: error.message, + color: 'red', + icon: , + }); + }, + }); + + 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: , + }); + refetch(); + }, + onError: (error) => { + notifications.show({ + title: 'Error', + message: error.message, + color: 'red', + icon: , + }); + }, + }); + + const pauseVm = api.unraid.pauseVm.useMutation({ + onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]), + onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)), + onSuccess: () => { + notifications.show({ + title: 'VM Paused', + message: 'Virtual machine paused successfully', + color: 'green', + icon: , + }); + refetch(); + }, + }); + + const resumeVm = api.unraid.resumeVm.useMutation({ + onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]), + onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)), + onSuccess: () => { + notifications.show({ + title: 'VM Resumed', + message: 'Virtual machine resumed successfully', + color: 'green', + icon: , + }); + refetch(); + }, + }); + + const rebootVm = api.unraid.rebootVm.useMutation({ + onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]), + onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)), + onSuccess: () => { + notifications.show({ + title: 'VM Rebooting', + message: 'Virtual machine is rebooting', + color: 'green', + icon: , + }); + refetch(); + }, + }); + + const startArray = api.unraid.startArray.useMutation({ + onMutate: () => setArrayLoading(true), + onSettled: () => setArrayLoading(false), + onSuccess: () => { + notifications.show({ + title: 'Array Starting', + message: 'Array is starting...', + color: 'green', + icon: , + }); + refetch(); + }, + onError: (error) => { + notifications.show({ + title: 'Error', + message: error.message, + color: 'red', + icon: , + }); + }, + }); + + 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: , + }); + refetch(); + }, + onError: (error) => { + notifications.show({ + title: 'Error', + message: error.message, + color: 'red', + icon: , + }); + }, + }); + + // Loading state + if (isLoading) { + return ( + + + + Connecting to Unraid server... + + + ); + } + + // Error state + if (error) { + return ( + + } + title="Connection Error" + color="red" + variant="filled" + > + {error.message} + + + ); + } + + // No data + if (!dashboard) { + return ( + + } + title="No Data" + color="yellow" + > + No data received from Unraid server. Please check your configuration. + + + ); + } + + return ( + + + {/* Header */} + + + + + +
+ Unraid Dashboard + + {dashboard.vars.name} - Unraid {dashboard.info.versions.unraid} + +
+
+
+ + {/* Dashboard Grid */} + + {/* System Info */} + + + + + {/* Array */} + + startArray.mutate()} + onStopArray={() => stopArray.mutate()} + isLoading={arrayLoading} + /> + + + {/* Docker */} + + startContainer.mutate({ id })} + onStopContainer={(id) => stopContainer.mutate({ id })} + loadingContainers={loadingContainers} + /> + + + {/* VMs */} + + startVm.mutate({ id })} + onStopVm={(id) => stopVm.mutate({ id })} + onPauseVm={(id) => pauseVm.mutate({ id })} + onResumeVm={(id) => resumeVm.mutate({ id })} + onRebootVm={(id) => rebootVm.mutate({ id })} + loadingVms={loadingVms} + /> + + +
+
+ ); +} + +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, + }, + }; +}; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 2ce6cc1e4..b8f446a3b 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -1,4 +1,5 @@ import { tdarrRouter } from '~/server/api/routers/tdarr'; +import { unraidRouter } from '~/server/api/routers/unraid/router'; import { createTRPCRouter } from '~/server/api/trpc'; import { appRouter } from './routers/app'; @@ -55,6 +56,7 @@ export const rootRouter = createTRPCRouter({ healthMonitoring: healthMonitoringRouter, tdarr: tdarrRouter, migrate: migrateRouter, + unraid: unraidRouter, }); // export type definition of API diff --git a/src/server/api/routers/unraid/router.ts b/src/server/api/routers/unraid/router.ts new file mode 100644 index 000000000..43a3e0dbd --- /dev/null +++ b/src/server/api/routers/unraid/router.ts @@ -0,0 +1,399 @@ +/** + * Unraid tRPC Router + * Provides API endpoints for Unraid management + */ + +import { z } from 'zod'; + +import { createTRPCRouter, protectedProcedure, publicProcedure } from '~/server/api/trpc'; +import { getUnraidClient, createUnraidClient } from '~/lib/unraid'; +import type { UnraidClientConfig } from '~/lib/unraid'; + +// Input schemas +const containerIdSchema = z.object({ + id: z.string(), +}); + +const vmIdSchema = z.object({ + id: z.string(), +}); + +const parityCheckSchema = z.object({ + correct: z.boolean().optional().default(false), +}); + +const notificationIdSchema = z.object({ + id: z.string(), +}); + +const diskIdSchema = z.object({ + id: z.string(), +}); + +const connectionTestSchema = z.object({ + host: z.string(), + apiKey: z.string(), + useSsl: z.boolean().optional().default(false), + port: z.number().optional(), +}); + +export const unraidRouter = createTRPCRouter({ + // ============================================================================ + // CONNECTION + // ============================================================================ + + /** + * Test connection to Unraid server + */ + testConnection: publicProcedure.input(connectionTestSchema).mutation(async ({ input }) => { + try { + const client = createUnraidClient(input as UnraidClientConfig); + const healthy = await client.healthCheck(); + return { success: healthy, error: null }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } + }), + + /** + * Check if Unraid is configured + */ + isConfigured: publicProcedure.query(() => { + return { + configured: !!(process.env.UNRAID_HOST && process.env.UNRAID_API_KEY), + }; + }), + + // ============================================================================ + // DASHBOARD / OVERVIEW + // ============================================================================ + + /** + * Get complete dashboard data + */ + dashboard: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getDashboard(); + }), + + // ============================================================================ + // SYSTEM INFO + // ============================================================================ + + /** + * Get system information + */ + info: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getInfo(); + }), + + /** + * Get server variables + */ + vars: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getVars(); + }), + + /** + * Get server details + */ + server: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getServer(); + }), + + /** + * Get registration/license info + */ + registration: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getRegistration(); + }), + + /** + * Get flash drive info + */ + flash: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getFlash(); + }), + + // ============================================================================ + // ARRAY + // ============================================================================ + + /** + * Get array status + */ + array: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getArray(); + }), + + /** + * Start array + */ + startArray: protectedProcedure.mutation(async () => { + const client = getUnraidClient(); + return client.startArray(); + }), + + /** + * Stop array + */ + stopArray: protectedProcedure.mutation(async () => { + const client = getUnraidClient(); + return client.stopArray(); + }), + + // ============================================================================ + // DISKS + // ============================================================================ + + /** + * Get all disks + */ + disks: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getDisks(); + }), + + /** + * Get single disk by ID + */ + disk: protectedProcedure.input(diskIdSchema).query(async ({ input }) => { + const client = getUnraidClient(); + return client.getDisk(input.id); + }), + + // ============================================================================ + // PARITY CHECK + // ============================================================================ + + /** + * Start parity check + */ + startParityCheck: protectedProcedure.input(parityCheckSchema).mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.startParityCheck(input.correct); + }), + + /** + * Pause parity check + */ + pauseParityCheck: protectedProcedure.mutation(async () => { + const client = getUnraidClient(); + return client.pauseParityCheck(); + }), + + /** + * Resume parity check + */ + resumeParityCheck: protectedProcedure.mutation(async () => { + const client = getUnraidClient(); + return client.resumeParityCheck(); + }), + + /** + * Cancel parity check + */ + cancelParityCheck: protectedProcedure.mutation(async () => { + const client = getUnraidClient(); + return client.cancelParityCheck(); + }), + + // ============================================================================ + // DOCKER + // ============================================================================ + + /** + * Get Docker containers and networks + */ + docker: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getDocker(); + }), + + /** + * Start container + */ + startContainer: protectedProcedure.input(containerIdSchema).mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.startContainer(input.id); + }), + + /** + * Stop container + */ + stopContainer: protectedProcedure.input(containerIdSchema).mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.stopContainer(input.id); + }), + + // ============================================================================ + // VMS + // ============================================================================ + + /** + * Get virtual machines + */ + vms: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getVms(); + }), + + /** + * Start VM + */ + startVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.startVm(input.id); + }), + + /** + * Stop VM (graceful) + */ + stopVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.stopVm(input.id); + }), + + /** + * Pause VM + */ + pauseVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.pauseVm(input.id); + }), + + /** + * Resume VM + */ + resumeVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.resumeVm(input.id); + }), + + /** + * Force stop VM + */ + forceStopVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.forceStopVm(input.id); + }), + + /** + * Reboot VM + */ + rebootVm: protectedProcedure.input(vmIdSchema).mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.rebootVm(input.id); + }), + + // ============================================================================ + // SHARES + // ============================================================================ + + /** + * Get shares + */ + shares: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getShares(); + }), + + // ============================================================================ + // NOTIFICATIONS + // ============================================================================ + + /** + * Get notifications + */ + notifications: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getNotifications(); + }), + + /** + * Delete notification + */ + deleteNotification: protectedProcedure.input(notificationIdSchema).mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.deleteNotification(input.id); + }), + + /** + * Archive notification + */ + archiveNotification: protectedProcedure + .input(notificationIdSchema) + .mutation(async ({ input }) => { + const client = getUnraidClient(); + return client.archiveNotification(input.id); + }), + + // ============================================================================ + // SERVICES + // ============================================================================ + + /** + * Get services + */ + services: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getServices(); + }), + + // ============================================================================ + // NETWORK + // ============================================================================ + + /** + * Get network info + */ + network: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getNetwork(); + }), + + // ============================================================================ + // UPS + // ============================================================================ + + /** + * Get UPS devices + */ + upsDevices: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getUpsDevices(); + }), + + // ============================================================================ + // PLUGINS + // ============================================================================ + + /** + * Get installed plugins + */ + plugins: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getPlugins(); + }), + + // ============================================================================ + // CUSTOMIZATION + // ============================================================================ + + /** + * Get customization settings + */ + customization: protectedProcedure.query(async () => { + const client = getUnraidClient(); + return client.getCustomization(); + }), +}); diff --git a/src/styles/global.scss b/src/styles/global.scss index 6be988fc9..d899a3a9c 100644 --- a/src/styles/global.scss +++ b/src/styles/global.scss @@ -1,4 +1,5 @@ @import 'fily-publish-gridstack/dist/gridstack.min.css'; +@import './orchis/variables.css'; :root { --gridstack-widget-width: 64; diff --git a/src/styles/orchis/theme.ts b/src/styles/orchis/theme.ts new file mode 100644 index 000000000..fcbb1d273 --- /dev/null +++ b/src/styles/orchis/theme.ts @@ -0,0 +1,518 @@ +/** + * Orchis Theme for Mantine + * Based on the Orchis GTK Theme by vinceliuice + * https://github.com/vinceliuice/Orchis-theme + */ + +import { MantineProviderProps, MantineThemeColors, Tuple } from '@mantine/core'; + +// ============================================================================ +// ORCHIS COLOR PALETTE +// ============================================================================ + +/** + * Orchis primary blue - the default accent color + * Matches Google's Material Design blue + */ +const orchisBlue: Tuple = [ + '#E3F2FD', // 0 - lightest + '#BBDEFB', // 1 + '#90CAF9', // 2 + '#64B5F6', // 3 + '#42A5F5', // 4 + '#2196F3', // 5 + '#1E88E5', // 6 - primary dark + '#1976D2', // 7 + '#1565C0', // 8 + '#1A73E8', // 9 - Orchis primary +]; + +/** + * Orchis grey scale for surfaces + */ +const orchisGrey: Tuple = [ + '#FAFAFA', // 0 - grey-050 + '#F2F2F2', // 1 - grey-100 + '#EEEEEE', // 2 - grey-150 + '#DDDDDD', // 3 - grey-200 + '#BFBFBF', // 4 - grey-300 + '#9E9E9E', // 5 - grey-400 + '#727272', // 6 - grey-500 + '#464646', // 7 - grey-600 + '#2C2C2C', // 8 - grey-700 + '#212121', // 9 - grey-800 +]; + +/** + * Orchis dark grey for dark mode + */ +const orchisDark: Tuple = [ + '#3C3C3C', // 0 - surface + '#333333', // 1 + '#2C2C2C', // 2 - base + '#262626', // 3 + '#242424', // 4 - base-alt + '#212121', // 5 - background + '#1A1A1A', // 6 + '#121212', // 7 + '#0F0F0F', // 8 + '#030303', // 9 +]; + +/** + * Orchis red for errors/destructive + */ +const orchisRed: Tuple = [ + '#FFEBEE', + '#FFCDD2', + '#EF9A9A', + '#E57373', + '#EF5350', + '#F44336', + '#E53935', // Orchis error + '#D32F2F', + '#C62828', + '#B71C1C', +]; + +/** + * Orchis green for success + */ +const orchisGreen: Tuple = [ + '#E8F5E9', + '#C8E6C9', + '#A5D6A7', + '#81C995', // Sea light + '#66BB6A', + '#4CAF50', + '#43A047', + '#388E3C', + '#0F9D58', // Sea dark (success) + '#1B5E20', +]; + +/** + * Orchis yellow for warnings + */ +const orchisYellow: Tuple = [ + '#FFFDE7', + '#FFF9C4', + '#FFF59D', + '#FFF176', + '#FFEE58', + '#FFEB3B', + '#FDD835', + '#FBC02D', // Yellow light + '#F9A825', + '#FFD600', // Yellow dark (warning) +]; + +/** + * Orchis purple for accents + */ +const orchisPurple: Tuple = [ + '#F3E5F5', + '#E1BEE7', + '#CE93D8', + '#BA68C8', // Purple light + '#AB47BC', // Purple dark + '#9C27B0', + '#8E24AA', + '#7B1FA2', + '#6A1B9A', + '#4A148C', +]; + +/** + * Orchis teal for secondary accents + */ +const orchisTeal: Tuple = [ + '#E0F2F1', + '#B2DFDB', + '#80CBC4', + '#4DB6AC', + '#26A69A', + '#009688', + '#00897B', + '#00796B', + '#00695C', + '#004D40', +]; + +/** + * Orchis orange for highlights + */ +const orchisOrange: Tuple = [ + '#FFF3E0', + '#FFE0B2', + '#FFCC80', + '#FFB74D', + '#FFA726', + '#FF9800', + '#FB8C00', + '#F57C00', + '#EF6C00', + '#E65100', +]; + +// ============================================================================ +// MANTINE THEME CONFIGURATION +// ============================================================================ + +export const orchisColors: MantineThemeColors = { + // Override default colors with Orchis palette + blue: orchisBlue, + gray: orchisGrey, + dark: orchisDark, + red: orchisRed, + green: orchisGreen, + yellow: orchisYellow, + violet: orchisPurple, + teal: orchisTeal, + orange: orchisOrange, + + // Custom Orchis-specific colors + orchis: orchisBlue, + surface: orchisGrey, +}; + +export const orchisTheme: MantineProviderProps['theme'] = { + // Color configuration + colors: orchisColors, + primaryColor: 'blue', + primaryShade: { light: 9, dark: 5 }, + + // Typography + fontFamily: '"M+ 1c", Roboto, Cantarell, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + fontFamilyMonospace: '"JetBrains Mono", "Fira Code", Consolas, monospace', + headings: { + fontFamily: 'Roboto, "M+ 1c", Cantarell, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + fontWeight: 700, + }, + + // Border radius - Orchis uses 12px as default + radius: { + xs: 4, + sm: 6, + md: 12, // Default Orchis corner radius + lg: 18, // Window radius + xl: 24, + }, + defaultRadius: 'md', + + // Spacing - Orchis base is 6px + spacing: { + xs: 4, + sm: 6, + md: 12, + lg: 18, + xl: 24, + }, + + // Shadows - Material Design elevation + shadows: { + xs: '0 1px 2px rgba(0,0,0,0.17)', + sm: '0 2px 2px -2px rgba(0,0,0,0.3), 0 1px 2px -1px rgba(0,0,0,0.24), 0 1px 2px -1px rgba(0,0,0,0.17)', + md: '0 3px 3px -2px rgba(0,0,0,0.2), 0 3px 3px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12)', + lg: '0 3px 3px -1px rgba(0,0,0,0.2), 0 6px 6px 0 rgba(0,0,0,0.14), 0 1px 11px 0 rgba(0,0,0,0.12)', + xl: '0 8px 6px -5px rgba(0,0,0,0.2), 0 16px 16px 2px rgba(0,0,0,0.14), 0 6px 18px 5px rgba(0,0,0,0.12)', + }, + + // Transitions - Orchis uses 75ms base duration + transitionTimingFunction: 'cubic-bezier(0.0, 0.0, 0.2, 1)', + + // Other + cursorType: 'pointer', + focusRing: 'auto', + respectReducedMotion: true, + white: '#FFFFFF', + black: '#000000', + + // Global styles + globalStyles: (theme) => ({ + '*, *::before, *::after': { + boxSizing: 'border-box', + }, + body: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], + color: theme.colorScheme === 'dark' ? theme.white : 'rgba(0, 0, 0, 0.87)', + lineHeight: 1.5, + }, + // Orchis-style focus ring + ':focus-visible': { + outline: `2px solid ${theme.colors.blue[theme.colorScheme === 'dark' ? 5 : 9]}`, + outlineOffset: 2, + }, + }), + + // Component overrides for Orchis styling + components: { + Button: { + defaultProps: { + radius: 'md', + }, + styles: (theme) => ({ + root: { + fontWeight: 500, + transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)', + }, + }), + }, + + Card: { + defaultProps: { + radius: 'md', + p: 'md', + }, + styles: (theme) => ({ + root: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.white, + border: `1px solid ${ + theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)' + }`, + }, + }), + }, + + Paper: { + defaultProps: { + radius: 'md', + }, + styles: (theme) => ({ + root: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[2] : theme.white, + }, + }), + }, + + Input: { + defaultProps: { + radius: 'md', + }, + styles: (theme) => ({ + input: { + backgroundColor: + theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)', + border: 'none', + transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)', + '&:hover': { + backgroundColor: + theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', + }, + '&:focus': { + outline: `2px solid ${theme.colors.blue[theme.colorScheme === 'dark' ? 5 : 9]}`, + outlineOffset: -2, + }, + }, + }), + }, + + TextInput: { + defaultProps: { + radius: 'md', + }, + }, + + Select: { + defaultProps: { + radius: 'md', + }, + }, + + Modal: { + defaultProps: { + radius: 'lg', + overlayProps: { + blur: 3, + }, + }, + styles: (theme) => ({ + content: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[0] : theme.white, + }, + header: { + backgroundColor: 'transparent', + }, + }), + }, + + Menu: { + defaultProps: { + radius: 'md', + shadow: 'lg', + }, + styles: (theme) => ({ + dropdown: { + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.white, + border: 'none', + }, + item: { + borderRadius: 5, // menuitem-radius + transition: 'background-color 75ms cubic-bezier(0.0, 0.0, 0.2, 1)', + }, + }), + }, + + Tooltip: { + defaultProps: { + radius: 6, + }, + styles: () => ({ + tooltip: { + backgroundColor: 'rgba(0, 0, 0, 0.9)', + color: '#FFFFFF', + }, + }), + }, + + Tabs: { + styles: (theme) => ({ + tab: { + fontWeight: 500, + transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)', + '&[data-active]': { + borderColor: theme.colors.blue[theme.colorScheme === 'dark' ? 5 : 9], + }, + }, + }), + }, + + Switch: { + styles: (theme) => ({ + track: { + backgroundColor: + theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.3)' : 'rgba(0,0,0,0.26)', + transition: 'background-color 75ms cubic-bezier(0.0, 0.0, 0.2, 1)', + }, + thumb: { + boxShadow: + '0 2px 2px -2px rgba(0,0,0,0.3), 0 1px 2px -1px rgba(0,0,0,0.24), 0 1px 2px -1px rgba(0,0,0,0.17)', + }, + }), + }, + + Progress: { + defaultProps: { + radius: 'md', + size: 6, // Orchis bar-size + }, + styles: (theme) => ({ + root: { + backgroundColor: + theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)', + }, + }), + }, + + NavLink: { + styles: (theme) => ({ + root: { + borderRadius: '0 9999px 9999px 0', // Orchis sidebar row style + fontWeight: 500, + transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)', + '&[data-active]': { + backgroundColor: + theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)', + color: theme.colors.blue[theme.colorScheme === 'dark' ? 5 : 9], + }, + }, + }), + }, + + Notification: { + defaultProps: { + radius: 'md', + }, + }, + + Badge: { + defaultProps: { + radius: 'md', + }, + }, + + Checkbox: { + styles: (theme) => ({ + input: { + cursor: 'pointer', + borderRadius: 4, + transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)', + }, + label: { + cursor: 'pointer', + }, + }), + }, + + Divider: { + styles: (theme) => ({ + root: { + borderColor: + theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)', + }, + }), + }, + + Table: { + styles: (theme) => ({ + root: { + '& thead tr th': { + color: theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.6)', + fontWeight: 'normal', + borderBottom: `1px solid ${ + theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)' + }`, + }, + '& tbody tr td': { + borderBottom: `1px solid ${ + theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.12)' + }`, + }, + '& tbody tr:hover': { + backgroundColor: + theme.colorScheme === 'dark' ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', + }, + }, + }), + }, + + ActionIcon: { + defaultProps: { + radius: 'md', + }, + styles: () => ({ + root: { + transition: 'all 75ms cubic-bezier(0.0, 0.0, 0.2, 1)', + }, + }), + }, + + Accordion: { + defaultProps: { + radius: 'md', + }, + }, + + Alert: { + defaultProps: { + radius: 'md', + }, + }, + + Popover: { + defaultProps: { + radius: 'md', + shadow: 'lg', + }, + }, + + HoverCard: { + defaultProps: { + radius: 'md', + shadow: 'lg', + }, + }, + }, +}; + +export default orchisTheme; diff --git a/src/styles/orchis/variables.css b/src/styles/orchis/variables.css new file mode 100644 index 000000000..7f9d57a9f --- /dev/null +++ b/src/styles/orchis/variables.css @@ -0,0 +1,155 @@ +/** + * Orchis Theme CSS Variables + * Based on the Orchis GTK Theme by vinceliuice + * https://github.com/vinceliuice/Orchis-theme + */ + +:root { + /* ---- Primary / Accent ---- */ + --orchis-primary: #1A73E8; + --orchis-primary-light: #3281EA; + --orchis-on-primary: #FFFFFF; + --orchis-on-primary-secondary: rgba(255, 255, 255, 0.7); + + /* ---- Backgrounds (Light Mode) ---- */ + --orchis-background: #F2F2F2; + --orchis-surface: #FFFFFF; + --orchis-base: #FFFFFF; + --orchis-base-alt: #FAFAFA; + + /* ---- Text (Light Mode) ---- */ + --orchis-text: rgba(0, 0, 0, 0.87); + --orchis-text-secondary: rgba(0, 0, 0, 0.6); + --orchis-text-disabled: rgba(0, 0, 0, 0.38); + + /* ---- Semantic Colors ---- */ + --orchis-error: #E53935; + --orchis-warning: #FFD600; + --orchis-success: #0F9D58; + --orchis-info: #1A73E8; + --orchis-link: #1A73E8; + --orchis-link-visited: #AB47BC; + + /* ---- Borders ---- */ + --orchis-border: rgba(0, 0, 0, 0.12); + --orchis-border-solid: #E2E2E2; + --orchis-divider: rgba(0, 0, 0, 0.12); + + /* ---- Overlay States ---- */ + --orchis-overlay-hover: rgba(0, 0, 0, 0.08); + --orchis-overlay-focus: rgba(0, 0, 0, 0.08); + --orchis-overlay-active: rgba(0, 0, 0, 0.12); + --orchis-overlay-checked: rgba(0, 0, 0, 0.10); + --orchis-overlay-selected: rgba(0, 0, 0, 0.06); + + /* ---- Track / Fill ---- */ + --orchis-track: rgba(0, 0, 0, 0.26); + --orchis-track-disabled: rgba(0, 0, 0, 0.15); + --orchis-fill: rgba(0, 0, 0, 0.04); + --orchis-secondary-fill: rgba(0, 0, 0, 0.08); + + /* ---- Tooltip ---- */ + --orchis-tooltip-bg: rgba(0, 0, 0, 0.9); + + /* ---- Titlebar / Sidebar ---- */ + --orchis-titlebar: #FFFFFF; + --orchis-titlebar-backdrop: #FAFAFA; + --orchis-sidebar: #FAFAFA; + + /* ---- Window Buttons ---- */ + --orchis-btn-close: #fd5f51; + --orchis-btn-maximize: #38c76a; + --orchis-btn-minimize: #fdbe04; + + /* ---- Typography ---- */ + --orchis-font-family: "M+ 1c", Roboto, Cantarell, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --orchis-font-size: 14px; + --orchis-font-weight-button: 500; + + /* ---- Spacing ---- */ + --orchis-space: 6px; + --orchis-space-xs: 2px; + --orchis-space-sm: 4px; + --orchis-space-md: 6px; + --orchis-space-lg: 12px; + --orchis-space-xl: 18px; + --orchis-space-xxl: 24px; + + /* ---- Border Radius ---- */ + --orchis-radius: 12px; + --orchis-radius-window: 18px; + --orchis-radius-corner: 12px; + --orchis-radius-menu: 11px; + --orchis-radius-card: 11px; + --orchis-radius-tooltip: 6px; + --orchis-radius-menuitem: 5px; + --orchis-radius-circular: 9999px; + + /* ---- Shadows ---- */ + --orchis-shadow-z1: 0 2px 2px -2px rgba(0,0,0,0.3), 0 1px 2px -1px rgba(0,0,0,0.24), 0 1px 2px -1px rgba(0,0,0,0.17); + --orchis-shadow-z2: 0 3px 2px -3px rgba(0,0,0,0.3), 0 2px 2px -1px rgba(0,0,0,0.24), 0 1px 3px 0 rgba(0,0,0,0.12); + --orchis-shadow-z3: 0 3px 3px -2px rgba(0,0,0,0.2), 0 3px 3px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12); + --orchis-shadow-z4: 0 2px 2px -1px rgba(0,0,0,0.2), 0 4px 4px 0 rgba(0,0,0,0.14), 0 1px 6px 0 rgba(0,0,0,0.12); + --orchis-shadow-z6: 0 3px 3px -1px rgba(0,0,0,0.2), 0 6px 6px 0 rgba(0,0,0,0.14), 0 1px 11px 0 rgba(0,0,0,0.12); + --orchis-shadow-z8: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 8px 1px rgba(0,0,0,0.14), 0 3px 9px 2px rgba(0,0,0,0.12); + --orchis-shadow-z12: 0 7px 7px -4px rgba(0,0,0,0.2), 0 12px 12px 2px rgba(0,0,0,0.14), 0 5px 13px 4px rgba(0,0,0,0.12); + --orchis-shadow-z16: 0 8px 6px -5px rgba(0,0,0,0.2), 0 16px 16px 2px rgba(0,0,0,0.14), 0 6px 18px 5px rgba(0,0,0,0.12); + --orchis-shadow-z24: 0 11px 11px -7px rgba(0,0,0,0.2), 0 24px 24px 3px rgba(0,0,0,0.14), 0 9px 28px 8px rgba(0,0,0,0.12); + + /* ---- Transitions ---- */ + --orchis-duration: 75ms; + --orchis-duration-short: 150ms; + --orchis-duration-ripple: 225ms; + --orchis-ease: cubic-bezier(0.4, 0.0, 0.2, 1); + --orchis-ease-out: cubic-bezier(0.0, 0.0, 0.2, 1); + --orchis-ease-in: cubic-bezier(0.4, 0.0, 1, 1); + --orchis-transition: all 75ms cubic-bezier(0.0, 0.0, 0.2, 1); + + /* ---- Component Sizes ---- */ + --orchis-size-small: 24px; + --orchis-size-medium: 36px; + --orchis-size-large: 48px; + --orchis-icon-size: 16px; + --orchis-icon-size-md: 24px; + --orchis-icon-size-lg: 32px; + --orchis-bar-size: 6px; +} + +/* ---- Dark Mode Overrides ---- */ +[data-mantine-color-scheme="dark"], +.mantine-ColorScheme-root[data-mantine-color-scheme="dark"] { + --orchis-primary: #3281EA; + --orchis-background: #212121; + --orchis-surface: #3C3C3C; + --orchis-base: #2C2C2C; + --orchis-base-alt: #242424; + + --orchis-text: #FFFFFF; + --orchis-text-secondary: rgba(255, 255, 255, 0.7); + --orchis-text-disabled: rgba(255, 255, 255, 0.5); + + --orchis-error: #F44336; + --orchis-warning: #FBC02D; + --orchis-success: #81C995; + --orchis-link: #3281EA; + --orchis-link-visited: #BA68C8; + + --orchis-border: rgba(255, 255, 255, 0.12); + --orchis-border-solid: #3D3D3D; + --orchis-divider: rgba(255, 255, 255, 0.12); + + --orchis-overlay-hover: rgba(255, 255, 255, 0.08); + --orchis-overlay-focus: rgba(255, 255, 255, 0.08); + --orchis-overlay-active: rgba(255, 255, 255, 0.12); + --orchis-overlay-checked: rgba(255, 255, 255, 0.10); + --orchis-overlay-selected: rgba(255, 255, 255, 0.06); + + --orchis-track: rgba(255, 255, 255, 0.3); + --orchis-track-disabled: rgba(255, 255, 255, 0.15); + --orchis-fill: rgba(255, 255, 255, 0.04); + --orchis-secondary-fill: rgba(255, 255, 255, 0.08); + + --orchis-titlebar: #2C2C2C; + --orchis-titlebar-backdrop: #3C3C3C; + --orchis-sidebar: #242424; +} diff --git a/src/tools/server/theme/theme.ts b/src/tools/server/theme/theme.ts index 69d7b9643..b2b2223a9 100644 --- a/src/tools/server/theme/theme.ts +++ b/src/tools/server/theme/theme.ts @@ -1,3 +1,6 @@ import { MantineProviderProps } from '@mantine/core'; -export const theme: MantineProviderProps['theme'] = {}; +import { orchisTheme } from '~/styles/orchis/theme'; + +// Use Orchis theme as the base theme +export const theme: MantineProviderProps['theme'] = orchisTheme;