Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f92f0593f | ||
|
|
783a12b444 | ||
|
|
a48a06e680 | ||
|
|
9a2c56a5dc | ||
|
|
83a8546521 | ||
|
|
e881ec6cb5 | ||
|
|
ce336311b1 | ||
|
|
c86c69158a |
@@ -10,4 +10,10 @@ NEXTAUTH_SECRET="anything"
|
|||||||
# Disable analytics
|
# Disable analytics
|
||||||
NEXT_PUBLIC_DISABLE_ANALYTICS="true"
|
NEXT_PUBLIC_DISABLE_ANALYTICS="true"
|
||||||
|
|
||||||
DEFAULT_COLOR_SCHEME="light"
|
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
|
||||||
105
Dockerfile.unraid
Normal file
105
Dockerfile.unraid
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# 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"]
|
||||||
42
docker-compose.unraid.yml
Normal file
42
docker-compose.unraid.yml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
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
|
||||||
148
docs/unraid-ui-project/README.md
Normal file
148
docs/unraid-ui-project/README.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
# 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
|
||||||
|
```
|
||||||
1505
docs/unraid-ui-project/WEBGUI-COMPLETE-INVENTORY.md
Normal file
1505
docs/unraid-ui-project/WEBGUI-COMPLETE-INVENTORY.md
Normal file
File diff suppressed because it is too large
Load Diff
60
docs/unraid-ui-project/homarr-architecture.md
Normal file
60
docs/unraid-ui-project/homarr-architecture.md
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# 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
|
||||||
1329
docs/unraid-ui-project/orchis-design-system.ts
Normal file
1329
docs/unraid-ui-project/orchis-design-system.ts
Normal file
File diff suppressed because it is too large
Load Diff
81
docs/unraid-ui-project/unraid-api-research.md
Normal file
81
docs/unraid-ui-project/unraid-api-research.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 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
|
||||||
@@ -6,6 +6,9 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = withBundleAnalyzer({
|
module.exports = withBundleAnalyzer({
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
webpack: (config) => {
|
webpack: (config) => {
|
||||||
// for dynamic loading of auth providers
|
// for dynamic loading of auth providers
|
||||||
config.experiments = { ...config.experiments, topLevelAwait: true };
|
config.experiments = { ...config.experiments, topLevelAwait: true };
|
||||||
@@ -21,7 +24,7 @@ module.exports = withBundleAnalyzer({
|
|||||||
redirects: async () => [
|
redirects: async () => [
|
||||||
{
|
{
|
||||||
source: '/',
|
source: '/',
|
||||||
destination: '/board',
|
destination: '/unraid',
|
||||||
permanent: false,
|
permanent: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -187,7 +187,6 @@
|
|||||||
"importOrderSortSpecifiers": true
|
"importOrderSortSpecifiers": true
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"ignoreDuringBuilds": true,
|
|
||||||
"extends": [
|
"extends": [
|
||||||
"next",
|
"next",
|
||||||
"eslint:recommended",
|
"eslint:recommended",
|
||||||
|
|||||||
38
scripts/build-and-push.sh
Executable file
38
scripts/build-and-push.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/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"
|
||||||
306
src/components/Unraid/Dashboard/ArrayCard.tsx
Normal file
306
src/components/Unraid/Dashboard/ArrayCard.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* Array Card Component
|
||||||
|
* Displays Unraid array status and disk information
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Progress,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconDatabase,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerStop,
|
||||||
|
IconTemperature,
|
||||||
|
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;
|
||||||
205
src/components/Unraid/Dashboard/DockerCard.tsx
Normal file
205
src/components/Unraid/Dashboard/DockerCard.tsx
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Docker Card Component
|
||||||
|
* Displays Docker containers with start/stop controls
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconBrandDocker,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerStop,
|
||||||
|
IconDots,
|
||||||
|
IconBox,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
import type { Docker, DockerContainer, ContainerState } from '~/lib/unraid/types';
|
||||||
|
|
||||||
|
interface DockerCardProps {
|
||||||
|
docker: Docker;
|
||||||
|
onStartContainer?: (id: string) => void;
|
||||||
|
onStopContainer?: (id: string) => void;
|
||||||
|
loadingContainers?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateColor(state: ContainerState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'RUNNING':
|
||||||
|
return 'green';
|
||||||
|
case 'EXITED':
|
||||||
|
return 'red';
|
||||||
|
case 'PAUSED':
|
||||||
|
return 'yellow';
|
||||||
|
case 'RESTARTING':
|
||||||
|
return 'orange';
|
||||||
|
case 'CREATED':
|
||||||
|
return 'blue';
|
||||||
|
case 'DEAD':
|
||||||
|
return 'gray';
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerRow({
|
||||||
|
container,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
container: DockerContainer;
|
||||||
|
onStart?: () => void;
|
||||||
|
onStop?: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
|
const isRunning = container.state === 'RUNNING';
|
||||||
|
const containerName = container.names[0]?.replace(/^\//, '') || 'Unknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group position="apart" py="xs" sx={(theme) => ({
|
||||||
|
borderBottom: `1px solid ${
|
||||||
|
theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]
|
||||||
|
}`,
|
||||||
|
'&:last-child': {
|
||||||
|
borderBottom: 'none',
|
||||||
|
},
|
||||||
|
})}>
|
||||||
|
<Group spacing="sm">
|
||||||
|
<ThemeIcon size="md" variant="light" color={getStateColor(container.state)}>
|
||||||
|
<IconBox size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" weight={500} lineClamp={1}>
|
||||||
|
{containerName}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||||
|
{container.image}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Badge size="xs" color={getStateColor(container.state)} variant="light">
|
||||||
|
{container.state}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{isRunning && onStop && (
|
||||||
|
<Tooltip label="Stop">
|
||||||
|
<ActionIcon
|
||||||
|
color="red"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onStop}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerStop size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isRunning && onStart && (
|
||||||
|
<Tooltip label="Start">
|
||||||
|
<ActionIcon
|
||||||
|
color="green"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onStart}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Menu shadow="md" width={150} position="bottom-end">
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle" size="sm">
|
||||||
|
<IconDots size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item disabled>View Logs</Menu.Item>
|
||||||
|
<Menu.Item disabled>Edit</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item color="red" disabled>
|
||||||
|
Remove
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DockerCard({
|
||||||
|
docker,
|
||||||
|
onStartContainer,
|
||||||
|
onStopContainer,
|
||||||
|
loadingContainers = [],
|
||||||
|
}: DockerCardProps) {
|
||||||
|
const runningCount = docker.containers.filter((c) => c.state === 'RUNNING').length;
|
||||||
|
const totalCount = docker.containers.length;
|
||||||
|
|
||||||
|
// Sort containers: running first, then by name
|
||||||
|
const sortedContainers = [...docker.containers].sort((a, b) => {
|
||||||
|
if (a.state === 'RUNNING' && b.state !== 'RUNNING') return -1;
|
||||||
|
if (a.state !== 'RUNNING' && b.state === 'RUNNING') return 1;
|
||||||
|
return (a.names[0] || '').localeCompare(b.names[0] || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Card.Section withBorder inheritPadding py="xs">
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size="lg" radius="md" variant="light" color="cyan">
|
||||||
|
<IconBrandDocker size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={4}>Docker</Title>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
{runningCount} running / {totalCount} total
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Badge color="cyan" variant="light">
|
||||||
|
{docker.networks.length} networks
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<ScrollArea 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;
|
||||||
192
src/components/Unraid/Dashboard/SystemInfoCard.tsx
Normal file
192
src/components/Unraid/Dashboard/SystemInfoCard.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* System Info Card Component
|
||||||
|
* Displays Unraid system information in the dashboard
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Progress,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconCpu,
|
||||||
|
IconDeviceDesktop,
|
||||||
|
IconServer,
|
||||||
|
IconClock,
|
||||||
|
IconBrandUbuntu,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
import type { SystemInfo, ServerVars, Registration } from '~/lib/unraid/types';
|
||||||
|
|
||||||
|
interface SystemInfoCardProps {
|
||||||
|
info: SystemInfo;
|
||||||
|
vars: ServerVars;
|
||||||
|
registration: Registration;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SystemInfoCard({ info, vars, registration }: SystemInfoCardProps) {
|
||||||
|
const memoryUsedPercent = ((info.memory.used / info.memory.total) * 100).toFixed(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Card.Section withBorder inheritPadding py="xs">
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size="lg" radius="md" variant="light" color="blue">
|
||||||
|
<IconServer size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={4}>{vars.name}</Title>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
{vars.description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
<Badge
|
||||||
|
color={registration.type === 'Pro' ? 'green' : registration.type === 'Plus' ? 'blue' : 'gray'}
|
||||||
|
variant="filled"
|
||||||
|
>
|
||||||
|
{registration.type}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<Stack spacing="md" mt="md">
|
||||||
|
{/* CPU Info */}
|
||||||
|
<Group position="apart" noWrap>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<ThemeIcon size="sm" radius="md" variant="light" color="violet">
|
||||||
|
<IconCpu size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
CPU
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" color="dimmed" align="right" style={{ flex: 1 }}>
|
||||||
|
{info.cpu.brand}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<SimpleGrid cols={3} spacing="xs">
|
||||||
|
<div>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Cores
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
{info.cpu.cores}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Threads
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
{info.cpu.threads}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Speed
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
{info.cpu.speed.toFixed(2)} GHz
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Memory Info */}
|
||||||
|
<div>
|
||||||
|
<Group position="apart" mb={5}>
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
Memory
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
{formatBytes(info.memory.used)} / {formatBytes(info.memory.total)} ({memoryUsedPercent}%)
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={parseFloat(memoryUsedPercent)}
|
||||||
|
size="md"
|
||||||
|
radius="md"
|
||||||
|
color={parseFloat(memoryUsedPercent) > 80 ? 'red' : parseFloat(memoryUsedPercent) > 60 ? 'yellow' : 'blue'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OS Info */}
|
||||||
|
<Group position="apart" noWrap>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<ThemeIcon size="sm" radius="md" variant="light" color="green">
|
||||||
|
<IconBrandUbuntu size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
OS
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
Unraid {info.versions.unraid}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Motherboard */}
|
||||||
|
<Group position="apart" noWrap>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<ThemeIcon size="sm" radius="md" variant="light" color="orange">
|
||||||
|
<IconDeviceDesktop size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
Motherboard
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" color="dimmed" lineClamp={1}>
|
||||||
|
{info.baseboard.manufacturer} {info.baseboard.model}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Uptime */}
|
||||||
|
<Group position="apart" noWrap>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<ThemeIcon size="sm" radius="md" variant="light" color="teal">
|
||||||
|
<IconClock size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
Uptime
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
{formatUptime(info.os.uptime)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SystemInfoCard;
|
||||||
277
src/components/Unraid/Dashboard/VmsCard.tsx
Normal file
277
src/components/Unraid/Dashboard/VmsCard.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* VMs Card Component
|
||||||
|
* Displays Virtual Machines with power controls
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconServer2,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerStop,
|
||||||
|
IconPlayerPause,
|
||||||
|
IconRefresh,
|
||||||
|
IconDots,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
|
||||||
|
import type { VirtualMachine, VmState } from '~/lib/unraid/types';
|
||||||
|
|
||||||
|
interface VmsCardProps {
|
||||||
|
vms: VirtualMachine[];
|
||||||
|
onStartVm?: (id: string) => void;
|
||||||
|
onStopVm?: (id: string) => void;
|
||||||
|
onPauseVm?: (id: string) => void;
|
||||||
|
onResumeVm?: (id: string) => void;
|
||||||
|
onRebootVm?: (id: string) => void;
|
||||||
|
loadingVms?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStateColor(state: VmState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'RUNNING':
|
||||||
|
return 'green';
|
||||||
|
case 'SHUTOFF':
|
||||||
|
return 'red';
|
||||||
|
case 'PAUSED':
|
||||||
|
case 'PMSUSPENDED':
|
||||||
|
return 'yellow';
|
||||||
|
case 'SHUTDOWN':
|
||||||
|
return 'orange';
|
||||||
|
case 'IDLE':
|
||||||
|
return 'blue';
|
||||||
|
case 'CRASHED':
|
||||||
|
return 'red';
|
||||||
|
case 'NOSTATE':
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMemory(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function VmRow({
|
||||||
|
vm,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
onReboot,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
vm: VirtualMachine;
|
||||||
|
onStart?: () => void;
|
||||||
|
onStop?: () => void;
|
||||||
|
onPause?: () => void;
|
||||||
|
onResume?: () => void;
|
||||||
|
onReboot?: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}) {
|
||||||
|
const isRunning = vm.state === 'RUNNING';
|
||||||
|
const isPaused = vm.state === 'PAUSED' || vm.state === 'PMSUSPENDED';
|
||||||
|
const isStopped = vm.state === 'SHUTOFF' || vm.state === 'SHUTDOWN' || vm.state === 'NOSTATE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
position="apart"
|
||||||
|
py="xs"
|
||||||
|
sx={(theme) => ({
|
||||||
|
borderBottom: `1px solid ${
|
||||||
|
theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[2]
|
||||||
|
}`,
|
||||||
|
'&:last-child': {
|
||||||
|
borderBottom: 'none',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Group spacing="sm">
|
||||||
|
<ThemeIcon size="md" variant="light" color={getStateColor(vm.state)}>
|
||||||
|
<IconServer2 size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text size="sm" weight={500} lineClamp={1}>
|
||||||
|
{vm.name}
|
||||||
|
</Text>
|
||||||
|
<Group spacing={4}>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
{vm.cpus} vCPU
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
•
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
{formatMemory(vm.memory)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Badge size="xs" color={getStateColor(vm.state)} variant="light">
|
||||||
|
{vm.state}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{isRunning && onPause && (
|
||||||
|
<Tooltip label="Pause">
|
||||||
|
<ActionIcon
|
||||||
|
color="yellow"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onPause}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerPause size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPaused && onResume && (
|
||||||
|
<Tooltip label="Resume">
|
||||||
|
<ActionIcon
|
||||||
|
color="green"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onResume}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRunning && onStop && (
|
||||||
|
<Tooltip label="Stop">
|
||||||
|
<ActionIcon
|
||||||
|
color="red"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onStop}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerStop size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStopped && onStart && (
|
||||||
|
<Tooltip label="Start">
|
||||||
|
<ActionIcon
|
||||||
|
color="green"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={onStart}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Menu shadow="md" width={150} position="bottom-end">
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle" size="sm">
|
||||||
|
<IconDots size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{isRunning && onReboot && (
|
||||||
|
<Menu.Item
|
||||||
|
icon={<IconRefresh size={14} />}
|
||||||
|
onClick={onReboot}
|
||||||
|
>
|
||||||
|
Reboot
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Item disabled>VNC Console</Menu.Item>
|
||||||
|
<Menu.Item disabled>Edit</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item color="red" disabled>
|
||||||
|
Force Stop
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VmsCard({
|
||||||
|
vms,
|
||||||
|
onStartVm,
|
||||||
|
onStopVm,
|
||||||
|
onPauseVm,
|
||||||
|
onResumeVm,
|
||||||
|
onRebootVm,
|
||||||
|
loadingVms = [],
|
||||||
|
}: VmsCardProps) {
|
||||||
|
const runningCount = vms.filter((vm) => vm.state === 'RUNNING').length;
|
||||||
|
const totalCount = vms.length;
|
||||||
|
|
||||||
|
// Sort VMs: running first, then by name
|
||||||
|
const sortedVms = [...vms].sort((a, b) => {
|
||||||
|
if (a.state === 'RUNNING' && b.state !== 'RUNNING') return -1;
|
||||||
|
if (a.state !== 'RUNNING' && b.state === 'RUNNING') return 1;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Card.Section withBorder inheritPadding py="xs">
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size="lg" radius="md" variant="light" color="violet">
|
||||||
|
<IconServer2 size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={4}>Virtual Machines</Title>
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
{runningCount} running / {totalCount} total
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<ScrollArea 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;
|
||||||
8
src/components/Unraid/Dashboard/index.ts
Normal file
8
src/components/Unraid/Dashboard/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Unraid Dashboard Components
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { SystemInfoCard } from './SystemInfoCard';
|
||||||
|
export { ArrayCard } from './ArrayCard';
|
||||||
|
export { DockerCard } from './DockerCard';
|
||||||
|
export { VmsCard } from './VmsCard';
|
||||||
279
src/components/Unraid/Layout/UnraidLayout.tsx
Normal file
279
src/components/Unraid/Layout/UnraidLayout.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
/**
|
||||||
|
* Unraid Layout Component
|
||||||
|
* Main layout wrapper with sidebar navigation for Unraid pages
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
AppShell,
|
||||||
|
Burger,
|
||||||
|
Group,
|
||||||
|
Header,
|
||||||
|
MediaQuery,
|
||||||
|
Navbar,
|
||||||
|
NavLink,
|
||||||
|
ScrollArea,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
useMantineColorScheme,
|
||||||
|
useMantineTheme,
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
Badge,
|
||||||
|
Divider,
|
||||||
|
Box,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconDashboard,
|
||||||
|
IconDatabase,
|
||||||
|
IconBrandDocker,
|
||||||
|
IconServer2,
|
||||||
|
IconFolders,
|
||||||
|
IconSettings,
|
||||||
|
IconTools,
|
||||||
|
IconBell,
|
||||||
|
IconMoon,
|
||||||
|
IconSun,
|
||||||
|
IconChevronRight,
|
||||||
|
IconServer,
|
||||||
|
IconUsers,
|
||||||
|
IconNetwork,
|
||||||
|
IconShield,
|
||||||
|
IconCpu,
|
||||||
|
IconPlug,
|
||||||
|
IconFileText,
|
||||||
|
IconInfoCircle,
|
||||||
|
IconTerminal2,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
interface UnraidLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
notifications?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavItem {
|
||||||
|
icon: React.FC<{ size?: number }>;
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
badge?: number | string;
|
||||||
|
children?: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const mainNavItems: NavItem[] = [
|
||||||
|
{ icon: IconDashboard, label: 'Dashboard', href: '/unraid' },
|
||||||
|
{ icon: IconDatabase, label: 'Array', href: '/unraid/array' },
|
||||||
|
{ icon: IconBrandDocker, label: 'Docker', href: '/unraid/docker' },
|
||||||
|
{ icon: IconServer2, label: 'VMs', href: '/unraid/vms' },
|
||||||
|
{ icon: IconFolders, label: 'Shares', href: '/unraid/shares' },
|
||||||
|
{ icon: IconUsers, label: 'Users', href: '/unraid/users' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const settingsNavItems: NavItem[] = [
|
||||||
|
{ icon: IconServer, label: 'Identification', href: '/unraid/settings/identification' },
|
||||||
|
{ icon: IconDatabase, label: 'Disk Settings', href: '/unraid/settings/disk' },
|
||||||
|
{ icon: IconNetwork, label: 'Network', href: '/unraid/settings/network' },
|
||||||
|
{ icon: IconBrandDocker, label: 'Docker', href: '/unraid/settings/docker' },
|
||||||
|
{ icon: IconServer2, label: 'VM Manager', href: '/unraid/settings/vm' },
|
||||||
|
{ icon: IconShield, label: 'Management Access', href: '/unraid/settings/management' },
|
||||||
|
{ icon: IconCpu, label: 'CPU Pinning', href: '/unraid/settings/cpu' },
|
||||||
|
{ icon: IconBell, label: 'Notifications', href: '/unraid/settings/notifications' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const toolsNavItems: NavItem[] = [
|
||||||
|
{ icon: IconFileText, label: 'System Log', href: '/unraid/tools/syslog' },
|
||||||
|
{ icon: IconInfoCircle, label: 'Diagnostics', href: '/unraid/tools/diagnostics' },
|
||||||
|
{ icon: IconCpu, label: 'System Devices', href: '/unraid/tools/devices' },
|
||||||
|
{ icon: IconTerminal2, label: 'Terminal', href: '/unraid/tools/terminal' },
|
||||||
|
{ icon: IconPlug, label: 'Plugins', href: '/unraid/tools/plugins' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function NavSection({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
currentPath,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
items: NavItem[];
|
||||||
|
currentPath: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{title && (
|
||||||
|
<Text size="xs" weight={500} color="dimmed" px="md" py="xs" transform="uppercase">
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{items.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.href}
|
||||||
|
component={Link}
|
||||||
|
href={item.href}
|
||||||
|
label={item.label}
|
||||||
|
icon={
|
||||||
|
<ThemeIcon variant="light" size="sm">
|
||||||
|
<item.icon size={14} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
active={currentPath === item.href}
|
||||||
|
rightSection={
|
||||||
|
item.badge ? (
|
||||||
|
<Badge size="xs" variant="filled" color="red">
|
||||||
|
{item.badge}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<IconChevronRight size={14} stroke={1.5} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={(theme) => ({
|
||||||
|
borderRadius: '0 9999px 9999px 0',
|
||||||
|
marginRight: theme.spacing.sm,
|
||||||
|
'&[data-active]': {
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === 'dark'
|
||||||
|
? 'rgba(255, 255, 255, 0.1)'
|
||||||
|
: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
color: theme.colors.blue[theme.colorScheme === 'dark' ? 4 : 7],
|
||||||
|
fontWeight: 500,
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnraidLayout({ children, notifications = 0 }: UnraidLayoutProps) {
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
||||||
|
const [opened, setOpened] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
styles={{
|
||||||
|
main: {
|
||||||
|
background: colorScheme === 'dark' ? theme.colors.dark[8] : theme.colors.gray[0],
|
||||||
|
minHeight: '100vh',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
navbarOffsetBreakpoint="sm"
|
||||||
|
navbar={
|
||||||
|
<Navbar
|
||||||
|
p="xs"
|
||||||
|
hiddenBreakpoint="sm"
|
||||||
|
hidden={!opened}
|
||||||
|
width={{ sm: 240, lg: 280 }}
|
||||||
|
sx={(theme) => ({
|
||||||
|
backgroundColor:
|
||||||
|
colorScheme === 'dark' ? theme.colors.dark[7] : theme.colors.gray[0],
|
||||||
|
borderRight: `1px solid ${
|
||||||
|
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2]
|
||||||
|
}`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Navbar.Section grow component={ScrollArea}>
|
||||||
|
<NavSection items={mainNavItems} currentPath={router.pathname} />
|
||||||
|
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<NavSection title="Settings" items={settingsNavItems} currentPath={router.pathname} />
|
||||||
|
|
||||||
|
<Divider my="sm" />
|
||||||
|
|
||||||
|
<NavSection title="Tools" items={toolsNavItems} currentPath={router.pathname} />
|
||||||
|
</Navbar.Section>
|
||||||
|
|
||||||
|
<Navbar.Section>
|
||||||
|
<Divider my="sm" />
|
||||||
|
<Box px="md" py="xs">
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Unraid Custom UI v0.1.0
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Navbar.Section>
|
||||||
|
</Navbar>
|
||||||
|
}
|
||||||
|
header={
|
||||||
|
<Header
|
||||||
|
height={60}
|
||||||
|
px="md"
|
||||||
|
sx={(theme) => ({
|
||||||
|
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[7] : theme.white,
|
||||||
|
borderBottom: `1px solid ${
|
||||||
|
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[2]
|
||||||
|
}`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Group position="apart" sx={{ height: '100%' }}>
|
||||||
|
<Group>
|
||||||
|
<MediaQuery largerThan="sm" styles={{ display: 'none' }}>
|
||||||
|
<Burger
|
||||||
|
opened={opened}
|
||||||
|
onClick={() => setOpened((o) => !o)}
|
||||||
|
size="sm"
|
||||||
|
color={theme.colors.gray[6]}
|
||||||
|
/>
|
||||||
|
</MediaQuery>
|
||||||
|
|
||||||
|
<Group spacing="xs">
|
||||||
|
<ThemeIcon
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: 'blue', to: 'cyan' }}
|
||||||
|
>
|
||||||
|
<IconServer size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={4}>Unraid</Title>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Tooltip label={`${notifications} notifications`}>
|
||||||
|
<ActionIcon variant="subtle" size="lg">
|
||||||
|
<IconBell size={20} />
|
||||||
|
{notifications > 0 && (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="filled"
|
||||||
|
color="red"
|
||||||
|
sx={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
padding: '0 4px',
|
||||||
|
minWidth: 16,
|
||||||
|
height: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notifications > 99 ? '99+' : notifications}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip label={colorScheme === 'dark' ? 'Light mode' : 'Dark mode'}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => toggleColorScheme()}
|
||||||
|
>
|
||||||
|
{colorScheme === 'dark' ? <IconSun size={20} /> : <IconMoon size={20} />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Header>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UnraidLayout;
|
||||||
1
src/components/Unraid/Layout/index.ts
Normal file
1
src/components/Unraid/Layout/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { UnraidLayout } from './UnraidLayout';
|
||||||
11
src/env.js
11
src/env.js
@@ -40,6 +40,12 @@ const env = createEnv({
|
|||||||
DOCKER_HOST: z.string().optional(),
|
DOCKER_HOST: z.string().optional(),
|
||||||
DOCKER_PORT: portSchema,
|
DOCKER_PORT: portSchema,
|
||||||
DEMO_MODE: z.string().optional(),
|
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'),
|
DISABLE_UPGRADE_MODAL: zodParsedBoolean().default('false'),
|
||||||
HOSTNAME: z.string().optional(),
|
HOSTNAME: z.string().optional(),
|
||||||
|
|
||||||
@@ -167,6 +173,11 @@ const env = createEnv({
|
|||||||
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
|
AUTH_SESSION_EXPIRY_TIME: process.env.AUTH_SESSION_EXPIRY_TIME,
|
||||||
DEMO_MODE: process.env.DEMO_MODE,
|
DEMO_MODE: process.env.DEMO_MODE,
|
||||||
DISABLE_UPGRADE_MODAL: process.env.DISABLE_UPGRADE_MODAL,
|
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,
|
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||||
});
|
});
|
||||||
|
|||||||
483
src/lib/unraid/client.ts
Normal file
483
src/lib/unraid/client.ts
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
19
src/lib/unraid/index.ts
Normal file
19
src/lib/unraid/index.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Unraid API Integration
|
||||||
|
* ======================
|
||||||
|
* Type-safe client for the Unraid GraphQL API
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ```typescript
|
||||||
|
* import { getUnraidClient } from '~/lib/unraid';
|
||||||
|
*
|
||||||
|
* const client = getUnraidClient();
|
||||||
|
* const dashboard = await client.getDashboard();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './client';
|
||||||
|
export * from './types';
|
||||||
|
export * from './queries';
|
||||||
|
export * from './queries/mutations';
|
||||||
|
export * from './queries/subscriptions';
|
||||||
536
src/lib/unraid/queries/index.ts
Normal file
536
src/lib/unraid/queries/index.ts
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
/**
|
||||||
|
* Unraid GraphQL Queries
|
||||||
|
* Based on Unraid API v4.29.2
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SYSTEM QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const INFO_QUERY = `
|
||||||
|
query Info {
|
||||||
|
info {
|
||||||
|
cpu {
|
||||||
|
manufacturer
|
||||||
|
brand
|
||||||
|
cores
|
||||||
|
threads
|
||||||
|
speed
|
||||||
|
speedMax
|
||||||
|
cache {
|
||||||
|
l1d
|
||||||
|
l1i
|
||||||
|
l2
|
||||||
|
l3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
memory {
|
||||||
|
total
|
||||||
|
free
|
||||||
|
used
|
||||||
|
active
|
||||||
|
available
|
||||||
|
buffers
|
||||||
|
cached
|
||||||
|
slab
|
||||||
|
swapTotal
|
||||||
|
swapUsed
|
||||||
|
swapFree
|
||||||
|
}
|
||||||
|
os {
|
||||||
|
platform
|
||||||
|
distro
|
||||||
|
release
|
||||||
|
kernel
|
||||||
|
arch
|
||||||
|
hostname
|
||||||
|
uptime
|
||||||
|
}
|
||||||
|
baseboard {
|
||||||
|
manufacturer
|
||||||
|
model
|
||||||
|
version
|
||||||
|
serial
|
||||||
|
}
|
||||||
|
versions {
|
||||||
|
unraid
|
||||||
|
api
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VARS_QUERY = `
|
||||||
|
query Vars {
|
||||||
|
vars {
|
||||||
|
version
|
||||||
|
name
|
||||||
|
timezone
|
||||||
|
description
|
||||||
|
model
|
||||||
|
protocol
|
||||||
|
port
|
||||||
|
localTld
|
||||||
|
csrf
|
||||||
|
uptime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SERVER_QUERY = `
|
||||||
|
query Server {
|
||||||
|
server {
|
||||||
|
owner
|
||||||
|
guid
|
||||||
|
wanip
|
||||||
|
lanip
|
||||||
|
localurl
|
||||||
|
remoteurl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const REGISTRATION_QUERY = `
|
||||||
|
query Registration {
|
||||||
|
registration {
|
||||||
|
type
|
||||||
|
state
|
||||||
|
keyFile
|
||||||
|
expiration
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const FLASH_QUERY = `
|
||||||
|
query Flash {
|
||||||
|
flash {
|
||||||
|
guid
|
||||||
|
vendor
|
||||||
|
product
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ARRAY QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ARRAY_QUERY = `
|
||||||
|
query Array {
|
||||||
|
array {
|
||||||
|
state
|
||||||
|
capacity {
|
||||||
|
total
|
||||||
|
used
|
||||||
|
free
|
||||||
|
disks {
|
||||||
|
total
|
||||||
|
used
|
||||||
|
free
|
||||||
|
}
|
||||||
|
}
|
||||||
|
disks {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
device
|
||||||
|
size
|
||||||
|
status
|
||||||
|
type
|
||||||
|
temp
|
||||||
|
numReads
|
||||||
|
numWrites
|
||||||
|
numErrors
|
||||||
|
fsType
|
||||||
|
fsFree
|
||||||
|
fsUsed
|
||||||
|
fsSize
|
||||||
|
color
|
||||||
|
spunDown
|
||||||
|
transport
|
||||||
|
rotational
|
||||||
|
serial
|
||||||
|
model
|
||||||
|
}
|
||||||
|
parities {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
device
|
||||||
|
size
|
||||||
|
status
|
||||||
|
type
|
||||||
|
temp
|
||||||
|
numReads
|
||||||
|
numWrites
|
||||||
|
numErrors
|
||||||
|
color
|
||||||
|
spunDown
|
||||||
|
transport
|
||||||
|
rotational
|
||||||
|
serial
|
||||||
|
model
|
||||||
|
}
|
||||||
|
caches {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fsType
|
||||||
|
fsFree
|
||||||
|
fsUsed
|
||||||
|
fsSize
|
||||||
|
devices {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
device
|
||||||
|
size
|
||||||
|
status
|
||||||
|
temp
|
||||||
|
spunDown
|
||||||
|
serial
|
||||||
|
model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parityCheckStatus {
|
||||||
|
running
|
||||||
|
progress
|
||||||
|
errors
|
||||||
|
elapsed
|
||||||
|
eta
|
||||||
|
speed
|
||||||
|
mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DISKS_QUERY = `
|
||||||
|
query Disks {
|
||||||
|
disks {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
device
|
||||||
|
size
|
||||||
|
vendor
|
||||||
|
model
|
||||||
|
serial
|
||||||
|
firmware
|
||||||
|
type
|
||||||
|
interfaceType
|
||||||
|
rotational
|
||||||
|
temp
|
||||||
|
smartStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DISK_QUERY = `
|
||||||
|
query Disk($id: String!) {
|
||||||
|
disk(id: $id) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
device
|
||||||
|
size
|
||||||
|
vendor
|
||||||
|
model
|
||||||
|
serial
|
||||||
|
firmware
|
||||||
|
type
|
||||||
|
interfaceType
|
||||||
|
rotational
|
||||||
|
temp
|
||||||
|
smartStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DOCKER QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DOCKER_QUERY = `
|
||||||
|
query Docker {
|
||||||
|
docker {
|
||||||
|
containers {
|
||||||
|
id
|
||||||
|
names
|
||||||
|
image
|
||||||
|
state
|
||||||
|
status
|
||||||
|
created
|
||||||
|
ports {
|
||||||
|
privatePort
|
||||||
|
publicPort
|
||||||
|
type
|
||||||
|
ip
|
||||||
|
}
|
||||||
|
autoStart
|
||||||
|
networkMode
|
||||||
|
}
|
||||||
|
networks {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
driver
|
||||||
|
scope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VM QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const VMS_QUERY = `
|
||||||
|
query Vms {
|
||||||
|
vms {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
state
|
||||||
|
uuid
|
||||||
|
description
|
||||||
|
cpus
|
||||||
|
memory
|
||||||
|
autoStart
|
||||||
|
icon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SHARES QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const SHARES_QUERY = `
|
||||||
|
query Shares {
|
||||||
|
shares {
|
||||||
|
name
|
||||||
|
comment
|
||||||
|
free
|
||||||
|
used
|
||||||
|
size
|
||||||
|
include
|
||||||
|
exclude
|
||||||
|
cache
|
||||||
|
color
|
||||||
|
floor
|
||||||
|
splitLevel
|
||||||
|
allocator
|
||||||
|
export
|
||||||
|
security
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NOTIFICATIONS QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const NOTIFICATIONS_QUERY = `
|
||||||
|
query Notifications {
|
||||||
|
notifications {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
subject
|
||||||
|
description
|
||||||
|
importance
|
||||||
|
type
|
||||||
|
timestamp
|
||||||
|
read
|
||||||
|
archived
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVICES QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const SERVICES_QUERY = `
|
||||||
|
query Services {
|
||||||
|
services {
|
||||||
|
name
|
||||||
|
online
|
||||||
|
uptime
|
||||||
|
version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NETWORK QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const NETWORK_QUERY = `
|
||||||
|
query Network {
|
||||||
|
network {
|
||||||
|
accessUrls {
|
||||||
|
type
|
||||||
|
name
|
||||||
|
ipv4
|
||||||
|
ipv6
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UPS QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const UPS_DEVICES_QUERY = `
|
||||||
|
query UpsDevices {
|
||||||
|
upsDevices {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
model
|
||||||
|
status
|
||||||
|
batteryCharge
|
||||||
|
batteryRuntime
|
||||||
|
load
|
||||||
|
inputVoltage
|
||||||
|
outputVoltage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PLUGINS QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const PLUGINS_QUERY = `
|
||||||
|
query Plugins {
|
||||||
|
plugins {
|
||||||
|
name
|
||||||
|
version
|
||||||
|
author
|
||||||
|
url
|
||||||
|
icon
|
||||||
|
updateAvailable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CUSTOMIZATION QUERIES
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CUSTOMIZATION_QUERY = `
|
||||||
|
query Customization {
|
||||||
|
customization {
|
||||||
|
theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DASHBOARD COMPOSITE QUERY
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DASHBOARD_QUERY = `
|
||||||
|
query Dashboard {
|
||||||
|
info {
|
||||||
|
cpu {
|
||||||
|
manufacturer
|
||||||
|
brand
|
||||||
|
cores
|
||||||
|
threads
|
||||||
|
speed
|
||||||
|
}
|
||||||
|
memory {
|
||||||
|
total
|
||||||
|
free
|
||||||
|
used
|
||||||
|
available
|
||||||
|
}
|
||||||
|
os {
|
||||||
|
hostname
|
||||||
|
uptime
|
||||||
|
kernel
|
||||||
|
}
|
||||||
|
baseboard {
|
||||||
|
manufacturer
|
||||||
|
model
|
||||||
|
}
|
||||||
|
versions {
|
||||||
|
unraid
|
||||||
|
api
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vars {
|
||||||
|
name
|
||||||
|
version
|
||||||
|
description
|
||||||
|
}
|
||||||
|
registration {
|
||||||
|
type
|
||||||
|
state
|
||||||
|
}
|
||||||
|
array {
|
||||||
|
state
|
||||||
|
capacity {
|
||||||
|
total
|
||||||
|
used
|
||||||
|
free
|
||||||
|
}
|
||||||
|
disks {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
status
|
||||||
|
temp
|
||||||
|
size
|
||||||
|
fsUsed
|
||||||
|
fsFree
|
||||||
|
spunDown
|
||||||
|
}
|
||||||
|
parities {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
status
|
||||||
|
temp
|
||||||
|
size
|
||||||
|
}
|
||||||
|
caches {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
fsUsed
|
||||||
|
fsFree
|
||||||
|
fsSize
|
||||||
|
}
|
||||||
|
parityCheckStatus {
|
||||||
|
running
|
||||||
|
progress
|
||||||
|
errors
|
||||||
|
eta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
docker {
|
||||||
|
containers {
|
||||||
|
id
|
||||||
|
names
|
||||||
|
state
|
||||||
|
status
|
||||||
|
autoStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vms {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
state
|
||||||
|
autoStart
|
||||||
|
}
|
||||||
|
shares {
|
||||||
|
name
|
||||||
|
size
|
||||||
|
used
|
||||||
|
free
|
||||||
|
}
|
||||||
|
services {
|
||||||
|
name
|
||||||
|
online
|
||||||
|
}
|
||||||
|
notifications {
|
||||||
|
id
|
||||||
|
importance
|
||||||
|
read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
328
src/lib/unraid/queries/mutations.ts
Normal file
328
src/lib/unraid/queries/mutations.ts
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
/**
|
||||||
|
* Unraid GraphQL Mutations
|
||||||
|
* Based on Unraid API v4.29.2
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ARRAY MUTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ARRAY_SET_STATE_MUTATION = `
|
||||||
|
mutation ArraySetState($state: String!) {
|
||||||
|
array {
|
||||||
|
setState(state: $state) {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ARRAY_ADD_DISK_MUTATION = `
|
||||||
|
mutation ArrayAddDisk($slot: String!, $id: String!) {
|
||||||
|
array {
|
||||||
|
addDiskToArray(slot: $slot, id: $id) {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ARRAY_REMOVE_DISK_MUTATION = `
|
||||||
|
mutation ArrayRemoveDisk($slot: String!) {
|
||||||
|
array {
|
||||||
|
removeDiskFromArray(slot: $slot) {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ARRAY_MOUNT_DISK_MUTATION = `
|
||||||
|
mutation ArrayMountDisk($id: String!) {
|
||||||
|
array {
|
||||||
|
mountArrayDisk(id: $id) {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ARRAY_UNMOUNT_DISK_MUTATION = `
|
||||||
|
mutation ArrayUnmountDisk($id: String!) {
|
||||||
|
array {
|
||||||
|
unmountArrayDisk(id: $id) {
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// PARITY CHECK MUTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const PARITY_CHECK_START_MUTATION = `
|
||||||
|
mutation ParityCheckStart($correct: Boolean) {
|
||||||
|
parityCheck {
|
||||||
|
start(correct: $correct) {
|
||||||
|
running
|
||||||
|
progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PARITY_CHECK_PAUSE_MUTATION = `
|
||||||
|
mutation ParityCheckPause {
|
||||||
|
parityCheck {
|
||||||
|
pause {
|
||||||
|
running
|
||||||
|
progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PARITY_CHECK_RESUME_MUTATION = `
|
||||||
|
mutation ParityCheckResume {
|
||||||
|
parityCheck {
|
||||||
|
resume {
|
||||||
|
running
|
||||||
|
progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PARITY_CHECK_CANCEL_MUTATION = `
|
||||||
|
mutation ParityCheckCancel {
|
||||||
|
parityCheck {
|
||||||
|
cancel {
|
||||||
|
running
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DOCKER MUTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const DOCKER_START_MUTATION = `
|
||||||
|
mutation DockerStart($id: String!) {
|
||||||
|
docker {
|
||||||
|
start(id: $id) {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const DOCKER_STOP_MUTATION = `
|
||||||
|
mutation DockerStop($id: String!) {
|
||||||
|
docker {
|
||||||
|
stop(id: $id) {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// VM MUTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const VM_START_MUTATION = `
|
||||||
|
mutation VmStart($id: String!) {
|
||||||
|
vm {
|
||||||
|
start(id: $id) {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VM_STOP_MUTATION = `
|
||||||
|
mutation VmStop($id: String!) {
|
||||||
|
vm {
|
||||||
|
stop(id: $id) {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VM_PAUSE_MUTATION = `
|
||||||
|
mutation VmPause($id: String!) {
|
||||||
|
vm {
|
||||||
|
pause(id: $id) {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VM_RESUME_MUTATION = `
|
||||||
|
mutation VmResume($id: String!) {
|
||||||
|
vm {
|
||||||
|
resume(id: $id) {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VM_FORCE_STOP_MUTATION = `
|
||||||
|
mutation VmForceStop($id: String!) {
|
||||||
|
vm {
|
||||||
|
forceStop(id: $id) {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VM_REBOOT_MUTATION = `
|
||||||
|
mutation VmReboot($id: String!) {
|
||||||
|
vm {
|
||||||
|
reboot(id: $id) {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const VM_RESET_MUTATION = `
|
||||||
|
mutation VmReset($id: String!) {
|
||||||
|
vm {
|
||||||
|
reset(id: $id) {
|
||||||
|
id
|
||||||
|
state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NOTIFICATION MUTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const NOTIFICATION_CREATE_MUTATION = `
|
||||||
|
mutation NotificationCreate($input: CreateNotificationInput!) {
|
||||||
|
notification {
|
||||||
|
create(input: $input) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
subject
|
||||||
|
description
|
||||||
|
importance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const NOTIFICATION_DELETE_MUTATION = `
|
||||||
|
mutation NotificationDelete($id: String!) {
|
||||||
|
notification {
|
||||||
|
delete(id: $id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const NOTIFICATION_ARCHIVE_MUTATION = `
|
||||||
|
mutation NotificationArchive($id: String!) {
|
||||||
|
notification {
|
||||||
|
archive(id: $id) {
|
||||||
|
id
|
||||||
|
archived
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const NOTIFICATION_UNREAD_MUTATION = `
|
||||||
|
mutation NotificationUnread($id: String!) {
|
||||||
|
notification {
|
||||||
|
unread(id: $id) {
|
||||||
|
id
|
||||||
|
read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CUSTOMIZATION MUTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const SET_THEME_MUTATION = `
|
||||||
|
mutation SetTheme($theme: ThemeName!) {
|
||||||
|
customization {
|
||||||
|
setTheme(theme: $theme) {
|
||||||
|
theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API KEY MUTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const API_KEY_CREATE_MUTATION = `
|
||||||
|
mutation ApiKeyCreate($name: String!, $description: String) {
|
||||||
|
apiKey {
|
||||||
|
create(name: $name, description: $description) {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const API_KEY_DELETE_MUTATION = `
|
||||||
|
mutation ApiKeyDelete($id: String!) {
|
||||||
|
apiKey {
|
||||||
|
delete(id: $id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CONNECT MUTATIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CONNECT_SIGN_IN_MUTATION = `
|
||||||
|
mutation ConnectSignIn {
|
||||||
|
connectSignIn {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CONNECT_SIGN_OUT_MUTATION = `
|
||||||
|
mutation ConnectSignOut {
|
||||||
|
connectSignOut {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SETUP_REMOTE_ACCESS_MUTATION = `
|
||||||
|
mutation SetupRemoteAccess($enable: Boolean!) {
|
||||||
|
setupRemoteAccess(enable: $enable) {
|
||||||
|
success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
164
src/lib/unraid/queries/subscriptions.ts
Normal file
164
src/lib/unraid/queries/subscriptions.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
/**
|
||||||
|
* Unraid GraphQL Subscriptions
|
||||||
|
* For real-time updates via WebSocket
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SYSTEM METRICS SUBSCRIPTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const CPU_METRICS_SUBSCRIPTION = `
|
||||||
|
subscription CpuMetrics {
|
||||||
|
systemMetricsCpu {
|
||||||
|
cores
|
||||||
|
average
|
||||||
|
temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const CPU_TELEMETRY_SUBSCRIPTION = `
|
||||||
|
subscription CpuTelemetry {
|
||||||
|
systemMetricsCpuTelemetry {
|
||||||
|
cores
|
||||||
|
average
|
||||||
|
temperature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const MEMORY_METRICS_SUBSCRIPTION = `
|
||||||
|
subscription MemoryMetrics {
|
||||||
|
systemMetricsMemory {
|
||||||
|
total
|
||||||
|
used
|
||||||
|
free
|
||||||
|
cached
|
||||||
|
buffers
|
||||||
|
percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// ARRAY SUBSCRIPTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const ARRAY_SUBSCRIPTION = `
|
||||||
|
subscription ArrayUpdates {
|
||||||
|
arraySubscription {
|
||||||
|
state
|
||||||
|
capacity {
|
||||||
|
total
|
||||||
|
used
|
||||||
|
free
|
||||||
|
}
|
||||||
|
disks {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
status
|
||||||
|
temp
|
||||||
|
spunDown
|
||||||
|
}
|
||||||
|
parityCheckStatus {
|
||||||
|
running
|
||||||
|
progress
|
||||||
|
errors
|
||||||
|
eta
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const PARITY_HISTORY_SUBSCRIPTION = `
|
||||||
|
subscription ParityHistory {
|
||||||
|
parityHistorySubscription {
|
||||||
|
date
|
||||||
|
duration
|
||||||
|
errors
|
||||||
|
speed
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NOTIFICATION SUBSCRIPTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const NOTIFICATION_ADDED_SUBSCRIPTION = `
|
||||||
|
subscription NotificationAdded {
|
||||||
|
notificationAdded {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
subject
|
||||||
|
description
|
||||||
|
importance
|
||||||
|
type
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const NOTIFICATIONS_OVERVIEW_SUBSCRIPTION = `
|
||||||
|
subscription NotificationsOverview {
|
||||||
|
notificationsOverview {
|
||||||
|
total
|
||||||
|
unread
|
||||||
|
alerts
|
||||||
|
warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LOG SUBSCRIPTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const LOG_FILE_SUBSCRIPTION = `
|
||||||
|
subscription LogFile($path: String!) {
|
||||||
|
logFile(path: $path) {
|
||||||
|
line
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UPS SUBSCRIPTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const UPS_UPDATES_SUBSCRIPTION = `
|
||||||
|
subscription UpsUpdates {
|
||||||
|
upsUpdates {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
batteryCharge
|
||||||
|
batteryRuntime
|
||||||
|
load
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SERVER SUBSCRIPTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const OWNER_SUBSCRIPTION = `
|
||||||
|
subscription OwnerUpdates {
|
||||||
|
ownerSubscription {
|
||||||
|
email
|
||||||
|
username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const SERVERS_SUBSCRIPTION = `
|
||||||
|
subscription ServersUpdates {
|
||||||
|
serversSubscription {
|
||||||
|
guid
|
||||||
|
name
|
||||||
|
status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
522
src/lib/unraid/types.ts
Normal file
522
src/lib/unraid/types.ts
Normal file
@@ -0,0 +1,522 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
726
src/pages/unraid/array/index.tsx
Normal file
726
src/pages/unraid/array/index.tsx
Normal file
@@ -0,0 +1,726 @@
|
|||||||
|
/**
|
||||||
|
* Array Management Page
|
||||||
|
* Detailed view of array devices, parity, and pools
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Menu,
|
||||||
|
Paper,
|
||||||
|
Progress,
|
||||||
|
RingProgress,
|
||||||
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Tabs,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconDatabase,
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
476
src/pages/unraid/docker/index.tsx
Normal file
476
src/pages/unraid/docker/index.tsx
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
/**
|
||||||
|
* Docker Management Page
|
||||||
|
* Full Docker container management with details
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Menu,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconBrandDocker,
|
||||||
|
IconBox,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerStop,
|
||||||
|
IconDots,
|
||||||
|
IconSearch,
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCheck,
|
||||||
|
IconNetwork,
|
||||||
|
IconRefresh,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
|
||||||
|
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import type { DockerContainer, ContainerState } from '~/lib/unraid/types';
|
||||||
|
|
||||||
|
function getStateColor(state: ContainerState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'RUNNING':
|
||||||
|
return 'green';
|
||||||
|
case 'EXITED':
|
||||||
|
return 'red';
|
||||||
|
case 'PAUSED':
|
||||||
|
return 'yellow';
|
||||||
|
case 'RESTARTING':
|
||||||
|
return 'orange';
|
||||||
|
case 'CREATED':
|
||||||
|
return 'blue';
|
||||||
|
case 'DEAD':
|
||||||
|
return 'gray';
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp: number): string {
|
||||||
|
return new Date(timestamp * 1000).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContainerCard({
|
||||||
|
container,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
container: DockerContainer;
|
||||||
|
onStart: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const isRunning = container.state === 'RUNNING';
|
||||||
|
const containerName = container.names[0]?.replace(/^\//, '') || 'Unknown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Card.Section withBorder inheritPadding py="xs">
|
||||||
|
<Group position="apart">
|
||||||
|
<Group spacing="sm">
|
||||||
|
<ThemeIcon size="lg" variant="light" color={getStateColor(container.state)}>
|
||||||
|
<IconBox size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text weight={600} lineClamp={1}>
|
||||||
|
{containerName}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||||
|
{container.image}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Badge color={getStateColor(container.state)} variant="light">
|
||||||
|
{container.state}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{isRunning ? (
|
||||||
|
<Tooltip label="Stop Container">
|
||||||
|
<ActionIcon
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
onClick={onStop}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerStop size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip label="Start Container">
|
||||||
|
<ActionIcon
|
||||||
|
color="green"
|
||||||
|
variant="light"
|
||||||
|
onClick={onStart}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Menu shadow="md" width={150} position="bottom-end">
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle">
|
||||||
|
<IconDots size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item disabled>View Logs</Menu.Item>
|
||||||
|
<Menu.Item disabled>Console</Menu.Item>
|
||||||
|
<Menu.Item disabled>Edit</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item color="red" disabled>
|
||||||
|
Remove
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<Stack spacing="xs" mt="sm">
|
||||||
|
{/* Network */}
|
||||||
|
<Group spacing="xs">
|
||||||
|
<IconNetwork size={14} />
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
{container.networkMode}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Ports */}
|
||||||
|
{container.ports.length > 0 && (
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Text size="xs" weight={500}>
|
||||||
|
Ports:
|
||||||
|
</Text>
|
||||||
|
{container.ports.slice(0, 3).map((port, idx) => (
|
||||||
|
<Badge key={idx} size="xs" variant="outline">
|
||||||
|
{port.publicPort ? `${port.publicPort}:` : ''}
|
||||||
|
{port.privatePort}/{port.type}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{container.ports.length > 3 && (
|
||||||
|
<Badge size="xs" variant="outline" color="gray">
|
||||||
|
+{container.ports.length - 3} more
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
{container.status}
|
||||||
|
</Text>
|
||||||
|
{container.autoStart && (
|
||||||
|
<Badge size="xs" color="blue" variant="dot">
|
||||||
|
Auto-start
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DockerPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 200);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'running' | 'stopped'>('all');
|
||||||
|
const [loadingContainers, setLoadingContainers] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: docker,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = api.unraid.docker.useQuery(undefined, {
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startContainer = api.unraid.startContainer.useMutation({
|
||||||
|
onMutate: ({ id }) => setLoadingContainers((prev) => [...prev, id]),
|
||||||
|
onSettled: (_, __, { id }) =>
|
||||||
|
setLoadingContainers((prev) => prev.filter((cid) => cid !== id)),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Container Started',
|
||||||
|
message: 'Container started successfully',
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCheck size={16} />,
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: err.message,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopContainer = api.unraid.stopContainer.useMutation({
|
||||||
|
onMutate: ({ id }) => setLoadingContainers((prev) => [...prev, id]),
|
||||||
|
onSettled: (_, __, { id }) =>
|
||||||
|
setLoadingContainers((prev) => prev.filter((cid) => cid !== id)),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Container Stopped',
|
||||||
|
message: 'Container stopped successfully',
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCheck size={16} />,
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredContainers = docker?.containers.filter((container) => {
|
||||||
|
const name = container.names[0]?.replace(/^\//, '') || '';
|
||||||
|
const matchesSearch =
|
||||||
|
name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||||
|
container.image.toLowerCase().includes(debouncedSearch.toLowerCase());
|
||||||
|
|
||||||
|
const matchesFilter =
|
||||||
|
filter === 'all' ||
|
||||||
|
(filter === 'running' && container.state === 'RUNNING') ||
|
||||||
|
(filter === 'stopped' && container.state !== 'RUNNING');
|
||||||
|
|
||||||
|
return matchesSearch && matchesFilter;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningCount = docker?.containers.filter((c) => c.state === 'RUNNING').length || 0;
|
||||||
|
const stoppedCount = (docker?.containers.length || 0) - runningCount;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack align="center" spacing="md">
|
||||||
|
<Loader size="xl" />
|
||||||
|
<Text color="dimmed">Loading Docker data...</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack spacing="xl">
|
||||||
|
{/* Header */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color="cyan">
|
||||||
|
<IconBrandDocker size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Docker Containers</Title>
|
||||||
|
<Text color="dimmed" size="sm">
|
||||||
|
{runningCount} running, {stoppedCount} stopped
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftIcon={<IconRefresh size={16} />}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<SimpleGrid cols={3}>
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Total Containers
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700}>
|
||||||
|
{docker?.containers.length || 0}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Running
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700} color="green">
|
||||||
|
{runningCount}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Networks
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700}>
|
||||||
|
{docker?.networks.length || 0}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search containers..."
|
||||||
|
icon={<IconSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
style={{ flex: 1, maxWidth: 300 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SegmentedControl
|
||||||
|
value={filter}
|
||||||
|
onChange={(value) => setFilter(value as any)}
|
||||||
|
data={[
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Running', value: 'running' },
|
||||||
|
{ label: 'Stopped', value: 'stopped' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Container Grid */}
|
||||||
|
<Grid>
|
||||||
|
{filteredContainers?.map((container) => (
|
||||||
|
<Grid.Col key={container.id} sm={6} lg={4}>
|
||||||
|
<ContainerCard
|
||||||
|
container={container}
|
||||||
|
onStart={() => startContainer.mutate({ id: container.id })}
|
||||||
|
onStop={() => stopContainer.mutate({ id: container.id })}
|
||||||
|
isLoading={loadingContainers.includes(container.id)}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{filteredContainers?.length === 0 && (
|
||||||
|
<Text color="dimmed" align="center" py="xl">
|
||||||
|
No containers found
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Networks */}
|
||||||
|
{docker?.networks && docker.networks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
Networks
|
||||||
|
</Title>
|
||||||
|
<Paper shadow="xs" radius="md" withBorder>
|
||||||
|
<Table fontSize="sm" verticalSpacing="sm" highlightOnHover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Driver</th>
|
||||||
|
<th>Scope</th>
|
||||||
|
<th>ID</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{docker.networks.map((network) => (
|
||||||
|
<tr key={network.id}>
|
||||||
|
<td>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<IconNetwork size={14} />
|
||||||
|
<Text weight={500}>{network.name}</Text>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge size="sm" variant="outline">
|
||||||
|
{network.driver}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
{network.scope}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Text size="xs" color="dimmed" style={{ fontFamily: 'monospace' }}>
|
||||||
|
{network.id.substring(0, 12)}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const session = await getServerAuthSession(context);
|
||||||
|
const translations = await getServerSideTranslations(
|
||||||
|
['common'],
|
||||||
|
context.locale,
|
||||||
|
context.req,
|
||||||
|
context.res
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = checkForSessionOrAskForLogin(
|
||||||
|
context,
|
||||||
|
session,
|
||||||
|
() => session?.user != undefined
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...translations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
357
src/pages/unraid/index.tsx
Normal file
357
src/pages/unraid/index.tsx
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
288
src/pages/unraid/settings/identification.tsx
Normal file
288
src/pages/unraid/settings/identification.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
/**
|
||||||
|
* Identification Settings Page
|
||||||
|
* Server name, description, and basic settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconServer,
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCheck,
|
||||||
|
IconDeviceFloppy,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
|
||||||
|
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
|
export default function IdentificationSettingsPage() {
|
||||||
|
const {
|
||||||
|
data: vars,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = api.unraid.vars.useQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: info,
|
||||||
|
} = api.unraid.info.useQuery();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
model: '',
|
||||||
|
timezone: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update form when data loads
|
||||||
|
if (vars && !form.isTouched()) {
|
||||||
|
form.setValues({
|
||||||
|
name: vars.name || '',
|
||||||
|
description: vars.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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
183
src/pages/unraid/settings/index.tsx
Normal file
183
src/pages/unraid/settings/index.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Settings Index Page
|
||||||
|
* Overview of all Unraid settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
UnstyledButton,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconSettings,
|
||||||
|
IconServer,
|
||||||
|
IconDatabase,
|
||||||
|
IconNetwork,
|
||||||
|
IconBrandDocker,
|
||||||
|
IconServer2,
|
||||||
|
IconTool,
|
||||||
|
IconCpu,
|
||||||
|
IconBell,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
|
|
||||||
|
interface SettingItem {
|
||||||
|
icon: React.ElementType;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsItems: SettingItem[] = [
|
||||||
|
{
|
||||||
|
icon: IconServer,
|
||||||
|
label: 'Identification',
|
||||||
|
description: 'Server name, description, and basic settings',
|
||||||
|
href: '/unraid/settings/identification',
|
||||||
|
color: 'blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconDatabase,
|
||||||
|
label: 'Disk Settings',
|
||||||
|
description: 'Disk tuning, spin down, and power settings',
|
||||||
|
href: '/unraid/settings/disk',
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconNetwork,
|
||||||
|
label: 'Network Settings',
|
||||||
|
description: 'Network interfaces, bonding, and bridging',
|
||||||
|
href: '/unraid/settings/network',
|
||||||
|
color: 'cyan',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconBrandDocker,
|
||||||
|
label: 'Docker',
|
||||||
|
description: 'Docker daemon configuration and settings',
|
||||||
|
href: '/unraid/settings/docker',
|
||||||
|
color: 'blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconServer2,
|
||||||
|
label: 'VM Manager',
|
||||||
|
description: 'Virtualization settings and IOMMU groups',
|
||||||
|
href: '/unraid/settings/vm',
|
||||||
|
color: 'violet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconTool,
|
||||||
|
label: 'Management Access',
|
||||||
|
description: 'SSH, Telnet, HTTPS, and access control',
|
||||||
|
href: '/unraid/settings/management',
|
||||||
|
color: 'orange',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconCpu,
|
||||||
|
label: 'CPU Pinning',
|
||||||
|
description: 'CPU isolation and core assignment',
|
||||||
|
href: '/unraid/settings/cpu',
|
||||||
|
color: 'red',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconBell,
|
||||||
|
label: 'Notifications',
|
||||||
|
description: 'Email, Slack, and notification settings',
|
||||||
|
href: '/unraid/settings/notifications',
|
||||||
|
color: 'yellow',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function SettingCard({ item }: { item: SettingItem }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => router.push(item.href)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Card shadow="sm" radius="md" withBorder style={{ height: '100%' }}>
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color={item.color}>
|
||||||
|
<item.icon size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Text weight={600}>{item.label}</Text>
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SettingsIndexPage() {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack spacing="xl">
|
||||||
|
{/* Header */}
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color="gray">
|
||||||
|
<IconSettings size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Settings</Title>
|
||||||
|
<Text color="dimmed" size="sm">
|
||||||
|
Configure your Unraid server
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Settings Grid */}
|
||||||
|
<Grid>
|
||||||
|
{settingsItems.map((item) => (
|
||||||
|
<Grid.Col key={item.href} sm={6} lg={4}>
|
||||||
|
<SettingCard item={item} />
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const session = await getServerAuthSession(context);
|
||||||
|
const translations = await getServerSideTranslations(
|
||||||
|
['common'],
|
||||||
|
context.locale,
|
||||||
|
context.req,
|
||||||
|
context.res
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = checkForSessionOrAskForLogin(
|
||||||
|
context,
|
||||||
|
session,
|
||||||
|
() => session?.user != undefined
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...translations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
459
src/pages/unraid/settings/notifications.tsx
Normal file
459
src/pages/unraid/settings/notifications.tsx
Normal file
@@ -0,0 +1,459 @@
|
|||||||
|
/**
|
||||||
|
* Notifications Settings Page
|
||||||
|
* Configure notification preferences and view notification history
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Menu,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications as mantineNotifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconBell,
|
||||||
|
IconBellOff,
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCheck,
|
||||||
|
IconTrash,
|
||||||
|
IconDots,
|
||||||
|
IconRefresh,
|
||||||
|
IconMail,
|
||||||
|
IconBrandSlack,
|
||||||
|
IconBrandDiscord,
|
||||||
|
IconAlertTriangle,
|
||||||
|
IconInfoCircle,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
|
||||||
|
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import type { Notification, NotificationImportance } from '~/lib/unraid/types';
|
||||||
|
|
||||||
|
function getImportanceColor(importance: NotificationImportance): string {
|
||||||
|
switch (importance) {
|
||||||
|
case 'ALERT':
|
||||||
|
return 'red';
|
||||||
|
case 'WARNING':
|
||||||
|
return 'yellow';
|
||||||
|
case 'NORMAL':
|
||||||
|
return 'blue';
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImportanceIcon(importance: NotificationImportance) {
|
||||||
|
switch (importance) {
|
||||||
|
case 'ALERT':
|
||||||
|
return <IconAlertCircle size={14} />;
|
||||||
|
case 'WARNING':
|
||||||
|
return <IconAlertTriangle size={14} />;
|
||||||
|
case 'NORMAL':
|
||||||
|
return <IconInfoCircle size={14} />;
|
||||||
|
default:
|
||||||
|
return <IconBell size={14} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp: 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
411
src/pages/unraid/shares/index.tsx
Normal file
411
src/pages/unraid/shares/index.tsx
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
/**
|
||||||
|
* Shares Management Page
|
||||||
|
* View and manage Unraid shares
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Menu,
|
||||||
|
Paper,
|
||||||
|
Progress,
|
||||||
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import {
|
||||||
|
IconFolders,
|
||||||
|
IconFolder,
|
||||||
|
IconSearch,
|
||||||
|
IconAlertCircle,
|
||||||
|
IconRefresh,
|
||||||
|
IconLock,
|
||||||
|
IconLockOpen,
|
||||||
|
IconDots,
|
||||||
|
IconUsers,
|
||||||
|
IconWorld,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
|
||||||
|
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import type { Share, ShareSecurityLevel } from '~/lib/unraid/types';
|
||||||
|
|
||||||
|
function getSecurityColor(security: ShareSecurityLevel): string {
|
||||||
|
switch (security) {
|
||||||
|
case 'PUBLIC':
|
||||||
|
return 'green';
|
||||||
|
case 'SECURE':
|
||||||
|
return 'blue';
|
||||||
|
case 'PRIVATE':
|
||||||
|
return 'red';
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSecurityIcon(security: ShareSecurityLevel) {
|
||||||
|
switch (security) {
|
||||||
|
case 'PUBLIC':
|
||||||
|
return <IconWorld size={14} />;
|
||||||
|
case 'SECURE':
|
||||||
|
return <IconUsers size={14} />;
|
||||||
|
case 'PRIVATE':
|
||||||
|
return <IconLock size={14} />;
|
||||||
|
default:
|
||||||
|
return <IconLockOpen size={14} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShareCard({ share }: { share: Share }) {
|
||||||
|
const usedPercent = share.size > 0 ? (share.used / share.size) * 100 : 0;
|
||||||
|
const usedColor = usedPercent > 90 ? 'red' : usedPercent > 75 ? 'yellow' : 'green';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Card.Section withBorder inheritPadding py="xs">
|
||||||
|
<Group position="apart">
|
||||||
|
<Group spacing="sm">
|
||||||
|
<ThemeIcon size="lg" variant="light" color="orange">
|
||||||
|
<IconFolder size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text weight={600} lineClamp={1}>
|
||||||
|
{share.name}
|
||||||
|
</Text>
|
||||||
|
{share.comment && (
|
||||||
|
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||||
|
{share.comment}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Badge
|
||||||
|
color={getSecurityColor(share.security)}
|
||||||
|
variant="light"
|
||||||
|
leftSection={getSecurityIcon(share.security)}
|
||||||
|
>
|
||||||
|
{share.security}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<Menu shadow="md" width={150} position="bottom-end">
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle">
|
||||||
|
<IconDots size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item disabled>Browse</Menu.Item>
|
||||||
|
<Menu.Item disabled>Edit</Menu.Item>
|
||||||
|
<Menu.Item disabled>Permissions</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item color="red" disabled>
|
||||||
|
Delete
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<Stack spacing="xs" mt="sm">
|
||||||
|
{/* Storage usage */}
|
||||||
|
{share.size > 0 && (
|
||||||
|
<>
|
||||||
|
<Group position="apart">
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
Storage
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
{formatBytes(share.used)} / {formatBytes(share.size)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress value={usedPercent} color={usedColor} size="sm" radius="xl" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Allocation method */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Allocation
|
||||||
|
</Text>
|
||||||
|
<Badge size="xs" variant="outline">
|
||||||
|
{share.allocator}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Include/Exclude disks */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Disks
|
||||||
|
</Text>
|
||||||
|
<Text size="xs">
|
||||||
|
{share.include?.length ? `Include: ${share.include.join(', ')}` : 'All disks'}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Floor and split level */}
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Floor:
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" weight={500}>
|
||||||
|
{formatBytes(share.floor)}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Split:
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" weight={500}>
|
||||||
|
Level {share.splitLevel}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</SimpleGrid>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SharesPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 200);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'public' | 'secure' | 'private'>('all');
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: shares,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = api.unraid.shares.useQuery(undefined, {
|
||||||
|
refetchInterval: 30000, // Refresh every 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredShares = shares?.filter((share) => {
|
||||||
|
const matchesSearch =
|
||||||
|
share.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||||
|
share.comment?.toLowerCase().includes(debouncedSearch.toLowerCase());
|
||||||
|
|
||||||
|
const matchesFilter =
|
||||||
|
filter === 'all' ||
|
||||||
|
(filter === 'public' && share.security === 'PUBLIC') ||
|
||||||
|
(filter === 'secure' && share.security === 'SECURE') ||
|
||||||
|
(filter === 'private' && share.security === 'PRIVATE');
|
||||||
|
|
||||||
|
return matchesSearch && matchesFilter;
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicCount = shares?.filter((s) => s.security === 'PUBLIC').length || 0;
|
||||||
|
const secureCount = shares?.filter((s) => s.security === 'SECURE').length || 0;
|
||||||
|
const privateCount = shares?.filter((s) => s.security === 'PRIVATE').length || 0;
|
||||||
|
|
||||||
|
const totalUsed = shares?.reduce((sum, s) => sum + s.used, 0) || 0;
|
||||||
|
const totalSize = shares?.reduce((sum, s) => sum + s.size, 0) || 0;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack align="center" spacing="md">
|
||||||
|
<Loader size="xl" />
|
||||||
|
<Text color="dimmed">Loading shares...</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack spacing="xl">
|
||||||
|
{/* Header */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color="orange">
|
||||||
|
<IconFolders size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Shares</Title>
|
||||||
|
<Text color="dimmed" size="sm">
|
||||||
|
{shares?.length || 0} shares configured
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftIcon={<IconRefresh size={16} />}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<SimpleGrid cols={4}>
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Total Shares
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700}>
|
||||||
|
{shares?.length || 0}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Public
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700} color="green">
|
||||||
|
{publicCount}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Secure
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700} color="blue">
|
||||||
|
{secureCount}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Private
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700} color="red">
|
||||||
|
{privateCount}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Total storage */}
|
||||||
|
{totalSize > 0 && (
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Group position="apart" mb="xs">
|
||||||
|
<Text size="sm" weight={500}>
|
||||||
|
Total Storage Usage
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
{formatBytes(totalUsed)} / {formatBytes(totalSize)} (
|
||||||
|
{((totalUsed / totalSize) * 100).toFixed(1)}%)
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={(totalUsed / totalSize) * 100}
|
||||||
|
color={(totalUsed / totalSize) * 100 > 90 ? 'red' : 'green'}
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search shares..."
|
||||||
|
icon={<IconSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
style={{ flex: 1, maxWidth: 300 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SegmentedControl
|
||||||
|
value={filter}
|
||||||
|
onChange={(value) => setFilter(value as any)}
|
||||||
|
data={[
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Public', value: 'public' },
|
||||||
|
{ label: 'Secure', value: 'secure' },
|
||||||
|
{ label: 'Private', value: 'private' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Shares Grid */}
|
||||||
|
<Grid>
|
||||||
|
{filteredShares?.map((share) => (
|
||||||
|
<Grid.Col key={share.name} sm={6} lg={4}>
|
||||||
|
<ShareCard share={share} />
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{filteredShares?.length === 0 && (
|
||||||
|
<Text color="dimmed" align="center" py="xl">
|
||||||
|
No shares found
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const session = await getServerAuthSession(context);
|
||||||
|
const translations = await getServerSideTranslations(
|
||||||
|
['common'],
|
||||||
|
context.locale,
|
||||||
|
context.req,
|
||||||
|
context.res
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = checkForSessionOrAskForLogin(
|
||||||
|
context,
|
||||||
|
session,
|
||||||
|
() => session?.user != undefined
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...translations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
383
src/pages/unraid/tools/diagnostics.tsx
Normal file
383
src/pages/unraid/tools/diagnostics.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
/**
|
||||||
|
* Diagnostics Page
|
||||||
|
* Generate and download Unraid diagnostic reports
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
List,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Progress,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconBug,
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCheck,
|
||||||
|
IconDownload,
|
||||||
|
IconRefresh,
|
||||||
|
IconCpu,
|
||||||
|
IconDatabase,
|
||||||
|
IconServer,
|
||||||
|
IconNetwork,
|
||||||
|
IconBrandDocker,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
|
||||||
|
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
|
interface DiagnosticCheck {
|
||||||
|
name: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
status: 'pending' | 'running' | 'success' | 'warning' | 'error';
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DiagnosticsPage() {
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [checks, setChecks] = useState<DiagnosticCheck[]>([
|
||||||
|
{ name: 'System Information', icon: IconServer, status: 'pending' },
|
||||||
|
{ name: 'CPU & Memory', icon: IconCpu, status: 'pending' },
|
||||||
|
{ name: 'Array Status', icon: IconDatabase, status: 'pending' },
|
||||||
|
{ name: 'Network Configuration', icon: IconNetwork, status: 'pending' },
|
||||||
|
{ name: 'Docker Containers', icon: IconBrandDocker, status: 'pending' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: info,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = api.unraid.info.useQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: array,
|
||||||
|
} = api.unraid.array.useQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: docker,
|
||||||
|
} = api.unraid.docker.useQuery();
|
||||||
|
|
||||||
|
const runDiagnostics = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
// Simulate running diagnostics
|
||||||
|
const steps = checks.length;
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
setChecks((prev) =>
|
||||||
|
prev.map((check, idx) =>
|
||||||
|
idx === i ? { ...check, status: 'running' } : check
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Simulate random results
|
||||||
|
const statuses: Array<'success' | 'warning' | 'error'> = ['success', 'success', 'success', 'warning'];
|
||||||
|
const status = statuses[Math.floor(Math.random() * statuses.length)];
|
||||||
|
|
||||||
|
setChecks((prev) =>
|
||||||
|
prev.map((check, idx) =>
|
||||||
|
idx === i
|
||||||
|
? {
|
||||||
|
...check,
|
||||||
|
status,
|
||||||
|
message:
|
||||||
|
status === 'warning'
|
||||||
|
? 'Minor issues detected'
|
||||||
|
: status === 'error'
|
||||||
|
? 'Problems found'
|
||||||
|
: 'All checks passed',
|
||||||
|
}
|
||||||
|
: check
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setProgress(((i + 1) / steps) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGenerating(false);
|
||||||
|
notifications.show({
|
||||||
|
title: 'Diagnostics Complete',
|
||||||
|
message: 'Diagnostic report has been generated',
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCheck size={16} />,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetDiagnostics = () => {
|
||||||
|
setProgress(0);
|
||||||
|
setChecks((prev) =>
|
||||||
|
prev.map((check) => ({
|
||||||
|
...check,
|
||||||
|
status: 'pending',
|
||||||
|
message: undefined,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: DiagnosticCheck['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'success':
|
||||||
|
return 'green';
|
||||||
|
case 'warning':
|
||||||
|
return 'yellow';
|
||||||
|
case 'error':
|
||||||
|
return 'red';
|
||||||
|
case 'running':
|
||||||
|
return 'blue';
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="lg" py="xl">
|
||||||
|
<Stack align="center" spacing="md">
|
||||||
|
<Loader size="xl" />
|
||||||
|
<Text color="dimmed">Loading system information...</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="lg" py="xl">
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="lg" py="xl">
|
||||||
|
<Stack spacing="xl">
|
||||||
|
{/* Header */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color="red">
|
||||||
|
<IconBug size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Diagnostics</Title>
|
||||||
|
<Text color="dimmed" size="sm">
|
||||||
|
System health checks and diagnostic reports
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftIcon={<IconRefresh size={16} />}
|
||||||
|
onClick={resetDiagnostics}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
leftIcon={<IconBug size={16} />}
|
||||||
|
onClick={runDiagnostics}
|
||||||
|
loading={isGenerating}
|
||||||
|
>
|
||||||
|
Run Diagnostics
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
{progress > 0 && (
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Group position="apart" mb="xs">
|
||||||
|
<Text weight={500}>Diagnostic Progress</Text>
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
{Math.round(progress)}%
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Progress
|
||||||
|
value={progress}
|
||||||
|
color={progress === 100 ? 'green' : 'blue'}
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
animate={isGenerating}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Diagnostic Checks */}
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
System Checks
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Stack spacing="md">
|
||||||
|
{checks.map((check, idx) => (
|
||||||
|
<div key={check.name}>
|
||||||
|
<Group position="apart">
|
||||||
|
<Group spacing="sm">
|
||||||
|
<ThemeIcon
|
||||||
|
size="md"
|
||||||
|
variant="light"
|
||||||
|
color={getStatusColor(check.status)}
|
||||||
|
>
|
||||||
|
{check.status === 'running' ? (
|
||||||
|
<Loader size={14} color="blue" />
|
||||||
|
) : (
|
||||||
|
<check.icon size={16} />
|
||||||
|
)}
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text weight={500}>{check.name}</Text>
|
||||||
|
{check.message && (
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
{check.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
weight={500}
|
||||||
|
color={getStatusColor(check.status)}
|
||||||
|
transform="uppercase"
|
||||||
|
>
|
||||||
|
{check.status}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
{idx < checks.length - 1 && <Divider mt="md" />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* System Summary */}
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
System Summary
|
||||||
|
</Title>
|
||||||
|
|
||||||
|
<Stack spacing="md">
|
||||||
|
<Group position="apart">
|
||||||
|
<Text color="dimmed">Unraid Version</Text>
|
||||||
|
<Text weight={500}>{info?.versions.unraid || 'Unknown'}</Text>
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<Group position="apart">
|
||||||
|
<Text color="dimmed">Linux Kernel</Text>
|
||||||
|
<Text weight={500}>{info?.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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
175
src/pages/unraid/tools/index.tsx
Normal file
175
src/pages/unraid/tools/index.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
/**
|
||||||
|
* Tools Index Page
|
||||||
|
* Overview of all Unraid tools
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
UnstyledButton,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
IconTools,
|
||||||
|
IconFileText,
|
||||||
|
IconBug,
|
||||||
|
IconDevices,
|
||||||
|
IconTerminal2,
|
||||||
|
IconPuzzle,
|
||||||
|
IconDatabase,
|
||||||
|
IconHistory,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
|
||||||
|
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
|
|
||||||
|
interface ToolItem {
|
||||||
|
icon: React.ElementType;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
href: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolItems: ToolItem[] = [
|
||||||
|
{
|
||||||
|
icon: IconFileText,
|
||||||
|
label: 'System Log',
|
||||||
|
description: 'View and search the syslog',
|
||||||
|
href: '/unraid/tools/syslog',
|
||||||
|
color: 'blue',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconBug,
|
||||||
|
label: 'Diagnostics',
|
||||||
|
description: 'Generate diagnostic reports',
|
||||||
|
href: '/unraid/tools/diagnostics',
|
||||||
|
color: 'red',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconDevices,
|
||||||
|
label: 'System Devices',
|
||||||
|
description: 'View PCI and USB devices',
|
||||||
|
href: '/unraid/tools/devices',
|
||||||
|
color: 'green',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconTerminal2,
|
||||||
|
label: 'Terminal',
|
||||||
|
description: 'Web-based terminal access',
|
||||||
|
href: '/unraid/tools/terminal',
|
||||||
|
color: 'gray',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconPuzzle,
|
||||||
|
label: 'Plugins',
|
||||||
|
description: 'Manage Unraid plugins',
|
||||||
|
href: '/unraid/tools/plugins',
|
||||||
|
color: 'violet',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconDatabase,
|
||||||
|
label: 'Disk Log',
|
||||||
|
description: 'View disk activity logs',
|
||||||
|
href: '/unraid/tools/disklog',
|
||||||
|
color: 'orange',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: IconHistory,
|
||||||
|
label: 'Update History',
|
||||||
|
description: 'View update and change history',
|
||||||
|
href: '/unraid/tools/history',
|
||||||
|
color: 'cyan',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ToolCard({ item }: { item: ToolItem }) {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnstyledButton
|
||||||
|
onClick={() => router.push(item.href)}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
<Card shadow="sm" radius="md" withBorder style={{ height: '100%' }}>
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color={item.color}>
|
||||||
|
<item.icon size={24} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<Text weight={600}>{item.label}</Text>
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
</UnstyledButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ToolsIndexPage() {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack spacing="xl">
|
||||||
|
{/* Header */}
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color="gray">
|
||||||
|
<IconTools size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Tools</Title>
|
||||||
|
<Text color="dimmed" size="sm">
|
||||||
|
System utilities and diagnostics
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Tools Grid */}
|
||||||
|
<Grid>
|
||||||
|
{toolItems.map((item) => (
|
||||||
|
<Grid.Col key={item.href} sm={6} lg={4}>
|
||||||
|
<ToolCard item={item} />
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const session = await getServerAuthSession(context);
|
||||||
|
const translations = await getServerSideTranslations(
|
||||||
|
['common'],
|
||||||
|
context.locale,
|
||||||
|
context.req,
|
||||||
|
context.res
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = checkForSessionOrAskForLogin(
|
||||||
|
context,
|
||||||
|
session,
|
||||||
|
() => session?.user != undefined
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...translations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
373
src/pages/unraid/tools/syslog.tsx
Normal file
373
src/pages/unraid/tools/syslog.tsx
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
/**
|
||||||
|
* System Log Page
|
||||||
|
* View and search the Unraid syslog
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Code,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
ScrollArea,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import {
|
||||||
|
IconFileText,
|
||||||
|
IconSearch,
|
||||||
|
IconAlertCircle,
|
||||||
|
IconRefresh,
|
||||||
|
IconDownload,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerPause,
|
||||||
|
IconArrowDown,
|
||||||
|
IconFilter,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
|
||||||
|
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
|
||||||
|
interface LogLine {
|
||||||
|
timestamp: string;
|
||||||
|
host: string;
|
||||||
|
process: string;
|
||||||
|
message: string;
|
||||||
|
level: 'info' | 'warning' | 'error' | 'debug';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLogLine(line: string): LogLine | null {
|
||||||
|
// Parse syslog format: "Jan 1 00:00:00 hostname process[pid]: message"
|
||||||
|
const match = line.match(/^(\w{3}\s+\d+\s+\d{2}:\d{2}:\d{2})\s+(\S+)\s+(\S+?)(?:\[\d+\])?:\s*(.*)$/);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return {
|
||||||
|
timestamp: '',
|
||||||
|
host: '',
|
||||||
|
process: '',
|
||||||
|
message: line,
|
||||||
|
level: 'info',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, timestamp, host, process, message] = match;
|
||||||
|
|
||||||
|
// Determine log level based on message content
|
||||||
|
let level: LogLine['level'] = 'info';
|
||||||
|
if (/error|fail|critical/i.test(message)) {
|
||||||
|
level = 'error';
|
||||||
|
} else if (/warn|warning/i.test(message)) {
|
||||||
|
level = 'warning';
|
||||||
|
} else if (/debug/i.test(message)) {
|
||||||
|
level = 'debug';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { timestamp, host, process, message, level };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLevelColor(level: LogLine['level']): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'error':
|
||||||
|
return 'red';
|
||||||
|
case 'warning':
|
||||||
|
return 'yellow';
|
||||||
|
case 'debug':
|
||||||
|
return 'gray';
|
||||||
|
default:
|
||||||
|
return 'blue';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SyslogPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 300);
|
||||||
|
const [levelFilter, setLevelFilter] = useState<'all' | 'error' | 'warning'>('all');
|
||||||
|
const [autoScroll, setAutoScroll] = useState(true);
|
||||||
|
const [lines, setLines] = useState<string[]>([]);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: syslog,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = api.unraid.syslog.useQuery(
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
384
src/pages/unraid/users/index.tsx
Normal file
384
src/pages/unraid/users/index.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
/**
|
||||||
|
* Users Management Page
|
||||||
|
* View and manage Unraid users
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Avatar,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Menu,
|
||||||
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import {
|
||||||
|
IconUsers,
|
||||||
|
IconUser,
|
||||||
|
IconUserShield,
|
||||||
|
IconSearch,
|
||||||
|
IconAlertCircle,
|
||||||
|
IconRefresh,
|
||||||
|
IconDots,
|
||||||
|
IconKey,
|
||||||
|
IconEdit,
|
||||||
|
IconTrash,
|
||||||
|
IconUserPlus,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
|
||||||
|
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import type { User } from '~/lib/unraid/types';
|
||||||
|
|
||||||
|
function UserCard({ user }: { user: User }) {
|
||||||
|
const isAdmin = user.name === 'root';
|
||||||
|
const initials = user.name.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Card.Section withBorder inheritPadding py="xs">
|
||||||
|
<Group position="apart">
|
||||||
|
<Group spacing="sm">
|
||||||
|
<Avatar color={isAdmin ? 'red' : 'blue'} radius="xl">
|
||||||
|
{initials}
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Text weight={600}>{user.name}</Text>
|
||||||
|
{isAdmin && (
|
||||||
|
<Badge size="xs" color="red" variant="filled">
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
{user.description && (
|
||||||
|
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||||
|
{user.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Menu shadow="md" width={150} position="bottom-end">
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle">
|
||||||
|
<IconDots size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item icon={<IconKey size={14} />} disabled>
|
||||||
|
Change Password
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item icon={<IconEdit size={14} />} disabled>
|
||||||
|
Edit
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
<Menu.Item
|
||||||
|
color="red"
|
||||||
|
icon={<IconTrash size={14} />}
|
||||||
|
disabled={isAdmin}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<Stack spacing="xs" mt="sm">
|
||||||
|
{/* UID */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
UID
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" weight={500} style={{ fontFamily: 'monospace' }}>
|
||||||
|
{user.id}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Role indicator */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Text size="xs" color="dimmed">
|
||||||
|
Role
|
||||||
|
</Text>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
color={isAdmin ? 'red' : 'blue'}
|
||||||
|
variant="light"
|
||||||
|
leftSection={isAdmin ? <IconUserShield size={12} /> : <IconUser size={12} />}
|
||||||
|
>
|
||||||
|
{isAdmin ? 'Administrator' : 'User'}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 200);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: users,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = api.unraid.users.useQuery(undefined, {
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredUsers = users?.filter((user) =>
|
||||||
|
user.name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
||||||
|
user.description?.toLowerCase().includes(debouncedSearch.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const adminCount = users?.filter((u) => u.name === 'root').length || 0;
|
||||||
|
const userCount = (users?.length || 0) - adminCount;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack align="center" spacing="md">
|
||||||
|
<Loader size="xl" />
|
||||||
|
<Text color="dimmed">Loading users...</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack spacing="xl">
|
||||||
|
{/* Header */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color="teal">
|
||||||
|
<IconUsers size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Users</Title>
|
||||||
|
<Text color="dimmed" size="sm">
|
||||||
|
{users?.length || 0} users configured
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftIcon={<IconUserPlus size={16} />}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftIcon={<IconRefresh size={16} />}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<SimpleGrid cols={3}>
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Total Users
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700}>
|
||||||
|
{users?.length || 0}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Administrators
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700} color="red">
|
||||||
|
{adminCount}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Regular Users
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700} color="blue">
|
||||||
|
{userCount}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search users..."
|
||||||
|
icon={<IconSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
style={{ flex: 1, maxWidth: 300 }}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Users Grid */}
|
||||||
|
<Grid>
|
||||||
|
{filteredUsers?.map((user) => (
|
||||||
|
<Grid.Col key={user.id} sm={6} lg={4}>
|
||||||
|
<UserCard user={user} />
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{filteredUsers?.length === 0 && (
|
||||||
|
<Text color="dimmed" align="center" py="xl">
|
||||||
|
No users found
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Users Table (alternative view) */}
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Title order={4} mb="md">
|
||||||
|
User List
|
||||||
|
</Title>
|
||||||
|
<Paper shadow="xs" radius="md" withBorder>
|
||||||
|
<Table fontSize="sm" verticalSpacing="sm" highlightOnHover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>User</th>
|
||||||
|
<th>UID</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th>Role</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users?.map((user) => {
|
||||||
|
const isAdmin = user.name === 'root';
|
||||||
|
return (
|
||||||
|
<tr key={user.id}>
|
||||||
|
<td>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Avatar size="sm" color={isAdmin ? 'red' : 'blue'} radius="xl">
|
||||||
|
{user.name.slice(0, 2).toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Text weight={500}>{user.name}</Text>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Text size="sm" style={{ fontFamily: 'monospace' }}>
|
||||||
|
{user.id}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Text size="sm" color="dimmed">
|
||||||
|
{user.description || '-'}
|
||||||
|
</Text>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
color={isAdmin ? 'red' : 'blue'}
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
{isAdmin ? 'Admin' : 'User'}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Tooltip label="Change Password">
|
||||||
|
<ActionIcon size="sm" variant="light" disabled>
|
||||||
|
<IconKey size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Edit">
|
||||||
|
<ActionIcon size="sm" variant="light" disabled>
|
||||||
|
<IconEdit size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Delete">
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
disabled={isAdmin}
|
||||||
|
>
|
||||||
|
<IconTrash size={14} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</Paper>
|
||||||
|
</Card>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const session = await getServerAuthSession(context);
|
||||||
|
const translations = await getServerSideTranslations(
|
||||||
|
['common'],
|
||||||
|
context.locale,
|
||||||
|
context.req,
|
||||||
|
context.res
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = checkForSessionOrAskForLogin(
|
||||||
|
context,
|
||||||
|
session,
|
||||||
|
() => session?.user != undefined
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...translations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
528
src/pages/unraid/vms/index.tsx
Normal file
528
src/pages/unraid/vms/index.tsx
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
/**
|
||||||
|
* Virtual Machines Management Page
|
||||||
|
* Full VM management with power controls
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Menu,
|
||||||
|
Paper,
|
||||||
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useDebouncedValue } from '@mantine/hooks';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import {
|
||||||
|
IconServer2,
|
||||||
|
IconPlayerPlay,
|
||||||
|
IconPlayerStop,
|
||||||
|
IconPlayerPause,
|
||||||
|
IconRefresh,
|
||||||
|
IconDots,
|
||||||
|
IconSearch,
|
||||||
|
IconAlertCircle,
|
||||||
|
IconCheck,
|
||||||
|
IconCpu,
|
||||||
|
IconDeviceDesktop,
|
||||||
|
} from '@tabler/icons-react';
|
||||||
|
import { GetServerSidePropsContext } from 'next';
|
||||||
|
|
||||||
|
import { UnraidLayout } from '~/components/Unraid/Layout';
|
||||||
|
import { getServerAuthSession } from '~/server/auth';
|
||||||
|
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||||
|
import { checkForSessionOrAskForLogin } from '~/tools/server/loginBuilder';
|
||||||
|
import { api } from '~/utils/api';
|
||||||
|
import type { VirtualMachine, VmState } from '~/lib/unraid/types';
|
||||||
|
|
||||||
|
function getStateColor(state: VmState): string {
|
||||||
|
switch (state) {
|
||||||
|
case 'RUNNING':
|
||||||
|
return 'green';
|
||||||
|
case 'SHUTOFF':
|
||||||
|
return 'red';
|
||||||
|
case 'PAUSED':
|
||||||
|
case 'PMSUSPENDED':
|
||||||
|
return 'yellow';
|
||||||
|
case 'SHUTDOWN':
|
||||||
|
return 'orange';
|
||||||
|
case 'IDLE':
|
||||||
|
return 'blue';
|
||||||
|
case 'CRASHED':
|
||||||
|
return 'red';
|
||||||
|
case 'NOSTATE':
|
||||||
|
default:
|
||||||
|
return 'gray';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMemory(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function VmCard({
|
||||||
|
vm,
|
||||||
|
onStart,
|
||||||
|
onStop,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
onReboot,
|
||||||
|
onForceStop,
|
||||||
|
isLoading,
|
||||||
|
}: {
|
||||||
|
vm: VirtualMachine;
|
||||||
|
onStart: () => void;
|
||||||
|
onStop: () => void;
|
||||||
|
onPause: () => void;
|
||||||
|
onResume: () => void;
|
||||||
|
onReboot: () => void;
|
||||||
|
onForceStop: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
}) {
|
||||||
|
const isRunning = vm.state === 'RUNNING';
|
||||||
|
const isPaused = vm.state === 'PAUSED' || vm.state === 'PMSUSPENDED';
|
||||||
|
const isStopped = vm.state === 'SHUTOFF' || vm.state === 'SHUTDOWN' || vm.state === 'NOSTATE';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Card.Section withBorder inheritPadding py="xs">
|
||||||
|
<Group position="apart">
|
||||||
|
<Group spacing="sm">
|
||||||
|
<ThemeIcon size="lg" variant="light" color={getStateColor(vm.state)}>
|
||||||
|
<IconServer2 size={20} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Text weight={600} lineClamp={1}>
|
||||||
|
{vm.name}
|
||||||
|
</Text>
|
||||||
|
{vm.description && (
|
||||||
|
<Text size="xs" color="dimmed" lineClamp={1}>
|
||||||
|
{vm.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group spacing="xs">
|
||||||
|
<Badge color={getStateColor(vm.state)} variant="light">
|
||||||
|
{vm.state}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{isPaused && (
|
||||||
|
<Tooltip label="Resume">
|
||||||
|
<ActionIcon
|
||||||
|
color="green"
|
||||||
|
variant="light"
|
||||||
|
onClick={onResume}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRunning && (
|
||||||
|
<>
|
||||||
|
<Tooltip label="Pause">
|
||||||
|
<ActionIcon
|
||||||
|
color="yellow"
|
||||||
|
variant="light"
|
||||||
|
onClick={onPause}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerPause size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Stop">
|
||||||
|
<ActionIcon
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
onClick={onStop}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerStop size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isStopped && (
|
||||||
|
<Tooltip label="Start">
|
||||||
|
<ActionIcon
|
||||||
|
color="green"
|
||||||
|
variant="light"
|
||||||
|
onClick={onStart}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<IconPlayerPlay size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Menu shadow="md" width={150} position="bottom-end">
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle">
|
||||||
|
<IconDots size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{isRunning && (
|
||||||
|
<Menu.Item icon={<IconRefresh size={14} />} onClick={onReboot}>
|
||||||
|
Reboot
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Item disabled icon={<IconDeviceDesktop size={14} />}>
|
||||||
|
VNC Console
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item disabled>Edit</Menu.Item>
|
||||||
|
<Menu.Item disabled>Clone</Menu.Item>
|
||||||
|
<Menu.Divider />
|
||||||
|
{isRunning && (
|
||||||
|
<Menu.Item color="red" onClick={onForceStop}>
|
||||||
|
Force Stop
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
<Menu.Item color="red" disabled>
|
||||||
|
Remove
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Card.Section>
|
||||||
|
|
||||||
|
<Stack spacing="xs" mt="sm">
|
||||||
|
{/* Resources */}
|
||||||
|
<SimpleGrid cols={2}>
|
||||||
|
<Group spacing="xs">
|
||||||
|
<IconCpu size={14} />
|
||||||
|
<Text size="sm">
|
||||||
|
<Text span weight={500}>
|
||||||
|
{vm.cpus}
|
||||||
|
</Text>{' '}
|
||||||
|
<Text span color="dimmed">
|
||||||
|
vCPU
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Group spacing="xs">
|
||||||
|
<IconServer2 size={14} />
|
||||||
|
<Text size="sm">
|
||||||
|
<Text span weight={500}>
|
||||||
|
{formatMemory(vm.memory)}
|
||||||
|
</Text>{' '}
|
||||||
|
<Text span color="dimmed">
|
||||||
|
RAM
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Auto-start */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Text size="xs" color="dimmed" style={{ fontFamily: 'monospace' }}>
|
||||||
|
{vm.uuid.substring(0, 8)}...
|
||||||
|
</Text>
|
||||||
|
{vm.autoStart && (
|
||||||
|
<Badge size="xs" color="blue" variant="dot">
|
||||||
|
Auto-start
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VmsPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [debouncedSearch] = useDebouncedValue(search, 200);
|
||||||
|
const [filter, setFilter] = useState<'all' | 'running' | 'stopped'>('all');
|
||||||
|
const [loadingVms, setLoadingVms] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: vms,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = api.unraid.vms.useQuery(undefined, {
|
||||||
|
refetchInterval: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const startVm = api.unraid.startVm.useMutation({
|
||||||
|
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
|
||||||
|
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'VM Started',
|
||||||
|
message: 'Virtual machine started successfully',
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCheck size={16} />,
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'Error',
|
||||||
|
message: err.message,
|
||||||
|
color: 'red',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const stopVm = api.unraid.stopVm.useMutation({
|
||||||
|
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
|
||||||
|
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'VM Stopped',
|
||||||
|
message: 'Virtual machine stopped successfully',
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCheck size={16} />,
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pauseVm = api.unraid.pauseVm.useMutation({
|
||||||
|
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
|
||||||
|
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'VM Paused',
|
||||||
|
message: 'Virtual machine paused',
|
||||||
|
color: 'yellow',
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const resumeVm = api.unraid.resumeVm.useMutation({
|
||||||
|
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
|
||||||
|
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'VM Resumed',
|
||||||
|
message: 'Virtual machine resumed',
|
||||||
|
color: 'green',
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rebootVm = api.unraid.rebootVm.useMutation({
|
||||||
|
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
|
||||||
|
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'VM Rebooting',
|
||||||
|
message: 'Virtual machine is rebooting',
|
||||||
|
color: 'blue',
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const forceStopVm = api.unraid.forceStopVm.useMutation({
|
||||||
|
onMutate: ({ id }) => setLoadingVms((prev) => [...prev, id]),
|
||||||
|
onSettled: (_, __, { id }) => setLoadingVms((prev) => prev.filter((vid) => vid !== id)),
|
||||||
|
onSuccess: () => {
|
||||||
|
notifications.show({
|
||||||
|
title: 'VM Force Stopped',
|
||||||
|
message: 'Virtual machine was force stopped',
|
||||||
|
color: 'orange',
|
||||||
|
});
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredVms = vms?.filter((vm) => {
|
||||||
|
const matchesSearch = vm.name.toLowerCase().includes(debouncedSearch.toLowerCase());
|
||||||
|
|
||||||
|
const matchesFilter =
|
||||||
|
filter === 'all' ||
|
||||||
|
(filter === 'running' && vm.state === 'RUNNING') ||
|
||||||
|
(filter === 'stopped' && vm.state !== 'RUNNING');
|
||||||
|
|
||||||
|
return matchesSearch && matchesFilter;
|
||||||
|
});
|
||||||
|
|
||||||
|
const runningCount = vms?.filter((v) => v.state === 'RUNNING').length || 0;
|
||||||
|
const stoppedCount = (vms?.length || 0) - runningCount;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack align="center" spacing="md">
|
||||||
|
<Loader size="xl" />
|
||||||
|
<Text color="dimmed">Loading VMs...</Text>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Alert icon={<IconAlertCircle size={16} />} title="Error" color="red">
|
||||||
|
{error.message}
|
||||||
|
</Alert>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnraidLayout>
|
||||||
|
<Container size="xl" py="xl">
|
||||||
|
<Stack spacing="xl">
|
||||||
|
{/* Header */}
|
||||||
|
<Group position="apart">
|
||||||
|
<Group>
|
||||||
|
<ThemeIcon size={48} radius="md" variant="light" color="violet">
|
||||||
|
<IconServer2 size={28} />
|
||||||
|
</ThemeIcon>
|
||||||
|
<div>
|
||||||
|
<Title order={2}>Virtual Machines</Title>
|
||||||
|
<Text color="dimmed" size="sm">
|
||||||
|
{runningCount} running, {stoppedCount} stopped
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftIcon={<IconRefresh size={16} />}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<SimpleGrid cols={3}>
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Total VMs
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700}>
|
||||||
|
{vms?.length || 0}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Running
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700} color="green">
|
||||||
|
{runningCount}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card shadow="sm" radius="md" withBorder>
|
||||||
|
<Text size="xs" color="dimmed" transform="uppercase" weight={600}>
|
||||||
|
Total vCPUs Allocated
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" weight={700}>
|
||||||
|
{vms?.reduce((sum, vm) => sum + vm.cpus, 0) || 0}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<Group>
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search VMs..."
|
||||||
|
icon={<IconSearch size={16} />}
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
style={{ flex: 1, maxWidth: 300 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SegmentedControl
|
||||||
|
value={filter}
|
||||||
|
onChange={(value) => setFilter(value as any)}
|
||||||
|
data={[
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Running', value: 'running' },
|
||||||
|
{ label: 'Stopped', value: 'stopped' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
{/* VM Grid */}
|
||||||
|
<Grid>
|
||||||
|
{filteredVms?.map((vm) => (
|
||||||
|
<Grid.Col key={vm.id} sm={6} lg={4}>
|
||||||
|
<VmCard
|
||||||
|
vm={vm}
|
||||||
|
onStart={() => startVm.mutate({ id: vm.id })}
|
||||||
|
onStop={() => stopVm.mutate({ id: vm.id })}
|
||||||
|
onPause={() => pauseVm.mutate({ id: vm.id })}
|
||||||
|
onResume={() => resumeVm.mutate({ id: vm.id })}
|
||||||
|
onReboot={() => rebootVm.mutate({ id: vm.id })}
|
||||||
|
onForceStop={() => forceStopVm.mutate({ id: vm.id })}
|
||||||
|
isLoading={loadingVms.includes(vm.id)}
|
||||||
|
/>
|
||||||
|
</Grid.Col>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{filteredVms?.length === 0 && (
|
||||||
|
<Text color="dimmed" align="center" py="xl">
|
||||||
|
No virtual machines found
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</UnraidLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||||
|
const session = await getServerAuthSession(context);
|
||||||
|
const translations = await getServerSideTranslations(
|
||||||
|
['common'],
|
||||||
|
context.locale,
|
||||||
|
context.req,
|
||||||
|
context.res
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = checkForSessionOrAskForLogin(
|
||||||
|
context,
|
||||||
|
session,
|
||||||
|
() => session?.user != undefined
|
||||||
|
);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...translations,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { tdarrRouter } from '~/server/api/routers/tdarr';
|
import { tdarrRouter } from '~/server/api/routers/tdarr';
|
||||||
|
import { unraidRouter } from '~/server/api/routers/unraid/router';
|
||||||
import { createTRPCRouter } from '~/server/api/trpc';
|
import { createTRPCRouter } from '~/server/api/trpc';
|
||||||
|
|
||||||
import { appRouter } from './routers/app';
|
import { appRouter } from './routers/app';
|
||||||
@@ -55,6 +56,7 @@ export const rootRouter = createTRPCRouter({
|
|||||||
healthMonitoring: healthMonitoringRouter,
|
healthMonitoring: healthMonitoringRouter,
|
||||||
tdarr: tdarrRouter,
|
tdarr: tdarrRouter,
|
||||||
migrate: migrateRouter,
|
migrate: migrateRouter,
|
||||||
|
unraid: unraidRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
445
src/server/api/routers/unraid/router.ts
Normal file
445
src/server/api/routers/unraid/router.ts
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@import 'fily-publish-gridstack/dist/gridstack.min.css';
|
@import 'fily-publish-gridstack/dist/gridstack.min.css';
|
||||||
|
@import './orchis/variables.css';
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--gridstack-widget-width: 64;
|
--gridstack-widget-width: 64;
|
||||||
|
|||||||
518
src/styles/orchis/theme.ts
Normal file
518
src/styles/orchis/theme.ts
Normal file
@@ -0,0 +1,518 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
155
src/styles/orchis/variables.css
Normal file
155
src/styles/orchis/variables.css
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Orchis Theme CSS Variables
|
||||||
|
* Based on the Orchis GTK Theme by vinceliuice
|
||||||
|
* https://github.com/vinceliuice/Orchis-theme
|
||||||
|
*/
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* ---- Primary / Accent ---- */
|
||||||
|
--orchis-primary: #1A73E8;
|
||||||
|
--orchis-primary-light: #3281EA;
|
||||||
|
--orchis-on-primary: #FFFFFF;
|
||||||
|
--orchis-on-primary-secondary: rgba(255, 255, 255, 0.7);
|
||||||
|
|
||||||
|
/* ---- Backgrounds (Light Mode) ---- */
|
||||||
|
--orchis-background: #F2F2F2;
|
||||||
|
--orchis-surface: #FFFFFF;
|
||||||
|
--orchis-base: #FFFFFF;
|
||||||
|
--orchis-base-alt: #FAFAFA;
|
||||||
|
|
||||||
|
/* ---- Text (Light Mode) ---- */
|
||||||
|
--orchis-text: rgba(0, 0, 0, 0.87);
|
||||||
|
--orchis-text-secondary: rgba(0, 0, 0, 0.6);
|
||||||
|
--orchis-text-disabled: rgba(0, 0, 0, 0.38);
|
||||||
|
|
||||||
|
/* ---- Semantic Colors ---- */
|
||||||
|
--orchis-error: #E53935;
|
||||||
|
--orchis-warning: #FFD600;
|
||||||
|
--orchis-success: #0F9D58;
|
||||||
|
--orchis-info: #1A73E8;
|
||||||
|
--orchis-link: #1A73E8;
|
||||||
|
--orchis-link-visited: #AB47BC;
|
||||||
|
|
||||||
|
/* ---- Borders ---- */
|
||||||
|
--orchis-border: rgba(0, 0, 0, 0.12);
|
||||||
|
--orchis-border-solid: #E2E2E2;
|
||||||
|
--orchis-divider: rgba(0, 0, 0, 0.12);
|
||||||
|
|
||||||
|
/* ---- Overlay States ---- */
|
||||||
|
--orchis-overlay-hover: rgba(0, 0, 0, 0.08);
|
||||||
|
--orchis-overlay-focus: rgba(0, 0, 0, 0.08);
|
||||||
|
--orchis-overlay-active: rgba(0, 0, 0, 0.12);
|
||||||
|
--orchis-overlay-checked: rgba(0, 0, 0, 0.10);
|
||||||
|
--orchis-overlay-selected: rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
|
/* ---- Track / Fill ---- */
|
||||||
|
--orchis-track: rgba(0, 0, 0, 0.26);
|
||||||
|
--orchis-track-disabled: rgba(0, 0, 0, 0.15);
|
||||||
|
--orchis-fill: rgba(0, 0, 0, 0.04);
|
||||||
|
--orchis-secondary-fill: rgba(0, 0, 0, 0.08);
|
||||||
|
|
||||||
|
/* ---- Tooltip ---- */
|
||||||
|
--orchis-tooltip-bg: rgba(0, 0, 0, 0.9);
|
||||||
|
|
||||||
|
/* ---- Titlebar / Sidebar ---- */
|
||||||
|
--orchis-titlebar: #FFFFFF;
|
||||||
|
--orchis-titlebar-backdrop: #FAFAFA;
|
||||||
|
--orchis-sidebar: #FAFAFA;
|
||||||
|
|
||||||
|
/* ---- Window Buttons ---- */
|
||||||
|
--orchis-btn-close: #fd5f51;
|
||||||
|
--orchis-btn-maximize: #38c76a;
|
||||||
|
--orchis-btn-minimize: #fdbe04;
|
||||||
|
|
||||||
|
/* ---- Typography ---- */
|
||||||
|
--orchis-font-family: "M+ 1c", Roboto, Cantarell, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
--orchis-font-size: 14px;
|
||||||
|
--orchis-font-weight-button: 500;
|
||||||
|
|
||||||
|
/* ---- Spacing ---- */
|
||||||
|
--orchis-space: 6px;
|
||||||
|
--orchis-space-xs: 2px;
|
||||||
|
--orchis-space-sm: 4px;
|
||||||
|
--orchis-space-md: 6px;
|
||||||
|
--orchis-space-lg: 12px;
|
||||||
|
--orchis-space-xl: 18px;
|
||||||
|
--orchis-space-xxl: 24px;
|
||||||
|
|
||||||
|
/* ---- Border Radius ---- */
|
||||||
|
--orchis-radius: 12px;
|
||||||
|
--orchis-radius-window: 18px;
|
||||||
|
--orchis-radius-corner: 12px;
|
||||||
|
--orchis-radius-menu: 11px;
|
||||||
|
--orchis-radius-card: 11px;
|
||||||
|
--orchis-radius-tooltip: 6px;
|
||||||
|
--orchis-radius-menuitem: 5px;
|
||||||
|
--orchis-radius-circular: 9999px;
|
||||||
|
|
||||||
|
/* ---- Shadows ---- */
|
||||||
|
--orchis-shadow-z1: 0 2px 2px -2px rgba(0,0,0,0.3), 0 1px 2px -1px rgba(0,0,0,0.24), 0 1px 2px -1px rgba(0,0,0,0.17);
|
||||||
|
--orchis-shadow-z2: 0 3px 2px -3px rgba(0,0,0,0.3), 0 2px 2px -1px rgba(0,0,0,0.24), 0 1px 3px 0 rgba(0,0,0,0.12);
|
||||||
|
--orchis-shadow-z3: 0 3px 3px -2px rgba(0,0,0,0.2), 0 3px 3px 0 rgba(0,0,0,0.14), 0 1px 5px 0 rgba(0,0,0,0.12);
|
||||||
|
--orchis-shadow-z4: 0 2px 2px -1px rgba(0,0,0,0.2), 0 4px 4px 0 rgba(0,0,0,0.14), 0 1px 6px 0 rgba(0,0,0,0.12);
|
||||||
|
--orchis-shadow-z6: 0 3px 3px -1px rgba(0,0,0,0.2), 0 6px 6px 0 rgba(0,0,0,0.14), 0 1px 11px 0 rgba(0,0,0,0.12);
|
||||||
|
--orchis-shadow-z8: 0 5px 5px -3px rgba(0,0,0,0.2), 0 8px 8px 1px rgba(0,0,0,0.14), 0 3px 9px 2px rgba(0,0,0,0.12);
|
||||||
|
--orchis-shadow-z12: 0 7px 7px -4px rgba(0,0,0,0.2), 0 12px 12px 2px rgba(0,0,0,0.14), 0 5px 13px 4px rgba(0,0,0,0.12);
|
||||||
|
--orchis-shadow-z16: 0 8px 6px -5px rgba(0,0,0,0.2), 0 16px 16px 2px rgba(0,0,0,0.14), 0 6px 18px 5px rgba(0,0,0,0.12);
|
||||||
|
--orchis-shadow-z24: 0 11px 11px -7px rgba(0,0,0,0.2), 0 24px 24px 3px rgba(0,0,0,0.14), 0 9px 28px 8px rgba(0,0,0,0.12);
|
||||||
|
|
||||||
|
/* ---- Transitions ---- */
|
||||||
|
--orchis-duration: 75ms;
|
||||||
|
--orchis-duration-short: 150ms;
|
||||||
|
--orchis-duration-ripple: 225ms;
|
||||||
|
--orchis-ease: cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||||
|
--orchis-ease-out: cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||||
|
--orchis-ease-in: cubic-bezier(0.4, 0.0, 1, 1);
|
||||||
|
--orchis-transition: all 75ms cubic-bezier(0.0, 0.0, 0.2, 1);
|
||||||
|
|
||||||
|
/* ---- Component Sizes ---- */
|
||||||
|
--orchis-size-small: 24px;
|
||||||
|
--orchis-size-medium: 36px;
|
||||||
|
--orchis-size-large: 48px;
|
||||||
|
--orchis-icon-size: 16px;
|
||||||
|
--orchis-icon-size-md: 24px;
|
||||||
|
--orchis-icon-size-lg: 32px;
|
||||||
|
--orchis-bar-size: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Dark Mode Overrides ---- */
|
||||||
|
[data-mantine-color-scheme="dark"],
|
||||||
|
.mantine-ColorScheme-root[data-mantine-color-scheme="dark"] {
|
||||||
|
--orchis-primary: #3281EA;
|
||||||
|
--orchis-background: #212121;
|
||||||
|
--orchis-surface: #3C3C3C;
|
||||||
|
--orchis-base: #2C2C2C;
|
||||||
|
--orchis-base-alt: #242424;
|
||||||
|
|
||||||
|
--orchis-text: #FFFFFF;
|
||||||
|
--orchis-text-secondary: rgba(255, 255, 255, 0.7);
|
||||||
|
--orchis-text-disabled: rgba(255, 255, 255, 0.5);
|
||||||
|
|
||||||
|
--orchis-error: #F44336;
|
||||||
|
--orchis-warning: #FBC02D;
|
||||||
|
--orchis-success: #81C995;
|
||||||
|
--orchis-link: #3281EA;
|
||||||
|
--orchis-link-visited: #BA68C8;
|
||||||
|
|
||||||
|
--orchis-border: rgba(255, 255, 255, 0.12);
|
||||||
|
--orchis-border-solid: #3D3D3D;
|
||||||
|
--orchis-divider: rgba(255, 255, 255, 0.12);
|
||||||
|
|
||||||
|
--orchis-overlay-hover: rgba(255, 255, 255, 0.08);
|
||||||
|
--orchis-overlay-focus: rgba(255, 255, 255, 0.08);
|
||||||
|
--orchis-overlay-active: rgba(255, 255, 255, 0.12);
|
||||||
|
--orchis-overlay-checked: rgba(255, 255, 255, 0.10);
|
||||||
|
--orchis-overlay-selected: rgba(255, 255, 255, 0.06);
|
||||||
|
|
||||||
|
--orchis-track: rgba(255, 255, 255, 0.3);
|
||||||
|
--orchis-track-disabled: rgba(255, 255, 255, 0.15);
|
||||||
|
--orchis-fill: rgba(255, 255, 255, 0.04);
|
||||||
|
--orchis-secondary-fill: rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
--orchis-titlebar: #2C2C2C;
|
||||||
|
--orchis-titlebar-backdrop: #3C3C3C;
|
||||||
|
--orchis-sidebar: #242424;
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
import { MantineProviderProps } from '@mantine/core';
|
import { MantineProviderProps } from '@mantine/core';
|
||||||
|
|
||||||
export const theme: MantineProviderProps['theme'] = {};
|
import { orchisTheme } from '~/styles/orchis/theme';
|
||||||
|
|
||||||
|
// Use Orchis theme as the base theme
|
||||||
|
export const theme: MantineProviderProps['theme'] = orchisTheme;
|
||||||
|
|||||||
Reference in New Issue
Block a user