Compare commits

..

2 Commits
master ... dev

Author SHA1 Message Date
Thomas Camlong
6f06d53da8 Merge pull request #2322 from ajnart/archive-readme-text 2025-10-13 15:40:29 +02:00
Meier Lukas
afa6b8eb2d docs(readme): add archive notice 2025-08-04 21:25:06 +02:00
44 changed files with 26 additions and 12524 deletions

View File

@@ -10,10 +10,4 @@ NEXTAUTH_SECRET="anything"
# Disable analytics
NEXT_PUBLIC_DISABLE_ANALYTICS="true"
DEFAULT_COLOR_SCHEME="light"
# Unraid API Configuration
UNRAID_HOST=192.168.10.20
UNRAID_API_KEY=your-api-key-here
UNRAID_USE_SSL=false
UNRAID_PORT=80
DEFAULT_COLOR_SCHEME="light"

View File

@@ -1,105 +0,0 @@
# Multi-stage Dockerfile for Homarr Unraid UI
# Builds entirely inside Docker to avoid native module issues
# Build stage
FROM node:20.2.0-slim AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
git \
openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy package files
COPY package.json yarn.lock ./
COPY .yarnrc.yml ./
COPY .yarn ./.yarn
# Install dependencies
RUN yarn install --immutable
# Copy source code
COPY . .
# Build the application
ENV SKIP_ENV_VALIDATION=1
ENV NEXTAUTH_SECRET=build-time-secret
ENV DATABASE_URL=file:build.sqlite
RUN yarn build
# Production stage
FROM node:20.2.0-slim
WORKDIR /app
# Define node.js environment variables
ARG PORT=7575
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
ENV NODE_OPTIONS='--no-experimental-fetch'
# Copy built application from builder
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./temp_package.json
COPY --from=builder /app/yarn.lock ./temp_yarn.lock
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/scripts/run.sh ./scripts/run.sh
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle/migrate ./migrate
COPY --from=builder /app/tsconfig.json ./migrate/tsconfig.json
COPY --from=builder /app/cli ./cli
RUN chmod +x ./scripts/run.sh
RUN mkdir -p /data
# Install runtime dependencies
RUN apt-get update && apt-get install -y openssl wget && rm -rf /var/lib/apt/lists/*
# Move node_modules to temp location to avoid overwriting
RUN mv node_modules _node_modules
RUN rm package.json
# Install dependencies for migration
RUN cp ./migrate/package.json ./package.json
RUN yarn install --production=false
# Copy better_sqlite3 build for current platform
RUN cp /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/_node_modules/better-sqlite3/build/Release/better_sqlite3.node || true
# Copy node_modules for migration to migrate folder
RUN mv node_modules ./migrate/node_modules
# Restore app node_modules
RUN mv _node_modules node_modules
# Setup CLI
RUN echo '#!/bin/bash\nnode /app/cli/cli.js "$@"' > /usr/bin/homarr
RUN chmod +x /usr/bin/homarr
RUN cd /app/cli && yarn install --production || true
# Expose the default application port
EXPOSE $PORT
ENV PORT=${PORT}
# Environment defaults
ENV DATABASE_URL="file:/data/db.sqlite"
ENV AUTH_TRUST_HOST="true"
ENV PORT=7575
ENV NEXTAUTH_SECRET=NOT_IN_USE_BECAUSE_JWTS_ARE_UNUSED
# Health check
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT} || exit 1
VOLUME [ "/app/data/configs" ]
VOLUME [ "/data" ]
ENTRYPOINT ["sh", "./scripts/run.sh"]

View File

@@ -1,3 +1,25 @@
# 🦀 Homarr
This repository has been archived and the project was moved to [homarr-labs/homarr](https://github.com/homarr-labs/homarr) with v1+.
There will be no updates to this old version, but you can enjoy regular updates with v1+.
Usage of this old project is not recommended, however possible.
To migrate please follow the [Migration Guide](https://homarr.dev/blog/2025/01/19/migration-guide-1.0) and read the [Breaking Changes](https://homarr.dev/blog/2024/09/23/version-1.0#breaking-changes)
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<br />
<!-- Project Title -->
[![Banner](docs/banner.png)](https://homarr.dev/)
@@ -41,12 +63,6 @@ Simplify the management of your server with Homarr - a sleek, modern dashboard t
<br/>
> [!IMPORTANT]
> # The Homarr repository has been moved to [HomarrLabs](https://github.com/homarr-labs/homarr)
> # Please create issues related to 1.0 there. This repository will be archived once the 1.0 is fully polished.
> # 1.0 is a complete rewrite and to migrate you have to change your compose file. Please follow the [Migration Guide](https://homarr.dev/blog/2025/01/19/migration-guide-1.0) and read the [Breaking Changes](https://homarr.dev/blog/2024/09/23/version-1.0#breaking-changes)
[![Features Section](docs/section-features.png)](https://homarr.dev/)
- 🖌️ Highly customizable with an extensive drag and drop grid system

View File

@@ -1,42 +0,0 @@
version: "3.8"
services:
unmarr:
image: git.xtrm-lab.org/jazzymc/homarr:latest
container_name: unmarr
restart: unless-stopped
environment:
# Unraid API Configuration
- UNRAID_HOST=192.168.10.20
- UNRAID_API_KEY=${UNRAID_API_KEY}
- UNRAID_USE_SSL=false
- UNRAID_PORT=80
# App Configuration
- TZ=Europe/Sofia
- DATABASE_URL=file:/data/db.sqlite
- AUTH_TRUST_HOST=true
- NEXTAUTH_URL=https://unmarr.xtrm-lab.org
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-changeme}
volumes:
- /mnt/user/appdata/unmarr/data:/data
- /mnt/user/appdata/unmarr/configs:/app/data/configs
networks:
dockerproxy:
ipv4_address: 172.18.0.5
labels:
# Traefik
- "traefik.enable=true"
- "traefik.constraint=valid"
- "traefik.http.routers.unmarr.rule=Host(`unmarr.xtrm-lab.org`)"
- "traefik.http.routers.unmarr.entrypoints=https"
- "traefik.http.routers.unmarr.tls=true"
- "traefik.http.routers.unmarr.tls.certresolver=cloudflare"
- "traefik.http.services.unmarr.loadbalancer.server.port=7575"
# Unraid
- "net.unraid.docker.managed=true"
- "net.unraid.docker.icon=https://homarr.dev/img/logo.png"
- "net.unraid.docker.webui=https://unmarr.xtrm-lab.org"
networks:
dockerproxy:
external: true

View File

@@ -1,148 +0,0 @@
# Unraid Custom UI Project
This project transforms the Homarr fork into a custom Unraid management UI with an Orchis GTK theme.
## Project Goals
1. **Step 1**: Recreate all current Unraid WebGUI pages using Homarr as a base, with data from the Unraid GraphQL API
2. **Step 2**: Apply a custom theme based on the Orchis GTK theme
## Project Resources
| Document | Description |
|----------|-------------|
| [WEBGUI-COMPLETE-INVENTORY.md](./WEBGUI-COMPLETE-INVENTORY.md) | Complete inventory of ~100 Unraid WebGUI pages |
| [unraid-api-research.md](./unraid-api-research.md) | Unraid GraphQL API documentation |
| [homarr-architecture.md](./homarr-architecture.md) | Homarr codebase architecture |
| [orchis-design-system.ts](./orchis-design-system.ts) | Orchis theme design tokens (TypeScript) |
## Unraid Server Connection
- **GraphQL Endpoint**: `http://192.168.10.20/graphql`
- **Auth**: `x-api-key` header
- **Socket**: `/var/run/unraid-api.sock`
## Implementation Plan
### Phase 1: Foundation Setup
- [ ] Create Unraid GraphQL client integration
- [ ] Set up API authentication layer
- [ ] Configure environment variables for Unraid connection
- [ ] Create base types for Unraid data models
### Phase 2: Core Pages (Priority Order)
1. **Dashboard** - System overview with real-time metrics
- System info, CPU/RAM usage, array status
- Docker/VM summaries, network, UPS
2. **Array Management** (Main)
- Array devices, pool devices, boot device
- Parity check, array operations
3. **Docker Management**
- Container list, start/stop/restart
- Container details, logs
4. **VM Management**
- VM list, power controls
- VM details, console access
5. **Shares**
- User shares, disk shares
- Share settings, SMB/NFS security
### Phase 3: Orchis Theme Integration
- [ ] Copy design tokens to `src/styles/`
- [ ] Create Mantine theme override with Orchis values
- [ ] Implement light/dark mode with Orchis palettes
- [ ] Replace default components with Orchis-styled versions
### Phase 4: Settings & Tools
- Settings pages (identification, disk, network, etc.)
- Tools pages (syslog, diagnostics, system devices)
- User management
- Notifications system
### Phase 5: Real-time Features
- GraphQL subscriptions for CPU/memory metrics
- Nchan integration for legacy real-time features
- WebSocket connections for live updates
## Key Technical Decisions
### Homarr Stack
- Next.js 13 (Pages Router in this version)
- Mantine UI v6
- tRPC for type-safe API
- Zustand for state management
- React Query for server state
### Data Sources
| Feature | Source |
|---------|--------|
| System info, array, shares | Unraid GraphQL API |
| Docker containers | Unraid GraphQL API |
| VMs | Unraid GraphQL API |
| Real-time CPU/RAM | GraphQL Subscriptions |
| Real-time disk I/O | Nchan WebSocket (`/sub/diskload`) |
| Legacy features | PHP endpoints where needed |
### API Gaps (Require Legacy Endpoints)
- Docker create/delete/restart/logs
- VM create/delete
- Share CRUD, User CRUD
- System reboot/shutdown
- Mover operations
## Directory Structure (New Files)
```
src/
├── lib/
│ └── unraid/
│ ├── client.ts # GraphQL client
│ ├── types.ts # TypeScript types
│ └── queries/ # GraphQL queries
├── pages/
│ └── unraid/
│ ├── dashboard.tsx # Dashboard page
│ ├── array/ # Array pages
│ ├── docker/ # Docker pages
│ ├── vms/ # VM pages
│ ├── shares/ # Share pages
│ ├── settings/ # Settings pages
│ └── tools/ # Tools pages
├── components/
│ └── unraid/
│ ├── Dashboard/ # Dashboard components
│ ├── Array/ # Array components
│ ├── Docker/ # Docker components
│ └── ...
└── styles/
└── orchis/
├── theme.ts # Mantine theme config
├── variables.css # CSS custom properties
└── components.css # Component overrides
```
## Getting Started
```bash
# Install dependencies
yarn install
# Set up environment
cp .env.example .env.local
# Edit .env.local with your Unraid API key
# Run development server
yarn dev
```
## Environment Variables
```env
UNRAID_HOST=192.168.10.20
UNRAID_API_KEY=your-api-key-here
UNRAID_USE_SSL=false
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,60 +0,0 @@
# Homarr Architecture
## Tech Stack
- Next.js 13+ (App Router), React 18, TypeScript
- Mantine UI (component library)
- Zustand (client state), React Query (server state)
- Prisma ORM (SQLite default / PostgreSQL)
- Turbo monorepo, pnpm workspaces
## Project Structure
```
homarr/
├── apps/web/src/ # Main Next.js app
│ ├── app/ # App Router pages
│ ├── components/ # React components
│ ├── modules/ # Widgets & integrations
│ ├── stores/ # Zustand stores
│ └── styles/ # Themes & global CSS
├── packages/
│ ├── api-client/ # API client lib
│ ├── common/ # Shared types/utils
│ ├── definitions/ # Widget/integration defs
│ ├── auth/ # Auth logic
│ └── ui/ # Shared UI components
├── prisma/ # DB schema & migrations
└── turbo.json
```
## Widget System
Each widget in `apps/web/src/modules/`:
- `definition.ts` - Metadata + Zod config schema
- `component.tsx` - React component
- `settings.tsx` - Config panel
- `server-actions.ts` - Backend operations
- Registered in module index, auto-discovered
## Integration System
External service connectors with:
- API client abstraction, credential management
- URL/hostname config, SSL handling
- Error handling, retry, rate limiting, caching
## Theming
- CSS variables + Mantine theming + Tailwind
- Light/dark mode, custom color palettes, accent colors
- Wallpaper/background, font size, border radius customization
- Theme persisted in database
## Adding New Pages
1. Create in `apps/web/src/app/(group)/page-name/page.tsx`
2. Use Mantine UI components
3. Add API routes in `src/app/api/`
4. Create Zustand store or use React Query
5. Update navigation config
## Key Patterns
- Server Components by default, `'use client'` for interactive
- TRPC for type-safe RPC
- Zod schemas for validation
- Prisma for DB operations

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +0,0 @@
# Unraid API Research (Live from XTRM-Unraid v7.2.3)
## API: unraid-api v4.29.2 (NestJS + Apollo GraphQL)
### Connection
- HTTP: `http://192.168.10.20/graphql`
- WebSocket: `ws://192.168.10.20/graphql` (graphql-ws protocol)
- Unix socket: `/var/run/unraid-api.sock`
- Auth: `x-api-key` header
### GraphQL Queries
| Query | Description |
|-------|-------------|
| `info` | System info (cpu, memory, os, baseboard, devices, display, versions) |
| `array` | Array state, capacity, disks, parities, caches, parity check status |
| `disks` / `disk(id)` | Physical disks with SMART status, temp, serial |
| `docker` | Containers (id, names, state, status, image, ports, autoStart) + networks |
| `vms` | VMs (id, name, state) |
| `shares` | Shares (name, free, used, size, include, exclude, cache, color) |
| `notifications` | Notifications (title, subject, description, importance, type, timestamp) |
| `vars` | Server variables (version, name, timezone, network, SMB/NFS settings) |
| `services` | Running services (name, online, uptime, version) |
| `flash` | Flash drive (guid, vendor, product) |
| `registration` | License (type, state, keyFile, expiration) |
| `network` | Network info (accessUrls) |
| `server` | Server details (owner, guid, wanip, lanip, localurl, remoteurl) |
| `connect` | Connect status (dynamicRemoteAccess, settings) |
| `plugins` | Installed plugins (name, version) |
| `logFiles` / `logFile(path)` | Log file listing and content |
| `upsDevices` / `upsConfiguration` | UPS monitoring |
| `apiKeys` | API key management |
| `customization` | Theme + activation code |
| `me` / `owner` | Current user / server owner |
### GraphQL Mutations
| Mutation | Description |
|----------|-------------|
| `array.setState` | Start/stop array |
| `array.addDiskToArray` / `removeDiskFromArray` | Disk management |
| `array.mountArrayDisk` / `unmountArrayDisk` | Mount/unmount |
| `docker.start(id)` / `docker.stop(id)` | Container start/stop |
| `vm.start/stop/pause/resume/forceStop/reboot/reset(id)` | VM power management |
| `parityCheck.start/pause/resume/cancel` | Parity check control |
| `notification.create/delete/archive/unread` | Notification CRUD |
| `apiKey.create/update/delete` | API key management |
| `customization.setTheme` | Theme switching |
| `connectSignIn/SignOut` | Unraid Connect |
| `setupRemoteAccess` | Remote access config |
### GraphQL Subscriptions (Real-time)
- `systemMetricsCpu` / `systemMetricsCpuTelemetry` / `systemMetricsMemory`
- `arraySubscription` / `parityHistorySubscription`
- `notificationAdded` / `notificationsOverview`
- `logFile` (live log streaming)
- `upsUpdates`
- `ownerSubscription` / `serversSubscription`
### Enums
- ArrayState: STARTED, STOPPED, NEW_ARRAY, RECON_DISK, DISABLE_DISK, etc.
- ArrayDiskStatus: DISK_NP, DISK_OK, DISK_INVALID, DISK_WRONG, DISK_DSBL, etc.
- ArrayDiskType: DATA, PARITY, FLASH, CACHE
- ContainerState: RUNNING, EXITED
- VmState: NOSTATE, RUNNING, IDLE, PAUSED, SHUTDOWN, SHUTOFF, CRASHED, PMSUSPENDED
- DiskFsType: XFS, BTRFS, VFAT, ZFS, EXT4, NTFS
- DiskInterfaceType: SAS, SATA, USB, PCIE, UNKNOWN
- ThemeName: azure, black, gray, white
### Legacy PHP/Nchan Endpoints
- Nchan WebSocket channels: `/sub/var`, `/sub/docker`, `/sub/update1-3`, `/sub/diskload`, etc.
- PHP includes: DashboardApps.php, Control.php, SmartInfo.php, Syslog.php, etc.
- State INI files at `/var/local/emhttp/`: var.ini, disks.ini, shares.ini, etc.
- Legacy control: POST to `/update.htm` for commands
### NOT Available via GraphQL
- Docker create/delete/restart/logs
- VM create/delete
- Share CRUD, User CRUD
- Detailed SMART attributes
- Disk format/clear, spin up/down
- System reboot/shutdown
- Mover operations

View File

@@ -6,9 +6,6 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
});
module.exports = withBundleAnalyzer({
eslint: {
ignoreDuringBuilds: true,
},
webpack: (config) => {
// for dynamic loading of auth providers
config.experiments = { ...config.experiments, topLevelAwait: true };
@@ -24,7 +21,7 @@ module.exports = withBundleAnalyzer({
redirects: async () => [
{
source: '/',
destination: '/unraid',
destination: '/board',
permanent: false,
},
],

View File

@@ -187,6 +187,7 @@
"importOrderSortSpecifiers": true
},
"eslintConfig": {
"ignoreDuringBuilds": true,
"extends": [
"next",
"eslint:recommended",

View File

@@ -1,38 +0,0 @@
#!/bin/bash
# Build and push Homarr Unraid UI to Gitea registry
# Uses multi-stage Dockerfile to build inside Docker
set -e
REGISTRY="git.xtrm-lab.org"
IMAGE_NAME="jazzymc/homarr"
TAG="${1:-latest}"
echo "=== Building Homarr Unraid UI ==="
echo "Using multi-stage Docker build (no local dependencies required)"
echo ""
# Build Docker image using the multi-stage Dockerfile
echo "Building Docker image (this may take 5-10 minutes)..."
docker build -f Dockerfile.unraid -t "${REGISTRY}/${IMAGE_NAME}:${TAG}" .
# Login to Gitea registry (if not already logged in)
echo ""
echo "Logging into Gitea registry..."
docker login "${REGISTRY}" || echo "Already logged in or use: docker login ${REGISTRY}"
# Push to registry
echo ""
echo "Pushing to registry..."
docker push "${REGISTRY}/${IMAGE_NAME}:${TAG}"
echo ""
echo "=== Build Complete ==="
echo "Image: ${REGISTRY}/${IMAGE_NAME}:${TAG}"
echo ""
echo "To deploy on Unraid:"
echo "1. SSH to Unraid: ssh root@192.168.10.20 -p 422"
echo "2. Create directory: mkdir -p /mnt/user/appdata/unmarr/{data,configs}"
echo "3. Copy docker-compose.unraid.yml to Unraid"
echo "4. Set UNRAID_API_KEY in environment"
echo "5. Run: docker compose -f docker-compose.unraid.yml up -d"

View File

@@ -1,306 +0,0 @@
/**
* 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,
IconDisc,
} from '@tabler/icons-react';
import type { UnraidArray, ArrayDisk, ArrayState } from '~/lib/unraid/types';
interface ArrayCardProps {
array: UnraidArray;
onStartArray?: () => void;
onStopArray?: () => void;
isLoading?: boolean;
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function getStatusColor(status: string): string {
switch (status) {
case 'DISK_OK':
return 'green';
case 'DISK_INVALID':
case 'DISK_WRONG':
return 'red';
case 'DISK_DSBL':
case 'DISK_DSBL_NEW':
return 'orange';
case 'DISK_NEW':
return 'blue';
case 'DISK_NP':
return 'gray';
default:
return 'gray';
}
}
function getArrayStateColor(state: ArrayState): string {
switch (state) {
case 'STARTED':
return 'green';
case 'STOPPED':
return 'red';
default:
return 'orange';
}
}
function DiskRow({ disk }: { disk: ArrayDisk }) {
const usedPercent = disk.fsSize && disk.fsUsed
? ((disk.fsUsed / disk.fsSize) * 100).toFixed(1)
: null;
return (
<tr>
<td>
<Group spacing="xs">
<ThemeIcon size="sm" variant="light" color={disk.spunDown ? 'gray' : 'blue'}>
<IconDisc size={14} />
</ThemeIcon>
<Text size="sm" weight={500}>
{disk.name}
</Text>
</Group>
</td>
<td>
<Badge size="xs" color={getStatusColor(disk.status)} variant="light">
{disk.status.replace('DISK_', '')}
</Badge>
</td>
<td>
<Text size="sm">{formatBytes(disk.size)}</Text>
</td>
<td>
{disk.temp !== null ? (
<Group spacing={4}>
<IconTemperature size={14} />
<Text size="sm" color={disk.temp > 50 ? 'red' : disk.temp > 40 ? 'orange' : undefined}>
{disk.temp}°C
</Text>
</Group>
) : (
<Text size="sm" color="dimmed">
{disk.spunDown ? 'Spun down' : '-'}
</Text>
)}
</td>
<td style={{ width: 150 }}>
{usedPercent ? (
<Tooltip label={`${formatBytes(disk.fsUsed!)} / ${formatBytes(disk.fsSize!)}`}>
<Progress
value={parseFloat(usedPercent)}
size="sm"
radius="md"
color={parseFloat(usedPercent) > 90 ? 'red' : parseFloat(usedPercent) > 75 ? 'orange' : 'blue'}
/>
</Tooltip>
) : (
<Text size="sm" color="dimmed">-</Text>
)}
</td>
</tr>
);
}
export function ArrayCard({ array, onStartArray, onStopArray, isLoading }: ArrayCardProps) {
const usedPercent = ((array.capacity.used / array.capacity.total) * 100).toFixed(1);
const isStarted = array.state === 'STARTED';
return (
<Card shadow="sm" radius="md" withBorder>
<Card.Section withBorder inheritPadding py="xs">
<Group position="apart">
<Group>
<ThemeIcon size="lg" radius="md" variant="light" color="blue">
<IconDatabase size={20} />
</ThemeIcon>
<div>
<Title order={4}>Array</Title>
<Text size="xs" color="dimmed">
{formatBytes(array.capacity.used)} / {formatBytes(array.capacity.total)}
</Text>
</div>
</Group>
<Group spacing="xs">
<Badge color={getArrayStateColor(array.state)} variant="filled">
{array.state}
</Badge>
{isStarted && onStopArray && (
<Tooltip label="Stop Array">
<ActionIcon
color="red"
variant="light"
onClick={onStopArray}
loading={isLoading}
>
<IconPlayerStop size={16} />
</ActionIcon>
</Tooltip>
)}
{!isStarted && onStartArray && (
<Tooltip label="Start Array">
<ActionIcon
color="green"
variant="light"
onClick={onStartArray}
loading={isLoading}
>
<IconPlayerPlay size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
</Card.Section>
<Stack spacing="md" mt="md">
{/* Array Capacity */}
<div>
<Group position="apart" mb={5}>
<Text size="sm" weight={500}>
Total Capacity
</Text>
<Text size="xs" color="dimmed">
{usedPercent}% used
</Text>
</Group>
<Progress
value={parseFloat(usedPercent)}
size="lg"
radius="md"
color={parseFloat(usedPercent) > 90 ? 'red' : parseFloat(usedPercent) > 75 ? 'orange' : 'blue'}
/>
</div>
{/* Parity Check Status */}
{array.parityCheckStatus?.running && (
<div>
<Group position="apart" mb={5}>
<Text size="sm" weight={500} color="orange">
Parity Check in Progress
</Text>
<Text size="xs" color="dimmed">
{array.parityCheckStatus.progress.toFixed(1)}% - {array.parityCheckStatus.errors} errors
</Text>
</Group>
<Progress
value={array.parityCheckStatus.progress}
size="md"
radius="md"
color="orange"
animate
/>
</div>
)}
{/* Parity Disks */}
{array.parities.length > 0 && (
<>
<Text size="sm" weight={600} mt="xs">
Parity ({array.parities.length})
</Text>
<Table fontSize="sm" verticalSpacing={4}>
<thead>
<tr>
<th>Disk</th>
<th>Status</th>
<th>Size</th>
<th>Temp</th>
<th>Usage</th>
</tr>
</thead>
<tbody>
{array.parities.map((disk) => (
<DiskRow key={disk.id} disk={disk} />
))}
</tbody>
</Table>
</>
)}
{/* Data Disks */}
{array.disks.length > 0 && (
<>
<Text size="sm" weight={600} mt="xs">
Data Disks ({array.disks.length})
</Text>
<Table fontSize="sm" verticalSpacing={4}>
<thead>
<tr>
<th>Disk</th>
<th>Status</th>
<th>Size</th>
<th>Temp</th>
<th>Usage</th>
</tr>
</thead>
<tbody>
{array.disks.map((disk) => (
<DiskRow key={disk.id} disk={disk} />
))}
</tbody>
</Table>
</>
)}
{/* Cache Pools */}
{array.caches.length > 0 && (
<>
<Text size="sm" weight={600} mt="xs">
Cache Pools ({array.caches.length})
</Text>
{array.caches.map((cache) => {
const cacheUsedPercent = ((cache.fsUsed / cache.fsSize) * 100).toFixed(1);
return (
<div key={cache.id}>
<Group position="apart" mb={5}>
<Text size="sm" weight={500}>
{cache.name} ({cache.fsType})
</Text>
<Text size="xs" color="dimmed">
{formatBytes(cache.fsUsed)} / {formatBytes(cache.fsSize)}
</Text>
</Group>
<Progress
value={parseFloat(cacheUsedPercent)}
size="sm"
radius="md"
color={parseFloat(cacheUsedPercent) > 90 ? 'red' : parseFloat(cacheUsedPercent) > 75 ? 'orange' : 'teal'}
/>
</div>
);
})}
</>
)}
</Stack>
</Card>
);
}
export default ArrayCard;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
export { UnraidLayout } from './UnraidLayout';

View File

@@ -40,12 +40,6 @@ 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(),
@@ -173,11 +167,6 @@ 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,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,522 +0,0 @@
/**
* 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 type ShareSecurityLevel = 'PUBLIC' | 'SECURE' | 'PRIVATE';
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: ShareSecurityLevel;
}
// ============================================================================
// USER TYPES
// ============================================================================
export interface User {
id: string;
name: string;
description?: string;
}
// ============================================================================
// NOTIFICATION TYPES
// ============================================================================
export interface Notification {
id: string;
title: string;
subject: string;
description: string;
importance: NotificationImportance;
type: string;
timestamp: string;
read: boolean;
archived: boolean;
}
// ============================================================================
// DISK TYPES
// ============================================================================
export interface Disk {
id: string;
name: string;
device: string;
size: number;
vendor: string;
model: string;
serial: string;
firmware: string;
type: string;
interfaceType: DiskInterfaceType;
rotational: boolean;
temp: number | null;
smartStatus: string;
}
// ============================================================================
// SERVICES TYPES
// ============================================================================
export interface Service {
name: string;
online: boolean;
uptime: number | null;
version: string | null;
}
// ============================================================================
// SERVER VARIABLES
// ============================================================================
export interface ServerVars {
version: string;
name: string;
timezone: string;
description: string;
model: string;
protocol: string;
port: number;
localTld: string;
csrf: string;
uptime: number;
}
// ============================================================================
// FLASH DRIVE
// ============================================================================
export interface Flash {
guid: string;
vendor: string;
product: string;
}
// ============================================================================
// REGISTRATION / LICENSE
// ============================================================================
export interface Registration {
type: 'Basic' | 'Plus' | 'Pro' | 'Lifetime' | 'Trial' | 'Expired';
state: string;
keyFile: string | null;
expiration: string | null;
}
// ============================================================================
// NETWORK
// ============================================================================
export interface Network {
accessUrls: AccessUrl[];
}
export interface AccessUrl {
type: string;
name: string;
ipv4: string | null;
ipv6: string | null;
url: string;
}
// ============================================================================
// SERVER
// ============================================================================
export interface Server {
owner: string | null;
guid: string;
wanip: string | null;
lanip: string;
localurl: string;
remoteurl: string | null;
}
// ============================================================================
// UPS
// ============================================================================
export interface UpsDevice {
id: string;
name: string;
model: string;
status: string;
batteryCharge: number;
batteryRuntime: number;
load: number;
inputVoltage: number;
outputVoltage: number;
}
// ============================================================================
// PLUGINS
// ============================================================================
export interface Plugin {
name: string;
version: string;
author: string;
url: string;
icon: string | null;
updateAvailable: boolean;
}
// ============================================================================
// CUSTOMIZATION
// ============================================================================
export interface Customization {
theme: ThemeName;
}
// ============================================================================
// API RESPONSE WRAPPERS
// ============================================================================
export interface UnraidApiResponse<T> {
data: T;
errors?: Array<{
message: string;
path?: string[];
}>;
}
// ============================================================================
// REAL-TIME METRICS (Subscriptions)
// ============================================================================
export interface CpuMetrics {
cores: number[];
average: number;
temperature?: number;
}
export interface MemoryMetrics {
total: number;
used: number;
free: number;
cached: number;
buffers: number;
percent: number;
}
// ============================================================================
// MUTATION INPUTS
// ============================================================================
export interface ArraySetStateInput {
state: 'start' | 'stop';
}
export interface DockerControlInput {
id: string;
}
export interface VmPowerInput {
id: string;
}
export interface ParityCheckInput {
action: 'start' | 'pause' | 'resume' | 'cancel';
mode?: 'check' | 'correct';
}
export interface NotificationInput {
id: string;
}
export interface CreateNotificationInput {
title: string;
subject: string;
description: string;
importance: NotificationImportance;
}

View File

@@ -1,726 +0,0 @@
/**
* 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,
IconDisc,
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)}
>
<IconDisc 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={<IconDisc 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">
<IconDisc 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,
},
};
};

View File

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

View File

@@ -1,357 +0,0 @@
/**
* 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 { 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 UnraidDashboardPage() {
const theme = useMantineTheme();
const [loadingContainers, setLoadingContainers] = useState<string[]>([]);
const [loadingVms, setLoadingVms] = useState<string[]>([]);
const [arrayLoading, setArrayLoading] = useState(false);
// Fetch dashboard data
const {
data: dashboard,
isLoading,
error,
refetch,
} = api.unraid.dashboard.useQuery(undefined, {
refetchInterval: 10000, // Refresh every 10 seconds
});
// Mutations
const startContainer = api.unraid.startContainer.useMutation({
onMutate: ({ id }) => setLoadingContainers((prev) => [...prev, id]),
onSettled: (_, __, { id }) =>
setLoadingContainers((prev) => prev.filter((cid) => cid !== id)),
onSuccess: () => {
notifications.show({
title: 'Container Started',
message: 'Container started successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const stopContainer = api.unraid.stopContainer.useMutation({
onMutate: ({ id }) => setLoadingContainers((prev) => [...prev, id]),
onSettled: (_, __, { id }) =>
setLoadingContainers((prev) => prev.filter((cid) => cid !== id)),
onSuccess: () => {
notifications.show({
title: 'Container Stopped',
message: 'Container stopped successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const startVm = api.unraid.startVm.useMutation({
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
onSuccess: () => {
notifications.show({
title: 'VM Started',
message: 'Virtual machine started successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const stopVm = api.unraid.stopVm.useMutation({
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
onSuccess: () => {
notifications.show({
title: 'VM Stopped',
message: 'Virtual machine stopped successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const pauseVm = api.unraid.pauseVm.useMutation({
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
onSuccess: () => {
notifications.show({
title: 'VM Paused',
message: 'Virtual machine paused successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
});
const resumeVm = api.unraid.resumeVm.useMutation({
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
onSuccess: () => {
notifications.show({
title: 'VM Resumed',
message: 'Virtual machine resumed successfully',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
});
const rebootVm = api.unraid.rebootVm.useMutation({
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
onSuccess: () => {
notifications.show({
title: 'VM Rebooting',
message: 'Virtual machine is rebooting',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
});
const startArray = api.unraid.startArray.useMutation({
onMutate: () => setArrayLoading(true),
onSettled: () => setArrayLoading(false),
onSuccess: () => {
notifications.show({
title: 'Array Starting',
message: 'Array is starting...',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const stopArray = api.unraid.stopArray.useMutation({
onMutate: () => setArrayLoading(true),
onSettled: () => setArrayLoading(false),
onSuccess: () => {
notifications.show({
title: 'Array Stopping',
message: 'Array is stopping...',
color: 'green',
icon: <IconCheck size={16} />,
});
refetch();
},
onError: (error) => {
notifications.show({
title: 'Error',
message: error.message,
color: 'red',
icon: <IconAlertCircle size={16} />,
});
},
});
const unreadNotifications = dashboard?.notifications?.filter((n) => !n.read).length || 0;
// Loading state
if (isLoading) {
return (
<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 (
<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 (
<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 (
<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>
{/* Dashboard Grid */}
<Grid>
{/* System Info */}
<Grid.Col md={6} lg={4}>
<SystemInfoCard
info={dashboard.info}
vars={dashboard.vars}
registration={dashboard.registration}
/>
</Grid.Col>
{/* Array */}
<Grid.Col md={6} lg={8}>
<ArrayCard
array={dashboard.array}
onStartArray={() => startArray.mutate()}
onStopArray={() => stopArray.mutate()}
isLoading={arrayLoading}
/>
</Grid.Col>
{/* Docker */}
<Grid.Col md={6}>
<DockerCard
docker={dashboard.docker}
onStartContainer={(id) => startContainer.mutate({ id })}
onStopContainer={(id) => stopContainer.mutate({ id })}
loadingContainers={loadingContainers}
/>
</Grid.Col>
{/* VMs */}
<Grid.Col md={6}>
<VmsCard
vms={dashboard.vms}
onStartVm={(id) => startVm.mutate({ id })}
onStopVm={(id) => stopVm.mutate({ id })}
onPauseVm={(id) => pauseVm.mutate({ id })}
onResumeVm={(id) => resumeVm.mutate({ id })}
onRebootVm={(id) => rebootVm.mutate({ id })}
loadingVms={loadingVms}
/>
</Grid.Col>
</Grid>
</Stack>
</Container>
</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,
},
};
};

View File

@@ -1,288 +0,0 @@
/**
* 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.description || '',
model: vars.model || '',
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?.os.kernel || 'Unknown'}
</Text>
</Group>
<Divider />
<Group position="apart">
<Text size="sm" color="dimmed">
CPU
</Text>
<Text size="sm" weight={500}>
{info?.cpu.brand || 'Unknown'}
</Text>
</Group>
<Divider />
<Group position="apart">
<Text size="sm" color="dimmed">
Motherboard
</Text>
<Text size="sm" weight={500}>
{info?.baseboard?.model || '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>
{/* Server Model */}
<Card shadow="sm" radius="md" withBorder>
<Title order={4} mb="md">
Hardware
</Title>
<Stack spacing="md">
<Group position="apart">
<Text size="sm" color="dimmed">
Model
</Text>
<Text size="sm" weight={500}>
{vars?.model || 'Unknown'}
</Text>
</Group>
<Divider />
<Group position="apart">
<Text size="sm" color="dimmed">
Protocol
</Text>
<Text size="sm" weight={500}>
{vars?.protocol || 'Unknown'}
</Text>
</Group>
<Divider />
<Group position="apart">
<Text size="sm" color="dimmed">
Port
</Text>
<Text size="sm" weight={500} style={{ fontFamily: 'monospace' }}>
{vars?.port || '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,
},
};
};

View File

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

View File

@@ -1,459 +0,0 @@
/**
* 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: string): string {
return new Date(timestamp).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,
},
};
};

View File

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

View File

@@ -1,383 +0,0 @@
/**
* 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?.os.kernel || '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.brand || '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,
},
};
};

View File

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

View File

@@ -1,373 +0,0 @@
/**
* 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(
undefined,
{
refetchInterval: autoScroll ? 5000 : false,
}
);
useEffect(() => {
if (syslog) {
setLines(syslog || []);
}
}, [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,
},
};
};

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
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';
@@ -56,7 +55,6 @@ export const rootRouter = createTRPCRouter({
healthMonitoring: healthMonitoringRouter,
tdarr: tdarrRouter,
migrate: migrateRouter,
unraid: unraidRouter,
});
// export type definition of API

View File

@@ -1,445 +0,0 @@
/**
* 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);
}),
// ============================================================================
// USERS
// ============================================================================
/**
* Get users
*/
users: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getUsers();
}),
// ============================================================================
// SYSLOG
// ============================================================================
/**
* Get syslog lines
*/
syslog: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getSyslog();
}),
// ============================================================================
// NOTIFICATION ACTIONS
// ============================================================================
/**
* Mark notification as read
*/
markNotificationRead: protectedProcedure
.input(notificationIdSchema)
.mutation(async ({ input }) => {
const client = getUnraidClient();
return client.markNotificationRead(input.id);
}),
/**
* Mark all notifications as read
*/
markAllNotificationsRead: protectedProcedure.mutation(async () => {
const client = getUnraidClient();
return client.markAllNotificationsRead();
}),
// ============================================================================
// SERVICES
// ============================================================================
/**
* Get services
*/
services: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getServices();
}),
// ============================================================================
// NETWORK
// ============================================================================
/**
* Get network info
*/
network: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getNetwork();
}),
// ============================================================================
// UPS
// ============================================================================
/**
* Get UPS devices
*/
upsDevices: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getUpsDevices();
}),
// ============================================================================
// PLUGINS
// ============================================================================
/**
* Get installed plugins
*/
plugins: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getPlugins();
}),
// ============================================================================
// CUSTOMIZATION
// ============================================================================
/**
* Get customization settings
*/
customization: protectedProcedure.query(async () => {
const client = getUnraidClient();
return client.getCustomization();
}),
});

View File

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

View File

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

View File

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

View File

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