feat: replace docker overview table with web component (7.3+) (#1764)

## Summary

Introduces a new Vue-based Docker container management interface
replacing the legacy webgui table.

### Container Management
- Start, stop, pause, resume, and remove containers via GraphQL
mutations
- Bulk actions for managing multiple containers at once
- Container update detection with one-click updates
- Real-time container statistics (CPU, memory, I/O)

### Organization & Navigation
- Folder-based container organization with drag-and-drop support
- Accessible reordering via keyboard controls
- Customizable column visibility with persistent preferences
- Column resizing and reordering
- Filtering and search across container properties

### Auto-start Configuration
- Dedicated autostart view with delay configuration
- Drag-and-drop reordering of start/stop sequences

### Logs & Console
- Integrated log viewer with filtering and download
- Persistent console sessions with shell selection
- Slideover panel for quick access

### Networking
- Port conflict detection and alerts
- Tailscale integration for container networking status
- LAN IP and port information display

### Additional Features
- Orphaned container detection and cleanup
- Template mapping management
- Critical notifications system
- WebUI visit links with Tailscale support

<sub>PR Summary by Claude Opus 4.5</sub>
This commit is contained in:
Pujit Mehrotra
2025-12-18 11:11:05 -05:00
committed by GitHub
parent e1e3ea7eb6
commit 277ac42046
173 changed files with 18786 additions and 1648 deletions

View File

@@ -19,6 +19,7 @@ PATHS_LOGS_FILE=./dev/log/graphql-api.log
PATHS_CONNECT_STATUS_FILE_PATH=./dev/connectStatus.json # Connect plugin status file
PATHS_OIDC_JSON=./dev/configs/oidc.local.json
PATHS_LOCAL_SESSION_FILE=./dev/local-session
PATHS_DOCKER_TEMPLATES=./dev/docker-templates
ENVIRONMENT="development"
NODE_ENV="development"
PORT="3001"

View File

@@ -3,3 +3,4 @@ NODE_ENV="production"
PORT="/var/run/unraid-api.sock"
MOTHERSHIP_GRAPHQL_LINK="https://mothership.unraid.net/ws"
PATHS_CONFIG_MODULES="/boot/config/plugins/dynamix.my.servers/configs"
ENABLE_NEXT_DOCKER_RELEASE=true

View File

@@ -3,3 +3,4 @@ NODE_ENV="production"
PORT="/var/run/unraid-api.sock"
MOTHERSHIP_GRAPHQL_LINK="https://staging.mothership.unraid.net/ws"
PATHS_CONFIG_MODULES="/boot/config/plugins/dynamix.my.servers/configs"
ENABLE_NEXT_DOCKER_RELEASE=true

View File

@@ -8,7 +8,7 @@ export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
ignores: ['src/graphql/generated/client/**/*', 'src/**/**/dummy-process.js'],
ignores: ['src/graphql/generated/client/**/*', 'src/**/**/dummy-process.js', 'dist/**/*'],
},
{
plugins: {

6
api/.gitignore vendored
View File

@@ -83,6 +83,8 @@ deploy/*
!**/*.login.*
# Local Development Artifacts
# local api configs - don't need project-wide tracking
dev/connectStatus.json
dev/configs/*
@@ -96,3 +98,7 @@ dev/configs/oidc.local.json
# local api keys
dev/keys/*
# mock docker templates
dev/docker-templates
# ie unraid notifications
dev/notifications

View File

@@ -5,3 +5,4 @@ src/unraid-api/unraid-file-modifier/modifications/__fixtures__/downloaded/*
# Generated Types
src/graphql/generated/client/*.ts
dist/

View File

@@ -75,6 +75,16 @@ If you found this file you're likely a developer. If you'd like to know more abo
- Run `pnpm --filter @unraid/api i18n:extract` to scan the Nest.js source for translation helper usages and update `src/i18n/en.json` with any new keys. The extractor keeps existing translations intact and appends new keys with their English source text.
## Developer Documentation
For detailed information about specific features:
- [API Plugins](docs/developer/api-plugins.md) - Working with API plugins and workspace packages
- [Docker Feature](docs/developer/docker.md) - Container management, GraphQL API, and WebGUI integration
- [Feature Flags](docs/developer/feature-flags.md) - Conditionally enabling functionality
- [Repository Organization](docs/developer/repo-organization.md) - Codebase structure
- [Development Workflows](docs/developer/workflows.md) - Development processes
## License
Copyright Lime Technology Inc. All rights reserved.

View File

@@ -1,5 +1,5 @@
{
"version": "4.27.2",
"version": "4.28.2",
"extraOrigins": [],
"sandbox": true,
"ssoSubIds": [],

View File

@@ -0,0 +1,555 @@
# Docker Feature
The Docker feature provides complete container management for Unraid through a GraphQL API, including lifecycle operations, real-time monitoring, update detection, and organizational tools.
## Table of Contents
- [Overview](#overview)
- [Architecture](#architecture)
- [Module Structure](#module-structure)
- [Data Flow](#data-flow)
- [Core Services](#core-services)
- [DockerService](#dockerservice)
- [DockerNetworkService](#dockernetworkservice)
- [DockerPortService](#dockerportservice)
- [DockerLogService](#dockerlogservice)
- [DockerStatsService](#dockerstatsservice)
- [DockerAutostartService](#dockerautostartservice)
- [DockerConfigService](#dockerconfigservice)
- [DockerManifestService](#dockermanifestservice)
- [DockerPhpService](#dockerphpservice)
- [DockerTailscaleService](#dockertailscaleservice)
- [DockerTemplateScannerService](#dockertemplatescannerservice)
- [DockerOrganizerService](#dockerorganizerservice)
- [GraphQL API](#graphql-api)
- [Queries](#queries)
- [Mutations](#mutations)
- [Subscriptions](#subscriptions)
- [Data Models](#data-models)
- [DockerContainer](#dockercontainer)
- [ContainerState](#containerstate)
- [ContainerPort](#containerport)
- [DockerPortConflicts](#dockerportconflicts)
- [Caching Strategy](#caching-strategy)
- [WebGUI Integration](#webgui-integration)
- [File Modification](#file-modification)
- [PHP Integration](#php-integration)
- [Permissions](#permissions)
- [Configuration Files](#configuration-files)
- [Development](#development)
- [Adding a New Docker Service](#adding-a-new-docker-service)
- [Testing](#testing)
- [Feature Flag Testing](#feature-flag-testing)
## Overview
**Location:** `src/unraid-api/graph/resolvers/docker/`
**Feature Flag:** Many next-generation features are gated behind `ENABLE_NEXT_DOCKER_RELEASE`. See [Feature Flags](./feature-flags.md) for details on enabling.
**Key Capabilities:**
- Container lifecycle management (start, stop, pause, update, remove)
- Real-time container stats streaming
- Network and port conflict detection
- Container log retrieval
- Automatic update detection via digest comparison
- Tailscale container integration
- Container organization with folders and views
- Template-based metadata resolution
## Architecture
### Module Structure
The Docker module (`docker.module.ts`) serves as the entry point and exports:
- **13 services** for various Docker operations
- **3 resolvers** for GraphQL query/mutation/subscription handling
**Dependencies:**
- `JobModule` - Background job scheduling
- `NotificationsModule` - User notifications
- `ServicesModule` - Shared service utilities
### Data Flow
```text
Docker Daemon (Unix Socket)
dockerode library
DockerService (transform & cache)
GraphQL Resolvers
Client Applications
```
The API communicates with the Docker daemon through the `dockerode` library via Unix socket. Container data is transformed from raw Docker API format to GraphQL types, enriched with Unraid-specific metadata (templates, autostart config), and cached for performance.
## Core Services
### DockerService
**File:** `docker.service.ts`
Central orchestrator for all container operations.
**Key Methods:**
- `getContainers(skipCache?, includeSize?)` - List containers with caching
- `start(id)`, `stop(id)`, `pause(id)`, `unpause(id)` - Lifecycle operations
- `updateContainer(id)`, `updateContainers(ids)`, `updateAllContainers()` - Image updates
- `removeContainer(id, withImage?)` - Remove container and optionally its image
**Caching:**
- Cache TTL: 60 seconds (60000ms)
- Cache keys: `docker_containers`, `docker_containers_with_size`
- Invalidated automatically on mutations
### DockerNetworkService
**File:** `docker-network.service.ts`
Lists Docker networks with metadata including driver, scope, IPAM settings, and connected containers.
**Caching:** 60 seconds
### DockerPortService
**File:** `docker-port.service.ts`
Detects port conflicts between containers and with the host.
**Features:**
- Deduplicates port mappings from Docker API
- Identifies container-to-container conflicts
- Detects host-level port collisions
- Separates TCP and UDP conflicts
- Calculates LAN-accessible IP:port combinations
### DockerLogService
**File:** `docker-log.service.ts`
Retrieves container logs with configurable options.
**Parameters:**
- `tail` - Number of lines (default: 200, max: 2000)
- `since` - Timestamp filter for log entries
**Additional Features:**
- Calculates container log file sizes
- Supports timestamp-based filtering
### DockerStatsService
**File:** `docker-stats.service.ts`
Provides real-time container statistics via GraphQL subscription.
**Metrics:**
- CPU percentage
- Memory usage and limit
- Network I/O (received/transmitted bytes)
- Block I/O (read/written bytes)
**Implementation:**
- Spawns `docker stats` process with streaming output
- Publishes to `PUBSUB_CHANNEL.DOCKER_STATS`
- Auto-starts on first subscriber, stops when last disconnects
### DockerAutostartService
**File:** `docker-autostart.service.ts`
Manages container auto-start configuration.
**Features:**
- Parses auto-start file format (name + wait time per line)
- Maintains auto-start order and wait times
- Persists configuration changes
- Tracks container primary names
### DockerConfigService
**File:** `docker-config.service.ts`
Persistent configuration management using `ConfigFilePersister`.
**Configuration Options:**
- `templateMappings` - Container name to template file path mappings
- `skipTemplatePaths` - Containers excluded from template scanning
- `updateCheckCronSchedule` - Cron expression for digest refresh (default: daily at 6am)
### DockerManifestService
**File:** `docker-manifest.service.ts`
Detects available container image updates.
**Implementation:**
- Compares local and remote image SHA256 digests
- Reads cached status from `/var/lib/docker/unraid-update-status.json`
- Triggers refresh via PHP integration
### DockerPhpService
**File:** `docker-php.service.ts`
Integration with legacy Unraid PHP Docker scripts.
**PHP Scripts Used:**
- `DockerUpdate.php` - Refresh container digests
- `DockerContainers.php` - Get update statuses
**Update Statuses:**
- `UP_TO_DATE` - Container is current
- `UPDATE_AVAILABLE` - New image available
- `REBUILD_READY` - Rebuild required
- `UNKNOWN` - Status could not be determined
### DockerTailscaleService
**File:** `docker-tailscale.service.ts`
Detects and monitors Tailscale-enabled containers.
**Detection Methods:**
- Container labels indicating Tailscale
- Tailscale socket mount points
**Status Information:**
- Tailscale version and backend state
- Hostname and DNS name
- Exit node status
- Key expiry dates
**Caching:**
- Status cache: 30 seconds
- DERP map and versions: 24 hours
### DockerTemplateScannerService
**File:** `docker-template-scanner.service.ts`
Maps containers to their template files for metadata resolution.
**Bootstrap Process:**
1. Runs 5 seconds after app startup
2. Scans XML templates from configured paths
3. Parses container/image names from XML
4. Matches against running containers
5. Stores mappings in `docker.config.json`
**Template Metadata Resolved:**
- `projectUrl`, `registryUrl`, `supportUrl`
- `iconUrl`, `webUiUrl`, `shell`
- Template port mappings
**Orphaned Containers:**
Containers without matching templates are marked as "orphaned" in the API response.
### DockerOrganizerService
**File:** `organizer/docker-organizer.service.ts`
Container organization system for UI views.
**Features:**
- Hierarchical folder structure
- Multiple views with different layouts
- Position-based organization
- View-specific preferences (sorting, filtering)
## GraphQL API
### Queries
```graphql
type Query {
docker: Docker!
}
type Docker {
containers(skipCache: Boolean): [DockerContainer!]!
container(id: PrefixedID!): DockerContainer # Feature-flagged
networks(skipCache: Boolean): [DockerNetwork!]!
portConflicts(skipCache: Boolean): DockerPortConflicts!
logs(id: PrefixedID!, since: Int, tail: Int): DockerContainerLogs!
organizer(skipCache: Boolean): DockerOrganizer! # Feature-flagged
containerUpdateStatuses: [ContainerUpdateStatus!]! # Feature-flagged
}
```
### Mutations
**Container Lifecycle:**
```graphql
type Mutation {
start(id: PrefixedID!): DockerContainer!
stop(id: PrefixedID!): DockerContainer!
pause(id: PrefixedID!): DockerContainer!
unpause(id: PrefixedID!): DockerContainer!
removeContainer(id: PrefixedID!, withImage: Boolean): Boolean!
}
```
**Container Updates:**
```graphql
type Mutation {
updateContainer(id: PrefixedID!): DockerContainer!
updateContainers(ids: [PrefixedID!]!): [DockerContainer!]!
updateAllContainers: [DockerContainer!]!
refreshDockerDigests: Boolean!
}
```
**Configuration:**
```graphql
type Mutation {
updateAutostartConfiguration(
entries: [AutostartEntry!]!
persistUserPreferences: Boolean
): Boolean!
syncDockerTemplatePaths: Boolean!
resetDockerTemplateMappings: Boolean!
}
```
**Organizer (Feature-flagged):**
```graphql
type Mutation {
createDockerFolder(name: String!, parentId: ID, childrenIds: [ID!]): DockerFolder!
createDockerFolderWithItems(
name: String!
parentId: ID
sourceEntryIds: [ID!]
position: Int
): DockerFolder!
setDockerFolderChildren(folderId: ID!, childrenIds: [ID!]!): DockerFolder!
deleteDockerEntries(entryIds: [ID!]!): Boolean!
moveDockerEntriesToFolder(sourceEntryIds: [ID!]!, destinationFolderId: ID!): Boolean!
moveDockerItemsToPosition(
sourceEntryIds: [ID!]!
destinationFolderId: ID!
position: Int!
): Boolean!
renameDockerFolder(folderId: ID!, newName: String!): DockerFolder!
updateDockerViewPreferences(viewId: ID!, prefs: ViewPreferencesInput!): Boolean!
}
```
### Subscriptions
```graphql
type Subscription {
dockerContainerStats: DockerContainerStats!
}
```
Real-time container statistics stream. Automatically starts when first client subscribes and stops when last client disconnects.
## Data Models
### DockerContainer
Primary container representation with 24+ fields:
```typescript
{
id: PrefixedID
names: [String!]!
image: String!
imageId: String!
state: ContainerState!
status: String!
created: Float!
// Networking
ports: [ContainerPort!]!
lanIpPorts: [ContainerPort!]!
hostConfig: ContainerHostConfig
networkSettings: DockerNetworkSettings
// Storage
sizeRootFs: Float
sizeRw: Float
sizeLog: Float
mounts: [ContainerMount!]!
// Metadata
labels: JSON
// Auto-start
autoStart: Boolean!
autoStartOrder: Int
autoStartWait: Int
// Template Integration
templatePath: String
isOrphaned: Boolean!
projectUrl: String
registryUrl: String
supportUrl: String
iconUrl: String
webUiUrl: String
shell: String
templatePorts: [ContainerPort!]
// Tailscale
tailscaleEnabled: Boolean!
tailscaleStatus: TailscaleStatus
// Updates
isUpdateAvailable: Boolean
isRebuildReady: Boolean
}
```
### ContainerState
```typescript
enum ContainerState {
RUNNING
PAUSED
EXITED
}
```
### ContainerPort
```typescript
{
ip: String
privatePort: Int!
publicPort: Int
type: String! // "tcp" or "udp"
}
```
### DockerPortConflicts
```typescript
{
containerConflicts: [DockerContainerPortConflict!]!
lanConflicts: [DockerLanPortConflict!]!
}
```
## Caching Strategy
The Docker feature uses `cache-manager` v7 for performance optimization.
**Important:** cache-manager v7 expects TTL values in **milliseconds**, not seconds.
| Cache Key | TTL | Invalidation |
|-----------|-----|--------------|
| `docker_containers` | 60s | On any container mutation |
| `docker_containers_with_size` | 60s | On any container mutation |
| `docker_networks` | 60s | On network changes |
| Tailscale status | 30s | Automatic |
| Tailscale DERP/versions | 24h | Automatic |
**Cache Invalidation Triggers:**
- `start()`, `stop()`, `pause()`, `unpause()`
- `updateContainer()`, `updateContainers()`, `updateAllContainers()`
- `removeContainer()`
- `updateAutostartConfiguration()`
## WebGUI Integration
### File Modification
**File:** `unraid-file-modifier/modifications/docker-containers-page.modification.ts`
**Target:** `/usr/local/emhttp/plugins/dynamix.docker.manager/DockerContainers.page`
When `ENABLE_NEXT_DOCKER_RELEASE` is enabled and Unraid version is 7.3.0+, the modification:
1. Replaces the legacy Docker containers page
2. Injects the Vue web component: `<unraid-docker-container-overview>`
3. Retains the `Nchan="docker_load"` page attribute (an emhttp/WebGUI feature for real-time updates, not controlled by the API)
### PHP Integration
The API integrates with legacy Unraid PHP scripts for certain operations:
- **Digest refresh:** Calls `DockerUpdate.php` to refresh container image digests
- **Update status:** Reads from `DockerContainers.php` output
## Permissions
All Docker operations are protected with permission checks:
| Operation | Resource | Action |
|-----------|----------|--------|
| Read containers/networks | `Resource.DOCKER` | `AuthAction.READ_ANY` |
| Start/stop/pause/update | `Resource.DOCKER` | `AuthAction.UPDATE_ANY` |
| Remove containers | `Resource.DOCKER` | `AuthAction.DELETE_ANY` |
## Configuration Files
| File | Purpose |
|------|---------|
| `docker.config.json` | Template mappings, skip paths, cron schedule |
| `docker.organizer.json` | Container organization tree and views |
| `/var/lib/docker/unraid-update-status.json` | Cached container update statuses |
## Development
### Adding a New Docker Service
1. Create service file in `src/unraid-api/graph/resolvers/docker/`
2. Add to `docker.module.ts` providers and exports
3. Inject into resolvers as needed
4. Add GraphQL types to `docker.model.ts` if needed
### Testing
```bash
# Run Docker-related tests
pnpm --filter ./api test -- src/unraid-api/graph/resolvers/docker/
# Run specific test file
pnpm --filter ./api test -- src/unraid-api/graph/resolvers/docker/docker.service.spec.ts
```
### Feature Flag Testing
To test next-generation Docker features locally:
```bash
ENABLE_NEXT_DOCKER_RELEASE=true unraid-api start
```
Or add to `.env`:
```env
ENABLE_NEXT_DOCKER_RELEASE=true
```

View File

@@ -862,6 +862,38 @@ type DockerMutations {
"""Stop a container"""
stop(id: PrefixedID!): DockerContainer!
"""Pause (Suspend) a container"""
pause(id: PrefixedID!): DockerContainer!
"""Unpause (Resume) a container"""
unpause(id: PrefixedID!): DockerContainer!
"""Remove a container"""
removeContainer(id: PrefixedID!, withImage: Boolean): Boolean!
"""Update auto-start configuration for Docker containers"""
updateAutostartConfiguration(entries: [DockerAutostartEntryInput!]!, persistUserPreferences: Boolean): Boolean!
"""Update a container to the latest image"""
updateContainer(id: PrefixedID!): DockerContainer!
"""Update multiple containers to the latest images"""
updateContainers(ids: [PrefixedID!]!): [DockerContainer!]!
"""Update all containers that have available updates"""
updateAllContainers: [DockerContainer!]!
}
input DockerAutostartEntryInput {
"""Docker container identifier"""
id: PrefixedID!
"""Whether the container should auto-start"""
autoStart: Boolean!
"""Number of seconds to wait after starting the container"""
wait: Int
}
type VmMutations {
@@ -1089,6 +1121,29 @@ enum ContainerPortType {
UDP
}
type DockerPortConflictContainer {
id: PrefixedID!
name: String!
}
type DockerContainerPortConflict {
privatePort: Port!
type: ContainerPortType!
containers: [DockerPortConflictContainer!]!
}
type DockerLanPortConflict {
lanIpPort: String!
publicPort: Port
type: ContainerPortType!
containers: [DockerPortConflictContainer!]!
}
type DockerPortConflicts {
containerPorts: [DockerContainerPortConflict!]!
lanPorts: [DockerLanPortConflict!]!
}
type ContainerHostConfig {
networkMode: String!
}
@@ -1102,8 +1157,17 @@ type DockerContainer implements Node {
created: Int!
ports: [ContainerPort!]!
"""List of LAN-accessible host:port values"""
lanIpPorts: [String!]
"""Total size of all files in the container (in bytes)"""
sizeRootFs: BigInt
"""Size of writable layer (in bytes)"""
sizeRw: BigInt
"""Size of container logs (in bytes)"""
sizeLog: BigInt
labels: JSON
state: ContainerState!
status: String!
@@ -1111,12 +1175,50 @@ type DockerContainer implements Node {
networkSettings: JSON
mounts: [JSON!]
autoStart: Boolean!
"""Zero-based order in the auto-start list"""
autoStartOrder: Int
"""Wait time in seconds applied after start"""
autoStartWait: Int
templatePath: String
"""Project/Product homepage URL"""
projectUrl: String
"""Registry/Docker Hub URL"""
registryUrl: String
"""Support page/thread URL"""
supportUrl: String
"""Icon URL"""
iconUrl: String
"""Resolved WebUI URL from template"""
webUiUrl: String
"""Shell to use for console access (from template)"""
shell: String
"""Port mappings from template (used when container is not running)"""
templatePorts: [ContainerPort!]
"""Whether the container is orphaned (no template found)"""
isOrphaned: Boolean!
isUpdateAvailable: Boolean
isRebuildReady: Boolean
"""Whether Tailscale is enabled for this container"""
tailscaleEnabled: Boolean!
"""Tailscale status for this container (fetched via docker exec)"""
tailscaleStatus(forceRefresh: Boolean = false): TailscaleStatus
}
enum ContainerState {
RUNNING
PAUSED
EXITED
}
@@ -1138,49 +1240,213 @@ type DockerNetwork implements Node {
labels: JSON!
}
type DockerContainerLogLine {
timestamp: DateTime!
message: String!
}
type DockerContainerLogs {
containerId: PrefixedID!
lines: [DockerContainerLogLine!]!
"""
Cursor that can be passed back through the since argument to continue streaming logs.
"""
cursor: DateTime
}
type DockerContainerStats {
id: PrefixedID!
"""CPU Usage Percentage"""
cpuPercent: Float!
"""Memory Usage String (e.g. 100MB / 1GB)"""
memUsage: String!
"""Memory Usage Percentage"""
memPercent: Float!
"""Network I/O String (e.g. 100MB / 1GB)"""
netIO: String!
"""Block I/O String (e.g. 100MB / 1GB)"""
blockIO: String!
}
"""Tailscale exit node connection status"""
type TailscaleExitNodeStatus {
"""Whether the exit node is online"""
online: Boolean!
"""Tailscale IPs of the exit node"""
tailscaleIps: [String!]
}
"""Tailscale status for a Docker container"""
type TailscaleStatus {
"""Whether Tailscale is online in the container"""
online: Boolean!
"""Current Tailscale version"""
version: String
"""Latest available Tailscale version"""
latestVersion: String
"""Whether a Tailscale update is available"""
updateAvailable: Boolean!
"""Configured Tailscale hostname"""
hostname: String
"""Actual Tailscale DNS name"""
dnsName: String
"""DERP relay code"""
relay: String
"""DERP relay region name"""
relayName: String
"""Tailscale IPv4 and IPv6 addresses"""
tailscaleIps: [String!]
"""Advertised subnet routes"""
primaryRoutes: [String!]
"""Whether this container is an exit node"""
isExitNode: Boolean!
"""Status of the connected exit node (if using one)"""
exitNodeStatus: TailscaleExitNodeStatus
"""Tailscale Serve/Funnel WebUI URL"""
webUiUrl: String
"""Tailscale key expiry date"""
keyExpiry: DateTime
"""Days until key expires"""
keyExpiryDays: Int
"""Whether the Tailscale key has expired"""
keyExpired: Boolean!
"""Tailscale backend state (Running, NeedsLogin, Stopped, etc.)"""
backendState: String
"""Authentication URL if Tailscale needs login"""
authUrl: String
}
type Docker implements Node {
id: PrefixedID!
containers(skipCache: Boolean! = false): [DockerContainer!]!
networks(skipCache: Boolean! = false): [DockerNetwork!]!
organizer: ResolvedOrganizerV1!
portConflicts(skipCache: Boolean! = false): DockerPortConflicts!
"""
Access container logs. Requires specifying a target container id through resolver arguments.
"""
logs(id: PrefixedID!, since: DateTime, tail: Int): DockerContainerLogs!
container(id: PrefixedID!): DockerContainer
organizer(skipCache: Boolean! = false): ResolvedOrganizerV1!
containerUpdateStatuses: [ExplicitStatusItem!]!
}
type DockerTemplateSyncResult {
scanned: Int!
matched: Int!
skipped: Int!
errors: [String!]!
}
type ResolvedOrganizerView {
id: String!
name: String!
root: ResolvedOrganizerEntry!
rootId: String!
flatEntries: [FlatOrganizerEntry!]!
prefs: JSON
}
union ResolvedOrganizerEntry = ResolvedOrganizerFolder | OrganizerContainerResource | OrganizerResource
type ResolvedOrganizerFolder {
id: String!
type: String!
name: String!
children: [ResolvedOrganizerEntry!]!
}
type OrganizerContainerResource {
id: String!
type: String!
name: String!
meta: DockerContainer
}
type OrganizerResource {
id: String!
type: String!
name: String!
meta: JSON
}
type ResolvedOrganizerV1 {
version: Float!
views: [ResolvedOrganizerView!]!
}
type FlatOrganizerEntry {
id: String!
type: String!
name: String!
parentId: String
depth: Float!
position: Float!
path: [String!]!
hasChildren: Boolean!
childrenIds: [String!]!
meta: DockerContainer
}
type NotificationCounts {
info: Int!
warning: Int!
alert: Int!
total: Int!
}
type NotificationOverview {
unread: NotificationCounts!
archive: NotificationCounts!
}
type Notification implements Node {
id: PrefixedID!
"""Also known as 'event'"""
title: String!
subject: String!
description: String!
importance: NotificationImportance!
link: String
type: NotificationType!
"""ISO Timestamp for when the notification occurred"""
timestamp: String
formattedTimestamp: String
}
enum NotificationImportance {
ALERT
INFO
WARNING
}
enum NotificationType {
UNREAD
ARCHIVE
}
type Notifications implements Node {
id: PrefixedID!
"""A cached overview of the notifications in the system & their severity."""
overview: NotificationOverview!
list(filter: NotificationFilter!): [Notification!]!
"""
Deduplicated list of unread warning and alert notifications, sorted latest first.
"""
warningsAndAlerts: [Notification!]!
}
input NotificationFilter {
importance: NotificationImportance
type: NotificationType!
offset: Int!
limit: Int!
}
type FlashBackupStatus {
"""Status message indicating the outcome of the backup initiation."""
status: String!
@@ -1781,60 +2047,6 @@ type Metrics implements Node {
memory: MemoryUtilization
}
type NotificationCounts {
info: Int!
warning: Int!
alert: Int!
total: Int!
}
type NotificationOverview {
unread: NotificationCounts!
archive: NotificationCounts!
}
type Notification implements Node {
id: PrefixedID!
"""Also known as 'event'"""
title: String!
subject: String!
description: String!
importance: NotificationImportance!
link: String
type: NotificationType!
"""ISO Timestamp for when the notification occurred"""
timestamp: String
formattedTimestamp: String
}
enum NotificationImportance {
ALERT
INFO
WARNING
}
enum NotificationType {
UNREAD
ARCHIVE
}
type Notifications implements Node {
id: PrefixedID!
"""A cached overview of the notifications in the system & their severity."""
overview: NotificationOverview!
list(filter: NotificationFilter!): [Notification!]!
}
input NotificationFilter {
importance: NotificationImportance
type: NotificationType!
offset: Int!
limit: Int!
}
type Owner {
username: String!
url: String!
@@ -2444,6 +2656,11 @@ type Mutation {
"""Marks a notification as archived."""
archiveNotification(id: PrefixedID!): Notification!
archiveNotifications(ids: [PrefixedID!]!): NotificationOverview!
"""
Creates a notification if an equivalent unread notification does not already exist.
"""
notifyIfUnique(input: NotificationData!): Notification
archiveAll(importance: NotificationImportance): NotificationOverview!
"""Marks a notification as unread."""
@@ -2464,6 +2681,16 @@ type Mutation {
setDockerFolderChildren(folderId: String, childrenIds: [String!]!): ResolvedOrganizerV1!
deleteDockerEntries(entryIds: [String!]!): ResolvedOrganizerV1!
moveDockerEntriesToFolder(sourceEntryIds: [String!]!, destinationFolderId: String!): ResolvedOrganizerV1!
moveDockerItemsToPosition(sourceEntryIds: [String!]!, destinationFolderId: String!, position: Float!): ResolvedOrganizerV1!
renameDockerFolder(folderId: String!, newName: String!): ResolvedOrganizerV1!
createDockerFolderWithItems(name: String!, parentId: String, sourceEntryIds: [String!], position: Float): ResolvedOrganizerV1!
updateDockerViewPreferences(viewId: String = "default", prefs: JSON!): ResolvedOrganizerV1!
syncDockerTemplatePaths: DockerTemplateSyncResult!
"""
Reset Docker template mappings to defaults. Use this to recover from corrupted state.
"""
resetDockerTemplateMappings: Boolean!
refreshDockerDigests: Boolean!
"""Initiates a flash drive backup using a configured remote."""
@@ -2665,10 +2892,12 @@ input AccessUrlInput {
type Subscription {
notificationAdded: Notification!
notificationsOverview: NotificationOverview!
notificationsWarningsAndAlerts: [Notification!]!
ownerSubscription: Owner!
serversSubscription: Server!
parityHistorySubscription: ParityCheck!
arraySubscription: UnraidArray!
dockerContainerStats: DockerContainerStats!
logFile(path: String!): LogFileContent!
systemMetricsCpu: CpuUtilization!
systemMetricsCpuTelemetry: CpuPackages!

View File

@@ -12,8 +12,13 @@ default:
@deploy remote:
./scripts/deploy-dev.sh {{remote}}
# watches typescript files and restarts dev server on changes
@watch:
watchexec -e ts -r -- pnpm dev
alias b := build
alias d := deploy
alias w := watch
sync-env server:
rsync -avz --progress --stats -e ssh .env* root@{{server}}:/usr/local/unraid-api

View File

@@ -104,6 +104,7 @@
"escape-html": "1.0.3",
"execa": "9.6.0",
"exit-hook": "4.0.0",
"fast-xml-parser": "^5.3.0",
"fastify": "5.5.0",
"filenamify": "7.0.0",
"fs-extra": "11.3.1",

View File

@@ -7,7 +7,7 @@ import { exit } from 'process';
import type { PackageJson } from 'type-fest';
import { $, cd } from 'zx';
import { getDeploymentVersion } from './get-deployment-version.js';
import { getDeploymentVersion } from '@app/../scripts/get-deployment-version.js';
type ApiPackageJson = PackageJson & {
version: string;

View File

@@ -6,6 +6,7 @@ exports[`Returns paths 1`] = `
"unraid-api-base",
"unraid-data",
"docker-autostart",
"docker-userprefs",
"docker-socket",
"rclone-socket",
"parity-checks",

View File

@@ -11,6 +11,7 @@ test('Returns paths', async () => {
'unraid-api-base': '/usr/local/unraid-api/',
'unraid-data': expect.stringContaining('api/dev/data'),
'docker-autostart': '/var/lib/docker/unraid-autostart',
'docker-userprefs': '/boot/config/plugins/dockerMan/userprefs.cfg',
'docker-socket': '/var/run/docker.sock',
'parity-checks': expect.stringContaining('api/dev/states/parity-checks.log'),
htpasswd: '/etc/nginx/htpasswd',

View File

@@ -0,0 +1,234 @@
import { eq, gt, gte, lt, lte, parse } from 'semver';
import { describe, expect, it } from 'vitest';
import { compareVersions } from '@app/common/compare-semver-version.js';
describe('compareVersions', () => {
describe('basic comparisons', () => {
it('should return true when current version is greater than compared (gte)', () => {
const current = parse('7.3.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should return true when current version equals compared (gte)', () => {
const current = parse('7.2.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should return false when current version is less than compared (gte)', () => {
const current = parse('7.1.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(false);
});
it('should return true when current version is less than compared (lte)', () => {
const current = parse('7.1.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, lte)).toBe(true);
});
it('should return true when current version equals compared (lte)', () => {
const current = parse('7.2.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, lte)).toBe(true);
});
it('should return false when current version is greater than compared (lte)', () => {
const current = parse('7.3.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, lte)).toBe(false);
});
it('should return true when current version is greater than compared (gt)', () => {
const current = parse('7.3.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gt)).toBe(true);
});
it('should return false when current version equals compared (gt)', () => {
const current = parse('7.2.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gt)).toBe(false);
});
it('should return true when current version is less than compared (lt)', () => {
const current = parse('7.1.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, lt)).toBe(true);
});
it('should return false when current version equals compared (lt)', () => {
const current = parse('7.2.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, lt)).toBe(false);
});
it('should return true when versions are equal (eq)', () => {
const current = parse('7.2.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, eq)).toBe(true);
});
it('should return false when versions are not equal (eq)', () => {
const current = parse('7.3.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, eq)).toBe(false);
});
});
describe('prerelease handling - current has prerelease, compared is stable', () => {
it('should return true for gte when current prerelease > stable (same base)', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should return true for gt when current prerelease > stable (same base)', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gt)).toBe(true);
});
it('should return false for lte when current prerelease < stable (same base)', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, lte)).toBe(false);
});
it('should return false for lt when current prerelease < stable (same base)', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, lt)).toBe(false);
});
it('should return false for eq when current prerelease != stable (same base)', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, eq)).toBe(false);
});
});
describe('prerelease handling - current is stable, compared has prerelease', () => {
it('should use normal comparison when current is stable and compared has prerelease', () => {
const current = parse('7.2.0')!;
const compared = parse('7.2.0-beta.1')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should use normal comparison for lte when current is stable and compared has prerelease', () => {
const current = parse('7.2.0')!;
const compared = parse('7.2.0-beta.1')!;
expect(compareVersions(current, compared, lte)).toBe(false);
});
});
describe('prerelease handling - both have prerelease', () => {
it('should use normal comparison when both versions have prerelease', () => {
const current = parse('7.2.0-beta.2')!;
const compared = parse('7.2.0-beta.1')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should use normal comparison for lte when both have prerelease', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0-beta.2')!;
expect(compareVersions(current, compared, lte)).toBe(true);
});
it('should use normal comparison when prerelease versions are equal', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0-beta.1')!;
expect(compareVersions(current, compared, eq)).toBe(true);
});
});
describe('prerelease handling - different base versions', () => {
it('should use normal comparison when base versions differ (current prerelease)', () => {
const current = parse('7.3.0-beta.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should use normal comparison when base versions differ (current prerelease, less)', () => {
const current = parse('7.1.0-beta.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(false);
});
});
describe('includePrerelease flag', () => {
it('should apply special prerelease handling when includePrerelease is true', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte, { includePrerelease: true })).toBe(true);
});
it('should skip special prerelease handling when includePrerelease is false', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte, { includePrerelease: false })).toBe(false);
});
it('should default to includePrerelease true', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
});
describe('edge cases', () => {
it('should handle patch version differences', () => {
const current = parse('7.2.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should handle minor version differences', () => {
const current = parse('7.3.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should handle major version differences', () => {
const current = parse('8.0.0')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should handle complex prerelease tags', () => {
const current = parse('7.2.0-beta.2.4')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should handle alpha prerelease tags', () => {
const current = parse('7.2.0-alpha.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
it('should handle rc prerelease tags', () => {
const current = parse('7.2.0-rc.1')!;
const compared = parse('7.2.0')!;
expect(compareVersions(current, compared, gte)).toBe(true);
});
});
describe('comparison function edge cases', () => {
it('should handle custom comparison functions that are not gte/lte/gt/lt', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0')!;
const customCompare = (a: typeof current, b: typeof compared) => a.compare(b) === 1;
expect(compareVersions(current, compared, customCompare)).toBe(false);
});
it('should fall through to normal comparison for unknown functions with prerelease', () => {
const current = parse('7.2.0-beta.1')!;
const compared = parse('7.2.0')!;
const customCompare = () => false;
expect(compareVersions(current, compared, customCompare)).toBe(false);
});
});
});

View File

@@ -0,0 +1,44 @@
import type { SemVer } from 'semver';
import { gt, gte, lt, lte } from 'semver';
/**
* Shared version comparison logic with special handling for prerelease versions.
*
* When base versions are equal and current version has a prerelease tag while compared doesn't:
* - For gte/gt: prerelease is considered greater than stable (returns true)
* - For lte/lt: prerelease is considered less than stable (returns false)
* - For eq: prerelease is not equal to stable (returns false)
*
* @param currentVersion - The current Unraid version (SemVer object)
* @param comparedVersion - The version to compare against (SemVer object)
* @param compareFn - The comparison function (e.g., gte, lte, lt, gt, eq)
* @param includePrerelease - Whether to include special prerelease handling
* @returns The result of the comparison
*/
export const compareVersions = (
currentVersion: SemVer,
comparedVersion: SemVer,
compareFn: (a: SemVer, b: SemVer) => boolean,
{ includePrerelease = true }: { includePrerelease?: boolean } = {}
): boolean => {
if (includePrerelease) {
const baseCurrent = `${currentVersion.major}.${currentVersion.minor}.${currentVersion.patch}`;
const baseCompared = `${comparedVersion.major}.${comparedVersion.minor}.${comparedVersion.patch}`;
if (baseCurrent === baseCompared) {
const currentHasPrerelease = currentVersion.prerelease.length > 0;
const comparedHasPrerelease = comparedVersion.prerelease.length > 0;
if (currentHasPrerelease && !comparedHasPrerelease) {
if (compareFn === gte || compareFn === gt) {
return true;
}
if (compareFn === lte || compareFn === lt) {
return false;
}
}
}
}
return compareFn(currentVersion, comparedVersion);
};

View File

@@ -0,0 +1,60 @@
import type { SemVer } from 'semver';
import { coerce } from 'semver';
import { compareVersions } from '@app/common/compare-semver-version.js';
import { fileExistsSync } from '@app/core/utils/files/file-exists.js';
import { parseConfig } from '@app/core/utils/misc/parse-config.js';
type UnraidVersionIni = {
version?: string;
};
/**
* Synchronously reads the Unraid version from /etc/unraid-version
* @returns The Unraid version string, or 'unknown' if the file cannot be read
*/
export const getUnraidVersionSync = (): string => {
const versionPath = '/etc/unraid-version';
if (!fileExistsSync(versionPath)) {
return 'unknown';
}
try {
const versionIni = parseConfig<UnraidVersionIni>({ filePath: versionPath, type: 'ini' });
return versionIni.version || 'unknown';
} catch {
return 'unknown';
}
};
/**
* Compares the Unraid version against a specified version using a comparison function
* @param compareFn - The comparison function from semver (e.g., lt, gte, lte, gt, eq)
* @param version - The version to compare against (e.g., '7.3.0')
* @param options - Options for the comparison
* @returns The result of the comparison, or false if the version cannot be determined
*/
export const compareUnraidVersionSync = (
compareFn: (a: SemVer, b: SemVer) => boolean,
version: string,
{ includePrerelease = true }: { includePrerelease?: boolean } = {}
): boolean => {
const currentVersion = getUnraidVersionSync();
if (currentVersion === 'unknown') {
return false;
}
try {
const current = coerce(currentVersion, { includePrerelease });
const compared = coerce(version, { includePrerelease });
if (!current || !compared) {
return false;
}
return compareVersions(current, compared, compareFn, { includePrerelease });
} catch {
return false;
}
};

View File

@@ -1,7 +1,7 @@
import pino from 'pino';
import pretty from 'pino-pretty';
import { API_VERSION, LOG_LEVEL, LOG_TYPE, SUPPRESS_LOGS } from '@app/environment.js';
import { API_VERSION, LOG_LEVEL, LOG_TYPE, PATHS_LOGS_FILE, SUPPRESS_LOGS } from '@app/environment.js';
export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
@@ -15,18 +15,24 @@ const nullDestination = pino.destination({
},
});
const LOG_TRANSPORT = process.env.LOG_TRANSPORT ?? 'file';
const useConsole = LOG_TRANSPORT === 'console';
export const logDestination =
process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination();
// Since PM2 captures stdout and writes to the log file, we should not colorize stdout
// to avoid ANSI escape codes in the log file
process.env.SUPPRESS_LOGS === 'true'
? nullDestination
: useConsole
? pino.destination(1) // stdout
: pino.destination({ dest: PATHS_LOGS_FILE, mkdir: true });
const stream = SUPPRESS_LOGS
? nullDestination
: LOG_TYPE === 'pretty'
? pretty({
singleLine: true,
hideObject: false,
colorize: false, // No colors since PM2 writes stdout to file
colorizeObjects: false,
colorize: useConsole, // Enable colors when outputting to console
colorizeObjects: useConsole,
levelFirst: false,
ignore: 'hostname,pid',
destination: logDestination,
@@ -34,10 +40,10 @@ const stream = SUPPRESS_LOGS
customPrettifiers: {
time: (timestamp: string | object) => `[${timestamp}`,
level: (_logLevel: string | object, _key: string, log: any, extras: any) => {
// Use label instead of labelColorized for non-colored output
const { label } = extras;
const { label, labelColorized } = extras;
const context = log.context || log.logger || 'app';
return `${label} ${context}]`;
// Use colorized label when outputting to console
return `${useConsole ? labelColorized : label} ${context}]`;
},
},
messageFormat: (log: any, messageKey: string) => {

View File

@@ -2,7 +2,7 @@ import { AppError } from '@app/core/errors/app-error.js';
import { getters } from '@app/store/index.js';
interface DockerError extends NodeJS.ErrnoException {
address: string;
address?: string;
}
/**

View File

@@ -0,0 +1,19 @@
import { getters } from '@app/store/index.js';
/**
* Returns the LAN IPv4 address reported by emhttp, if available.
*/
export function getLanIp(): string {
const emhttp = getters.emhttp();
const lanFromNetworks = emhttp?.networks?.[0]?.ipaddr?.[0];
if (lanFromNetworks) {
return lanFromNetworks;
}
const lanFromNginx = emhttp?.nginx?.lanIp;
if (lanFromNginx) {
return lanFromNginx;
}
return '';
}

View File

@@ -111,5 +111,10 @@ export const PATHS_CONFIG_MODULES =
export const PATHS_LOCAL_SESSION_FILE =
process.env.PATHS_LOCAL_SESSION_FILE ?? '/var/run/unraid-api/local-session';
export const PATHS_DOCKER_TEMPLATES = process.env.PATHS_DOCKER_TEMPLATES?.split(',') ?? [
'/boot/config/plugins/dockerMan/templates-user',
'/boot/config/plugins/dockerMan/templates',
];
/** feature flag for the upcoming docker release */
export const ENABLE_NEXT_DOCKER_RELEASE = process.env.ENABLE_NEXT_DOCKER_RELEASE === 'true';

View File

@@ -20,6 +20,7 @@ const initialState = {
process.env.PATHS_UNRAID_DATA ?? ('/boot/config/plugins/dynamix.my.servers/data/' as const)
),
'docker-autostart': '/var/lib/docker/unraid-autostart' as const,
'docker-userprefs': '/boot/config/plugins/dockerMan/userprefs.cfg' as const,
'docker-socket': '/var/run/docker.sock' as const,
'rclone-socket': resolvePath(process.env.PATHS_RCLONE_SOCKET ?? ('/var/run/rclone.socket' as const)),
'parity-checks': resolvePath(

View File

@@ -6,102 +6,60 @@ import { AuthZGuard } from 'nest-authz';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { loadDynamixConfig, store } from '@app/store/index.js';
import { loadStateFiles } from '@app/store/modules/emhttp.js';
import { AppModule } from '@app/unraid-api/app/app.module.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { AuthenticationGuard } from '@app/unraid-api/auth/authentication.guard.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
// Mock external system boundaries that we can't control in tests
vi.mock('dockerode', () => {
return {
default: vi.fn().mockImplementation(() => ({
listContainers: vi.fn().mockResolvedValue([
{
Id: 'test-container-1',
Names: ['/test-container'],
State: 'running',
Status: 'Up 5 minutes',
Image: 'test:latest',
Command: 'node server.js',
Created: Date.now() / 1000,
Ports: [
{
IP: '0.0.0.0',
PrivatePort: 3000,
PublicPort: 3000,
Type: 'tcp',
},
],
Labels: {},
HostConfig: {
NetworkMode: 'bridge',
},
NetworkSettings: {
Networks: {},
},
Mounts: [],
// Mock the store before importing it
vi.mock('@app/store/index.js', () => ({
store: {
dispatch: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn().mockImplementation(() => vi.fn()),
getState: vi.fn().mockReturnValue({
emhttp: {
var: {
csrfToken: 'test-csrf-token',
},
]),
getContainer: vi.fn().mockImplementation((id) => ({
inspect: vi.fn().mockResolvedValue({
Id: id,
Name: '/test-container',
State: { Running: true },
Config: { Image: 'test:latest' },
}),
})),
listImages: vi.fn().mockResolvedValue([]),
listNetworks: vi.fn().mockResolvedValue([]),
listVolumes: vi.fn().mockResolvedValue({ Volumes: [] }),
})),
};
});
// Mock external command execution
vi.mock('execa', () => ({
execa: vi.fn().mockImplementation((cmd) => {
if (cmd === 'whoami') {
return Promise.resolve({ stdout: 'testuser' });
}
return Promise.resolve({ stdout: 'mocked output' });
}),
},
docker: {
containers: [],
autostart: [],
},
}),
unsubscribe: vi.fn(),
},
getters: {
emhttp: vi.fn().mockReturnValue({
var: {
csrfToken: 'test-csrf-token',
},
}),
docker: vi.fn().mockReturnValue({
containers: [],
autostart: [],
}),
paths: vi.fn().mockReturnValue({
'docker-autostart': '/tmp/docker-autostart',
'docker-socket': '/var/run/docker.sock',
'var-run': '/var/run',
'auth-keys': '/tmp/auth-keys',
activationBase: '/tmp/activation',
'dynamix-config': ['/tmp/dynamix-config', '/tmp/dynamix-config'],
identConfig: '/tmp/ident.cfg',
}),
dynamix: vi.fn().mockReturnValue({
notify: {
path: '/tmp/notifications',
},
}),
},
loadDynamixConfig: vi.fn(),
loadStateFiles: vi.fn().mockResolvedValue(undefined),
}));
// Mock child_process for services that spawn processes
vi.mock('node:child_process', () => ({
spawn: vi.fn(() => ({
on: vi.fn(),
kill: vi.fn(),
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
})),
}));
// Mock file system operations that would fail in test environment
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('fs/promises')>();
return {
...actual,
readFile: vi.fn().mockResolvedValue(''),
writeFile: vi.fn().mockResolvedValue(undefined),
mkdir: vi.fn().mockResolvedValue(undefined),
access: vi.fn().mockResolvedValue(undefined),
stat: vi.fn().mockResolvedValue({ isFile: () => true }),
readdir: vi.fn().mockResolvedValue([]),
rename: vi.fn().mockResolvedValue(undefined),
unlink: vi.fn().mockResolvedValue(undefined),
};
});
// Mock fs module for synchronous operations
vi.mock('node:fs', () => ({
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn().mockReturnValue(''),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
readdirSync: vi.fn().mockReturnValue([]),
// Mock fs-extra for directory operations
vi.mock('fs-extra', () => ({
ensureDirSync: vi.fn().mockReturnValue(undefined),
}));
describe('AppModule Integration Tests', () => {
@@ -109,14 +67,6 @@ describe('AppModule Integration Tests', () => {
let moduleRef: TestingModule;
beforeAll(async () => {
// Initialize the dynamix config and state files before creating the module
await store.dispatch(loadStateFiles());
loadDynamixConfig();
// Debug: Log the CSRF token from the store
const { getters } = await import('@app/store/index.js');
console.log('CSRF Token from store:', getters.emhttp().var.csrfToken);
moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
@@ -149,14 +99,6 @@ describe('AppModule Integration Tests', () => {
roles: ['admin'],
}),
})
// Override Redis client
.overrideProvider('REDIS_CLIENT')
.useValue({
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
connect: vi.fn(),
})
.compile();
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());
@@ -177,9 +119,9 @@ describe('AppModule Integration Tests', () => {
});
it('should resolve core services', () => {
const dockerService = moduleRef.get(DockerService);
const authService = moduleRef.get(AuthService);
expect(dockerService).toBeDefined();
expect(authService).toBeDefined();
});
});
@@ -238,18 +180,12 @@ describe('AppModule Integration Tests', () => {
});
describe('Service Integration', () => {
it('should have working service-to-service communication', async () => {
const dockerService = moduleRef.get(DockerService);
// Test that the service can be called and returns expected data structure
const containers = await dockerService.getContainers();
expect(containers).toBeInstanceOf(Array);
// The containers might be empty or cached, just verify structure
if (containers.length > 0) {
expect(containers[0]).toHaveProperty('id');
expect(containers[0]).toHaveProperty('names');
}
it('should have working service-to-service communication', () => {
// Test that the module can resolve its services without errors
// This validates that dependency injection is working correctly
const authService = moduleRef.get(AuthService);
expect(authService).toBeDefined();
expect(typeof authService.validateCookiesWithCsrfToken).toBe('function');
});
});
});

View File

@@ -183,6 +183,11 @@ export class ApiKeyService implements OnModuleInit {
async loadAllFromDisk(): Promise<ApiKey[]> {
const files = await readdir(this.basePath).catch((error) => {
if (error.code === 'ENOENT') {
// Directory doesn't exist, which means no API keys have been created yet
this.logger.error(`API key directory does not exist: ${this.basePath}`);
return [];
}
this.logger.error(`Failed to read API key directory: ${error}`);
throw new Error('Failed to list API keys');
});

View File

@@ -525,6 +525,7 @@ export enum ContainerPortType {
export enum ContainerState {
EXITED = 'EXITED',
PAUSED = 'PAUSED',
RUNNING = 'RUNNING'
}
@@ -678,11 +679,20 @@ export enum DiskSmartStatus {
export type Docker = Node & {
__typename?: 'Docker';
container?: Maybe<DockerContainer>;
containerUpdateStatuses: Array<ExplicitStatusItem>;
containers: Array<DockerContainer>;
id: Scalars['PrefixedID']['output'];
/** Access container logs. Requires specifying a target container id through resolver arguments. */
logs: DockerContainerLogs;
networks: Array<DockerNetwork>;
organizer: ResolvedOrganizerV1;
portConflicts: DockerPortConflicts;
};
export type DockerContainerArgs = {
id: Scalars['PrefixedID']['input'];
};
@@ -691,38 +701,169 @@ export type DockerContainersArgs = {
};
export type DockerLogsArgs = {
id: Scalars['PrefixedID']['input'];
since?: InputMaybe<Scalars['DateTime']['input']>;
tail?: InputMaybe<Scalars['Int']['input']>;
};
export type DockerNetworksArgs = {
skipCache?: Scalars['Boolean']['input'];
};
export type DockerOrganizerArgs = {
skipCache?: Scalars['Boolean']['input'];
};
export type DockerPortConflictsArgs = {
skipCache?: Scalars['Boolean']['input'];
};
export type DockerAutostartEntryInput = {
/** Whether the container should auto-start */
autoStart: Scalars['Boolean']['input'];
/** Docker container identifier */
id: Scalars['PrefixedID']['input'];
/** Number of seconds to wait after starting the container */
wait?: InputMaybe<Scalars['Int']['input']>;
};
export type DockerContainer = Node & {
__typename?: 'DockerContainer';
autoStart: Scalars['Boolean']['output'];
/** Zero-based order in the auto-start list */
autoStartOrder?: Maybe<Scalars['Int']['output']>;
/** Wait time in seconds applied after start */
autoStartWait?: Maybe<Scalars['Int']['output']>;
command: Scalars['String']['output'];
created: Scalars['Int']['output'];
hostConfig?: Maybe<ContainerHostConfig>;
/** Icon URL */
iconUrl?: Maybe<Scalars['String']['output']>;
id: Scalars['PrefixedID']['output'];
image: Scalars['String']['output'];
imageId: Scalars['String']['output'];
/** Whether the container is orphaned (no template found) */
isOrphaned: Scalars['Boolean']['output'];
isRebuildReady?: Maybe<Scalars['Boolean']['output']>;
isUpdateAvailable?: Maybe<Scalars['Boolean']['output']>;
labels?: Maybe<Scalars['JSON']['output']>;
/** List of LAN-accessible host:port values */
lanIpPorts?: Maybe<Array<Scalars['String']['output']>>;
mounts?: Maybe<Array<Scalars['JSON']['output']>>;
names: Array<Scalars['String']['output']>;
networkSettings?: Maybe<Scalars['JSON']['output']>;
ports: Array<ContainerPort>;
/** Project/Product homepage URL */
projectUrl?: Maybe<Scalars['String']['output']>;
/** Registry/Docker Hub URL */
registryUrl?: Maybe<Scalars['String']['output']>;
/** Shell to use for console access (from template) */
shell?: Maybe<Scalars['String']['output']>;
/** Size of container logs (in bytes) */
sizeLog?: Maybe<Scalars['BigInt']['output']>;
/** Total size of all files in the container (in bytes) */
sizeRootFs?: Maybe<Scalars['BigInt']['output']>;
/** Size of writable layer (in bytes) */
sizeRw?: Maybe<Scalars['BigInt']['output']>;
state: ContainerState;
status: Scalars['String']['output'];
/** Support page/thread URL */
supportUrl?: Maybe<Scalars['String']['output']>;
/** Whether Tailscale is enabled for this container */
tailscaleEnabled: Scalars['Boolean']['output'];
/** Tailscale status for this container (fetched via docker exec) */
tailscaleStatus?: Maybe<TailscaleStatus>;
templatePath?: Maybe<Scalars['String']['output']>;
/** Port mappings from template (used when container is not running) */
templatePorts?: Maybe<Array<ContainerPort>>;
/** Resolved WebUI URL from template */
webUiUrl?: Maybe<Scalars['String']['output']>;
};
export type DockerContainerTailscaleStatusArgs = {
forceRefresh?: InputMaybe<Scalars['Boolean']['input']>;
};
export type DockerContainerLogLine = {
__typename?: 'DockerContainerLogLine';
message: Scalars['String']['output'];
timestamp: Scalars['DateTime']['output'];
};
export type DockerContainerLogs = {
__typename?: 'DockerContainerLogs';
containerId: Scalars['PrefixedID']['output'];
/** Cursor that can be passed back through the since argument to continue streaming logs. */
cursor?: Maybe<Scalars['DateTime']['output']>;
lines: Array<DockerContainerLogLine>;
};
export type DockerContainerPortConflict = {
__typename?: 'DockerContainerPortConflict';
containers: Array<DockerPortConflictContainer>;
privatePort: Scalars['Port']['output'];
type: ContainerPortType;
};
export type DockerContainerStats = {
__typename?: 'DockerContainerStats';
/** Block I/O String (e.g. 100MB / 1GB) */
blockIO: Scalars['String']['output'];
/** CPU Usage Percentage */
cpuPercent: Scalars['Float']['output'];
id: Scalars['PrefixedID']['output'];
/** Memory Usage Percentage */
memPercent: Scalars['Float']['output'];
/** Memory Usage String (e.g. 100MB / 1GB) */
memUsage: Scalars['String']['output'];
/** Network I/O String (e.g. 100MB / 1GB) */
netIO: Scalars['String']['output'];
};
export type DockerLanPortConflict = {
__typename?: 'DockerLanPortConflict';
containers: Array<DockerPortConflictContainer>;
lanIpPort: Scalars['String']['output'];
publicPort?: Maybe<Scalars['Port']['output']>;
type: ContainerPortType;
};
export type DockerMutations = {
__typename?: 'DockerMutations';
/** Pause (Suspend) a container */
pause: DockerContainer;
/** Remove a container */
removeContainer: Scalars['Boolean']['output'];
/** Start a container */
start: DockerContainer;
/** Stop a container */
stop: DockerContainer;
/** Unpause (Resume) a container */
unpause: DockerContainer;
/** Update all containers that have available updates */
updateAllContainers: Array<DockerContainer>;
/** Update auto-start configuration for Docker containers */
updateAutostartConfiguration: Scalars['Boolean']['output'];
/** Update a container to the latest image */
updateContainer: DockerContainer;
/** Update multiple containers to the latest images */
updateContainers: Array<DockerContainer>;
};
export type DockerMutationsPauseArgs = {
id: Scalars['PrefixedID']['input'];
};
export type DockerMutationsRemoveContainerArgs = {
id: Scalars['PrefixedID']['input'];
withImage?: InputMaybe<Scalars['Boolean']['input']>;
};
@@ -735,6 +876,27 @@ export type DockerMutationsStopArgs = {
id: Scalars['PrefixedID']['input'];
};
export type DockerMutationsUnpauseArgs = {
id: Scalars['PrefixedID']['input'];
};
export type DockerMutationsUpdateAutostartConfigurationArgs = {
entries: Array<DockerAutostartEntryInput>;
persistUserPreferences?: InputMaybe<Scalars['Boolean']['input']>;
};
export type DockerMutationsUpdateContainerArgs = {
id: Scalars['PrefixedID']['input'];
};
export type DockerMutationsUpdateContainersArgs = {
ids: Array<Scalars['PrefixedID']['input']>;
};
export type DockerNetwork = Node & {
__typename?: 'DockerNetwork';
attachable: Scalars['Boolean']['output'];
@@ -754,6 +916,26 @@ export type DockerNetwork = Node & {
scope: Scalars['String']['output'];
};
export type DockerPortConflictContainer = {
__typename?: 'DockerPortConflictContainer';
id: Scalars['PrefixedID']['output'];
name: Scalars['String']['output'];
};
export type DockerPortConflicts = {
__typename?: 'DockerPortConflicts';
containerPorts: Array<DockerContainerPortConflict>;
lanPorts: Array<DockerLanPortConflict>;
};
export type DockerTemplateSyncResult = {
__typename?: 'DockerTemplateSyncResult';
errors: Array<Scalars['String']['output']>;
matched: Scalars['Int']['output'];
scanned: Scalars['Int']['output'];
skipped: Scalars['Int']['output'];
};
export type DynamicRemoteAccessStatus = {
__typename?: 'DynamicRemoteAccessStatus';
/** The type of dynamic remote access that is enabled */
@@ -799,6 +981,20 @@ export type FlashBackupStatus = {
status: Scalars['String']['output'];
};
export type FlatOrganizerEntry = {
__typename?: 'FlatOrganizerEntry';
childrenIds: Array<Scalars['String']['output']>;
depth: Scalars['Float']['output'];
hasChildren: Scalars['Boolean']['output'];
id: Scalars['String']['output'];
meta?: Maybe<DockerContainer>;
name: Scalars['String']['output'];
parentId?: Maybe<Scalars['String']['output']>;
path: Array<Scalars['String']['output']>;
position: Scalars['Float']['output'];
type: Scalars['String']['output'];
};
export type FormSchema = {
/** The data schema for the form */
dataSchema: Scalars['JSON']['output'];
@@ -1223,6 +1419,7 @@ export type Mutation = {
connectSignIn: Scalars['Boolean']['output'];
connectSignOut: Scalars['Boolean']['output'];
createDockerFolder: ResolvedOrganizerV1;
createDockerFolderWithItems: ResolvedOrganizerV1;
/** Creates a new notification record */
createNotification: Notification;
/** Deletes all archived notifications on server. */
@@ -1234,6 +1431,9 @@ export type Mutation = {
/** Initiates a flash drive backup using a configured remote. */
initiateFlashBackup: FlashBackupStatus;
moveDockerEntriesToFolder: ResolvedOrganizerV1;
moveDockerItemsToPosition: ResolvedOrganizerV1;
/** Creates a notification if an equivalent unread notification does not already exist. */
notifyIfUnique?: Maybe<Notification>;
parityCheck: ParityCheckMutations;
rclone: RCloneMutations;
/** Reads each notification to recompute & update the overview. */
@@ -1241,13 +1441,18 @@ export type Mutation = {
refreshDockerDigests: Scalars['Boolean']['output'];
/** Remove one or more plugins from the API. Returns false if restart was triggered automatically, true if manual restart is required. */
removePlugin: Scalars['Boolean']['output'];
renameDockerFolder: ResolvedOrganizerV1;
/** Reset Docker template mappings to defaults. Use this to recover from corrupted state. */
resetDockerTemplateMappings: Scalars['Boolean']['output'];
setDockerFolderChildren: ResolvedOrganizerV1;
setupRemoteAccess: Scalars['Boolean']['output'];
syncDockerTemplatePaths: DockerTemplateSyncResult;
unarchiveAll: NotificationOverview;
unarchiveNotifications: NotificationOverview;
/** Marks a notification as unread. */
unreadNotification: Notification;
updateApiSettings: ConnectSettingsValues;
updateDockerViewPreferences: ResolvedOrganizerV1;
updateSettings: UpdateSettingsResponse;
vm: VmMutations;
};
@@ -1290,6 +1495,14 @@ export type MutationCreateDockerFolderArgs = {
};
export type MutationCreateDockerFolderWithItemsArgs = {
name: Scalars['String']['input'];
parentId?: InputMaybe<Scalars['String']['input']>;
position?: InputMaybe<Scalars['Float']['input']>;
sourceEntryIds?: InputMaybe<Array<Scalars['String']['input']>>;
};
export type MutationCreateNotificationArgs = {
input: NotificationData;
};
@@ -1322,11 +1535,29 @@ export type MutationMoveDockerEntriesToFolderArgs = {
};
export type MutationMoveDockerItemsToPositionArgs = {
destinationFolderId: Scalars['String']['input'];
position: Scalars['Float']['input'];
sourceEntryIds: Array<Scalars['String']['input']>;
};
export type MutationNotifyIfUniqueArgs = {
input: NotificationData;
};
export type MutationRemovePluginArgs = {
input: PluginManagementInput;
};
export type MutationRenameDockerFolderArgs = {
folderId: Scalars['String']['input'];
newName: Scalars['String']['input'];
};
export type MutationSetDockerFolderChildrenArgs = {
childrenIds: Array<Scalars['String']['input']>;
folderId?: InputMaybe<Scalars['String']['input']>;
@@ -1358,6 +1589,12 @@ export type MutationUpdateApiSettingsArgs = {
};
export type MutationUpdateDockerViewPreferencesArgs = {
prefs: Scalars['JSON']['input'];
viewId?: InputMaybe<Scalars['String']['input']>;
};
export type MutationUpdateSettingsArgs = {
input: Scalars['JSON']['input'];
};
@@ -1433,6 +1670,8 @@ export type Notifications = Node & {
list: Array<Notification>;
/** A cached overview of the notifications in the system & their severity. */
overview: NotificationOverview;
/** Deduplicated list of unread warning and alert notifications, sorted latest first. */
warningsAndAlerts: Array<Notification>;
};
@@ -1498,22 +1737,6 @@ export type OidcSessionValidation = {
valid: Scalars['Boolean']['output'];
};
export type OrganizerContainerResource = {
__typename?: 'OrganizerContainerResource';
id: Scalars['String']['output'];
meta?: Maybe<DockerContainer>;
name: Scalars['String']['output'];
type: Scalars['String']['output'];
};
export type OrganizerResource = {
__typename?: 'OrganizerResource';
id: Scalars['String']['output'];
meta?: Maybe<Scalars['JSON']['output']>;
name: Scalars['String']['output'];
type: Scalars['String']['output'];
};
export type Owner = {
__typename?: 'Owner';
avatar: Scalars['String']['output'];
@@ -1882,16 +2105,6 @@ export type RemoveRoleFromApiKeyInput = {
role: Role;
};
export type ResolvedOrganizerEntry = OrganizerContainerResource | OrganizerResource | ResolvedOrganizerFolder;
export type ResolvedOrganizerFolder = {
__typename?: 'ResolvedOrganizerFolder';
children: Array<ResolvedOrganizerEntry>;
id: Scalars['String']['output'];
name: Scalars['String']['output'];
type: Scalars['String']['output'];
};
export type ResolvedOrganizerV1 = {
__typename?: 'ResolvedOrganizerV1';
version: Scalars['Float']['output'];
@@ -1900,10 +2113,11 @@ export type ResolvedOrganizerV1 = {
export type ResolvedOrganizerView = {
__typename?: 'ResolvedOrganizerView';
flatEntries: Array<FlatOrganizerEntry>;
id: Scalars['String']['output'];
name: Scalars['String']['output'];
prefs?: Maybe<Scalars['JSON']['output']>;
root: ResolvedOrganizerEntry;
rootId: Scalars['String']['output'];
};
/** Available resources for permissions */
@@ -2046,9 +2260,11 @@ export type SsoSettings = Node & {
export type Subscription = {
__typename?: 'Subscription';
arraySubscription: UnraidArray;
dockerContainerStats: DockerContainerStats;
logFile: LogFileContent;
notificationAdded: Notification;
notificationsOverview: NotificationOverview;
notificationsWarningsAndAlerts: Array<Notification>;
ownerSubscription: Owner;
parityHistorySubscription: ParityCheck;
serversSubscription: Server;
@@ -2062,6 +2278,56 @@ export type SubscriptionLogFileArgs = {
path: Scalars['String']['input'];
};
/** Tailscale exit node connection status */
export type TailscaleExitNodeStatus = {
__typename?: 'TailscaleExitNodeStatus';
/** Whether the exit node is online */
online: Scalars['Boolean']['output'];
/** Tailscale IPs of the exit node */
tailscaleIps?: Maybe<Array<Scalars['String']['output']>>;
};
/** Tailscale status for a Docker container */
export type TailscaleStatus = {
__typename?: 'TailscaleStatus';
/** Authentication URL if Tailscale needs login */
authUrl?: Maybe<Scalars['String']['output']>;
/** Tailscale backend state (Running, NeedsLogin, Stopped, etc.) */
backendState?: Maybe<Scalars['String']['output']>;
/** Actual Tailscale DNS name */
dnsName?: Maybe<Scalars['String']['output']>;
/** Status of the connected exit node (if using one) */
exitNodeStatus?: Maybe<TailscaleExitNodeStatus>;
/** Configured Tailscale hostname */
hostname?: Maybe<Scalars['String']['output']>;
/** Whether this container is an exit node */
isExitNode: Scalars['Boolean']['output'];
/** Whether the Tailscale key has expired */
keyExpired: Scalars['Boolean']['output'];
/** Tailscale key expiry date */
keyExpiry?: Maybe<Scalars['DateTime']['output']>;
/** Days until key expires */
keyExpiryDays?: Maybe<Scalars['Int']['output']>;
/** Latest available Tailscale version */
latestVersion?: Maybe<Scalars['String']['output']>;
/** Whether Tailscale is online in the container */
online: Scalars['Boolean']['output'];
/** Advertised subnet routes */
primaryRoutes?: Maybe<Array<Scalars['String']['output']>>;
/** DERP relay code */
relay?: Maybe<Scalars['String']['output']>;
/** DERP relay region name */
relayName?: Maybe<Scalars['String']['output']>;
/** Tailscale IPv4 and IPv6 addresses */
tailscaleIps?: Maybe<Array<Scalars['String']['output']>>;
/** Whether a Tailscale update is available */
updateAvailable: Scalars['Boolean']['output'];
/** Current Tailscale version */
version?: Maybe<Scalars['String']['output']>;
/** Tailscale Serve/Funnel WebUI URL */
webUiUrl?: Maybe<Scalars['String']['output']>;
};
/** Temperature unit */
export enum Temperature {
CELSIUS = 'CELSIUS',

View File

@@ -7,7 +7,7 @@ import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/dock
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
@Injectable()
export class ContainerStatusJob implements OnApplicationBootstrap {
export class ContainerStatusJob {
private readonly logger = new Logger(ContainerStatusJob.name);
constructor(
private readonly dockerManifestService: DockerManifestService,
@@ -17,8 +17,10 @@ export class ContainerStatusJob implements OnApplicationBootstrap {
/**
* Initialize cron job for refreshing the update status for all containers on a user-configurable schedule.
*
* Disabled for now to avoid duplication of the webgui's update notifier job (under Notification Settings).
*/
onApplicationBootstrap() {
_disabled_onApplicationBootstrap() {
if (!this.dockerConfigService.enabled()) return;
const cronExpression = this.dockerConfigService.getConfig().updateCheckCronSchedule;
const cronJob = CronJob.from({

View File

@@ -0,0 +1,144 @@
import { Test, TestingModule } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
AutoStartEntry,
DockerAutostartService,
} from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js';
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
// Mock store getters
const mockPaths = {
'docker-autostart': '/path/to/docker-autostart',
'docker-userprefs': '/path/to/docker-userprefs',
};
vi.mock('@app/store/index.js', () => ({
getters: {
paths: () => mockPaths,
},
}));
// Mock fs/promises
const { readFileMock, writeFileMock, unlinkMock } = vi.hoisted(() => ({
readFileMock: vi.fn().mockResolvedValue(''),
writeFileMock: vi.fn().mockResolvedValue(undefined),
unlinkMock: vi.fn().mockResolvedValue(undefined),
}));
vi.mock('fs/promises', () => ({
readFile: readFileMock,
writeFile: writeFileMock,
unlink: unlinkMock,
}));
describe('DockerAutostartService', () => {
let service: DockerAutostartService;
beforeEach(async () => {
readFileMock.mockReset();
writeFileMock.mockReset();
unlinkMock.mockReset();
readFileMock.mockResolvedValue('');
const module: TestingModule = await Test.createTestingModule({
providers: [DockerAutostartService],
}).compile();
service = module.get<DockerAutostartService>(DockerAutostartService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('should parse autostart entries correctly', () => {
const content = 'container1 10\ncontainer2\ncontainer3 0';
const entries = service.parseAutoStartEntries(content);
expect(entries).toHaveLength(3);
expect(entries[0]).toEqual({ name: 'container1', wait: 10, order: 0 });
expect(entries[1]).toEqual({ name: 'container2', wait: 0, order: 1 });
expect(entries[2]).toEqual({ name: 'container3', wait: 0, order: 2 });
});
it('should refresh autostart entries', async () => {
readFileMock.mockResolvedValue('alpha 5');
await service.refreshAutoStartEntries();
const entry = service.getAutoStartEntry('alpha');
expect(entry).toBeDefined();
expect(entry?.wait).toBe(5);
});
describe('updateAutostartConfiguration', () => {
const mockContainers = [
{ id: 'c1', names: ['/alpha'] },
{ id: 'c2', names: ['/beta'] },
] as DockerContainer[];
it('should update auto-start configuration and persist waits', async () => {
await service.updateAutostartConfiguration(
[
{ id: 'c1', autoStart: true, wait: 15 },
{ id: 'c2', autoStart: true, wait: 0 },
],
mockContainers,
{ persistUserPreferences: true }
);
expect(writeFileMock).toHaveBeenCalledWith(
mockPaths['docker-autostart'],
'alpha 15\nbeta\n',
'utf8'
);
expect(writeFileMock).toHaveBeenCalledWith(
mockPaths['docker-userprefs'],
'0="alpha"\n1="beta"\n',
'utf8'
);
});
it('should skip updating user preferences when persist flag is false', async () => {
await service.updateAutostartConfiguration(
[{ id: 'c1', autoStart: true, wait: 5 }],
mockContainers
);
expect(writeFileMock).toHaveBeenCalledWith(
mockPaths['docker-autostart'],
'alpha 5\n',
'utf8'
);
expect(writeFileMock).not.toHaveBeenCalledWith(
mockPaths['docker-userprefs'],
expect.any(String),
expect.any(String)
);
});
it('should remove auto-start file when no containers are configured', async () => {
await service.updateAutostartConfiguration(
[{ id: 'c1', autoStart: false, wait: 30 }],
mockContainers,
{ persistUserPreferences: true }
);
expect(unlinkMock).toHaveBeenCalledWith(mockPaths['docker-autostart']);
expect(writeFileMock).toHaveBeenCalledWith(
mockPaths['docker-userprefs'],
'0="alpha"\n',
'utf8'
);
});
});
it('should sanitize autostart wait values', () => {
expect(service.sanitizeAutoStartWait(null)).toBe(0);
expect(service.sanitizeAutoStartWait(undefined)).toBe(0);
expect(service.sanitizeAutoStartWait(10)).toBe(10);
expect(service.sanitizeAutoStartWait(-5)).toBe(0);
expect(service.sanitizeAutoStartWait(NaN)).toBe(0);
});
});

View File

@@ -0,0 +1,175 @@
import { Injectable, Logger } from '@nestjs/common';
import { readFile, unlink, writeFile } from 'fs/promises';
import Docker from 'dockerode';
import { getters } from '@app/store/index.js';
import {
DockerAutostartEntryInput,
DockerContainer,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
export interface AutoStartEntry {
name: string;
wait: number;
order: number;
}
@Injectable()
export class DockerAutostartService {
private readonly logger = new Logger(DockerAutostartService.name);
private autoStartEntries: AutoStartEntry[] = [];
private autoStartEntryByName = new Map<string, AutoStartEntry>();
public getAutoStartEntry(name: string): AutoStartEntry | undefined {
return this.autoStartEntryByName.get(name);
}
public setAutoStartEntries(entries: AutoStartEntry[]) {
this.autoStartEntries = entries;
this.autoStartEntryByName = new Map(entries.map((entry) => [entry.name, entry]));
}
public parseAutoStartEntries(rawContent: string): AutoStartEntry[] {
const lines = rawContent
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const seen = new Set<string>();
const entries: AutoStartEntry[] = [];
lines.forEach((line, index) => {
const [name, waitRaw] = line.split(/\s+/);
if (!name || seen.has(name)) {
return;
}
const parsedWait = Number.parseInt(waitRaw ?? '', 10);
const wait = Number.isFinite(parsedWait) && parsedWait > 0 ? parsedWait : 0;
entries.push({
name,
wait,
order: index,
});
seen.add(name);
});
return entries;
}
public async refreshAutoStartEntries(): Promise<void> {
const autoStartPath = getters.paths()['docker-autostart'];
const raw = await readFile(autoStartPath, 'utf8')
.then((file) => file.toString())
.catch(() => '');
const entries = this.parseAutoStartEntries(raw);
this.setAutoStartEntries(entries);
}
public sanitizeAutoStartWait(wait?: number | null): number {
if (wait === null || wait === undefined) return 0;
const coerced = Number.isInteger(wait) ? wait : Number.parseInt(String(wait), 10);
if (!Number.isFinite(coerced) || coerced < 0) {
return 0;
}
return coerced;
}
public getContainerPrimaryName(container: Docker.ContainerInfo | DockerContainer): string | null {
const names =
'Names' in container ? container.Names : 'names' in container ? container.names : undefined;
const firstName = names?.[0] ?? '';
return firstName ? firstName.replace(/^\//, '') : null;
}
private buildUserPreferenceLines(
entries: DockerAutostartEntryInput[],
containerById: Map<string, DockerContainer>
): string[] {
const seenNames = new Set<string>();
const lines: string[] = [];
for (const entry of entries) {
const container = containerById.get(entry.id);
if (!container) {
continue;
}
const primaryName = this.getContainerPrimaryName(container);
if (!primaryName || seenNames.has(primaryName)) {
continue;
}
lines.push(`${lines.length}="${primaryName}"`);
seenNames.add(primaryName);
}
return lines;
}
/**
* Docker auto start file
*
* @note Doesn't exist if array is offline.
* @see https://github.com/limetech/webgui/issues/502#issue-480992547
*/
public async getAutoStarts(): Promise<string[]> {
await this.refreshAutoStartEntries();
return this.autoStartEntries.map((entry) => entry.name);
}
public async updateAutostartConfiguration(
entries: DockerAutostartEntryInput[],
containers: DockerContainer[],
options?: { persistUserPreferences?: boolean }
): Promise<void> {
const containerById = new Map(containers.map((container) => [container.id, container]));
const paths = getters.paths();
const autoStartPath = paths['docker-autostart'];
const userPrefsPath = paths['docker-userprefs'];
const persistUserPreferences = Boolean(options?.persistUserPreferences);
const lines: string[] = [];
const seenNames = new Set<string>();
for (const entry of entries) {
if (!entry.autoStart) {
continue;
}
const container = containerById.get(entry.id);
if (!container) {
continue;
}
const primaryName = this.getContainerPrimaryName(container);
if (!primaryName || seenNames.has(primaryName)) {
continue;
}
const wait = this.sanitizeAutoStartWait(entry.wait);
lines.push(wait > 0 ? `${primaryName} ${wait}` : primaryName);
seenNames.add(primaryName);
}
if (lines.length) {
await writeFile(autoStartPath, `${lines.join('\n')}\n`, 'utf8');
} else {
await unlink(autoStartPath)?.catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') {
throw error;
}
});
}
if (persistUserPreferences) {
const userPrefsLines = this.buildUserPreferenceLines(entries, containerById);
if (userPrefsLines.length) {
await writeFile(userPrefsPath, `${userPrefsLines.join('\n')}\n`, 'utf8');
} else {
await unlink(userPrefsPath)?.catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') {
throw error;
}
});
}
}
await this.refreshAutoStartEntries();
}
}

View File

@@ -1,7 +1,22 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsArray, IsObject, IsOptional, IsString } from 'class-validator';
import { GraphQLJSON } from 'graphql-scalars';
@ObjectType()
export class DockerConfig {
@Field(() => String)
@IsString()
updateCheckCronSchedule!: string;
@Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
@IsObject()
templateMappings?: Record<string, string | null>;
@Field(() => [String], { nullable: true })
@IsOptional()
@IsArray()
@IsString({ each: true })
skipTemplatePaths?: string[];
}

View File

@@ -31,6 +31,8 @@ export class DockerConfigService extends ConfigFilePersister<DockerConfig> {
defaultConfig(): DockerConfig {
return {
updateCheckCronSchedule: CronExpression.EVERY_DAY_AT_6AM,
templateMappings: {},
skipTemplatePaths: [],
};
}
@@ -40,6 +42,7 @@ export class DockerConfigService extends ConfigFilePersister<DockerConfig> {
if (!cronExpression.valid) {
throw new AppError(`Cron expression not supported: ${dockerConfig.updateCheckCronSchedule}`);
}
return dockerConfig;
}
}

View File

@@ -1,18 +1,31 @@
import { Logger } from '@nestjs/common';
import { Mutation, Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Args, Mutation, Parent, ResolveField, Resolver } from '@nestjs/graphql';
import { Resource } from '@unraid/shared/graphql.model.js';
import { AuthAction, UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { AppError } from '@app/core/errors/app-error.js';
import { getLanIp } from '@app/core/utils/network.js';
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerTailscaleService } from '@app/unraid-api/graph/resolvers/docker/docker-tailscale.service.js';
import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js';
import {
ContainerPort,
ContainerPortType,
ContainerState,
DockerContainer,
TailscaleStatus,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
@Resolver(() => DockerContainer)
export class DockerContainerResolver {
private readonly logger = new Logger(DockerContainerResolver.name);
constructor(private readonly dockerManifestService: DockerManifestService) {}
constructor(
private readonly dockerManifestService: DockerManifestService,
private readonly dockerTemplateScannerService: DockerTemplateScannerService,
private readonly dockerTailscaleService: DockerTailscaleService
) {}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
@@ -39,6 +52,150 @@ export class DockerContainerResolver {
return this.dockerManifestService.isRebuildReady(container.hostConfig?.networkMode);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => String, { nullable: true })
public async projectUrl(@Parent() container: DockerContainer) {
if (!container.templatePath) return null;
const details = await this.dockerTemplateScannerService.getTemplateDetails(
container.templatePath
);
return details?.project || null;
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => String, { nullable: true })
public async registryUrl(@Parent() container: DockerContainer) {
if (!container.templatePath) return null;
const details = await this.dockerTemplateScannerService.getTemplateDetails(
container.templatePath
);
return details?.registry || null;
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => String, { nullable: true })
public async supportUrl(@Parent() container: DockerContainer) {
if (!container.templatePath) return null;
const details = await this.dockerTemplateScannerService.getTemplateDetails(
container.templatePath
);
return details?.support || null;
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => String, { nullable: true })
public async iconUrl(@Parent() container: DockerContainer) {
if (container.labels?.['net.unraid.docker.icon']) {
return container.labels['net.unraid.docker.icon'];
}
if (!container.templatePath) return null;
const details = await this.dockerTemplateScannerService.getTemplateDetails(
container.templatePath
);
return details?.icon || null;
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => String, { nullable: true, description: 'Shell to use for console access' })
public async shell(@Parent() container: DockerContainer): Promise<string | null> {
if (!container.templatePath) return null;
const details = await this.dockerTemplateScannerService.getTemplateDetails(
container.templatePath
);
return details?.shell || null;
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => [ContainerPort], {
nullable: true,
description: 'Port mappings from template (used when container is not running)',
})
public async templatePorts(@Parent() container: DockerContainer): Promise<ContainerPort[] | null> {
if (!container.templatePath) return null;
const details = await this.dockerTemplateScannerService.getTemplateDetails(
container.templatePath
);
if (!details?.ports?.length) return null;
return details.ports.map((port) => ({
privatePort: port.privatePort,
publicPort: port.publicPort,
type: port.type.toUpperCase() === 'UDP' ? ContainerPortType.UDP : ContainerPortType.TCP,
}));
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => String, {
nullable: true,
description: 'Resolved WebUI URL from template',
})
public async webUiUrl(@Parent() container: DockerContainer): Promise<string | null> {
if (!container.templatePath) return null;
const details = await this.dockerTemplateScannerService.getTemplateDetails(
container.templatePath
);
if (!details?.webUi) return null;
const lanIp = getLanIp();
if (!lanIp) return null;
let resolvedUrl = details.webUi;
// Replace [IP] placeholder with LAN IP
resolvedUrl = resolvedUrl.replace(/\[IP\]/g, lanIp);
// Replace [PORT:XXXX] placeholder
const portMatch = resolvedUrl.match(/\[PORT:(\d+)\]/);
if (portMatch) {
const templatePort = parseInt(portMatch[1], 10);
let resolvedPort = templatePort;
// Check if this port is mapped to a public port
if (container.ports) {
for (const port of container.ports) {
if (port.privatePort === templatePort && port.publicPort) {
resolvedPort = port.publicPort;
break;
}
}
}
resolvedUrl = resolvedUrl.replace(/\[PORT:\d+\]/g, String(resolvedPort));
}
return resolvedUrl;
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
@@ -48,4 +205,65 @@ export class DockerContainerResolver {
public async refreshDockerDigests() {
return this.dockerManifestService.refreshDigests();
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => Boolean, { description: 'Whether Tailscale is enabled for this container' })
public tailscaleEnabled(@Parent() container: DockerContainer): boolean {
// Check for Tailscale hostname label (set when hostname is explicitly configured)
if (container.labels?.['net.unraid.docker.tailscale.hostname']) {
return true;
}
// Check for Tailscale hook mount - look for the source path which is an Unraid system path
// The hook is mounted from /usr/local/share/docker/tailscale_container_hook
const mounts = container.mounts ?? [];
return mounts.some((mount: Record<string, unknown>) => {
const source = (mount?.Source ?? mount?.source) as string | undefined;
return source?.includes('tailscale_container_hook');
});
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => TailscaleStatus, {
nullable: true,
description: 'Tailscale status for this container (fetched via docker exec)',
})
public async tailscaleStatus(
@Parent() container: DockerContainer,
@Args('forceRefresh', { type: () => Boolean, nullable: true, defaultValue: false })
forceRefresh: boolean
): Promise<TailscaleStatus | null> {
// First check if Tailscale is enabled
if (!this.tailscaleEnabled(container)) {
return null;
}
const labels = container.labels ?? {};
const hostname = labels['net.unraid.docker.tailscale.hostname'];
if (container.state !== ContainerState.RUNNING) {
return {
online: false,
hostname: hostname || undefined,
isExitNode: false,
updateAvailable: false,
keyExpired: false,
};
}
const containerName = container.names[0];
if (!containerName) {
return null;
}
return this.dockerTailscaleService.getTailscaleStatus(containerName, labels, forceRefresh);
}
}

View File

@@ -1,8 +1,6 @@
import { Logger } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { PassThrough, Readable } from 'stream';
import { PassThrough } from 'stream';
import Docker from 'dockerode';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Import pubsub for use in tests
@@ -51,6 +49,14 @@ vi.mock('@app/core/pubsub.js', () => ({
},
}));
// Mock the docker client utility - this is what the service actually uses
const mockDockerClientInstance = {
getEvents: vi.fn(),
};
vi.mock('./utils/docker-client.js', () => ({
getDockerClient: vi.fn(() => mockDockerClientInstance),
}));
// Mock DockerService
vi.mock('./docker.service.js', () => ({
DockerService: vi.fn().mockImplementation(() => ({
@@ -63,20 +69,13 @@ vi.mock('./docker.service.js', () => ({
describe('DockerEventService', () => {
let service: DockerEventService;
let dockerService: DockerService;
let mockDockerClient: Docker;
let mockEventStream: PassThrough;
let mockLogger: Logger;
let module: TestingModule;
beforeEach(async () => {
// Create a mock Docker client
mockDockerClient = {
getEvents: vi.fn(),
} as unknown as Docker;
// Create a mock Docker service *instance*
const mockDockerServiceImpl = {
getDockerClient: vi.fn().mockReturnValue(mockDockerClient),
getDockerClient: vi.fn(),
clearContainerCache: vi.fn(),
getAppInfo: vi.fn().mockResolvedValue({ info: { apps: { installed: 1, running: 1 } } }),
};
@@ -85,12 +84,7 @@ describe('DockerEventService', () => {
mockEventStream = new PassThrough();
// Set up the mock Docker client to return our mock event stream
vi.spyOn(mockDockerClient, 'getEvents').mockResolvedValue(
mockEventStream as unknown as Readable
);
// Create a mock logger
mockLogger = new Logger(DockerEventService.name) as Logger;
mockDockerClientInstance.getEvents = vi.fn().mockResolvedValue(mockEventStream);
// Use the mock implementation in the testing module
module = await Test.createTestingModule({

View File

@@ -7,6 +7,7 @@ import Docker from 'dockerode';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { getters } from '@app/store/index.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js';
enum DockerEventAction {
DIE = 'die',
@@ -66,7 +67,7 @@ export class DockerEventService implements OnModuleDestroy, OnModuleInit {
];
constructor(private readonly dockerService: DockerService) {
this.client = this.dockerService.getDockerClient();
this.client = getDockerClient();
}
async onModuleInit() {

View File

@@ -0,0 +1,144 @@
import { Test, TestingModule } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AppError } from '@app/core/errors/app-error.js';
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
import { DockerContainerLogs } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
// Mock dependencies
const mockExeca = vi.fn();
vi.mock('execa', () => ({
execa: (cmd: string, args: string[]) => mockExeca(cmd, args),
}));
const { mockDockerInstance, mockGetContainer, mockContainer } = vi.hoisted(() => {
const mockContainer = {
inspect: vi.fn(),
};
const mockGetContainer = vi.fn().mockReturnValue(mockContainer);
const mockDockerInstance = {
getContainer: mockGetContainer,
};
return { mockDockerInstance, mockGetContainer, mockContainer };
});
vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({
getDockerClient: vi.fn().mockReturnValue(mockDockerInstance),
}));
const { statMock } = vi.hoisted(() => ({
statMock: vi.fn().mockResolvedValue({ size: 0 }),
}));
vi.mock('fs/promises', () => ({
stat: statMock,
}));
describe('DockerLogService', () => {
let service: DockerLogService;
beforeEach(async () => {
mockExeca.mockReset();
mockGetContainer.mockReset();
mockGetContainer.mockReturnValue(mockContainer);
mockContainer.inspect.mockReset();
statMock.mockReset();
statMock.mockResolvedValue({ size: 0 });
const module: TestingModule = await Test.createTestingModule({
providers: [DockerLogService],
}).compile();
service = module.get<DockerLogService>(DockerLogService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getContainerLogSizes', () => {
it('should get container log sizes using dockerode inspect', async () => {
mockContainer.inspect.mockResolvedValue({
LogPath: '/var/lib/docker/containers/id/id-json.log',
});
statMock.mockResolvedValue({ size: 1024 });
const sizes = await service.getContainerLogSizes(['test-container']);
expect(mockGetContainer).toHaveBeenCalledWith('test-container');
expect(mockContainer.inspect).toHaveBeenCalled();
expect(statMock).toHaveBeenCalledWith('/var/lib/docker/containers/id/id-json.log');
expect(sizes.get('test-container')).toBe(1024);
});
it('should return 0 for missing log path', async () => {
mockContainer.inspect.mockResolvedValue({}); // No LogPath
const sizes = await service.getContainerLogSizes(['test-container']);
expect(sizes.get('test-container')).toBe(0);
});
it('should handle inspect errors gracefully', async () => {
mockContainer.inspect.mockRejectedValue(new Error('Inspect failed'));
const sizes = await service.getContainerLogSizes(['test-container']);
expect(sizes.get('test-container')).toBe(0);
});
});
describe('getContainerLogs', () => {
it('should fetch logs via docker CLI', async () => {
mockExeca.mockResolvedValue({ stdout: '2023-01-01T00:00:00Z Log message\n' });
const result = await service.getContainerLogs('test-id');
expect(mockExeca).toHaveBeenCalledWith('docker', [
'logs',
'--timestamps',
'--tail',
'200',
'test-id',
]);
expect(result.lines).toHaveLength(1);
expect(result.lines[0].message).toBe('Log message');
});
it('should respect tail option', async () => {
mockExeca.mockResolvedValue({ stdout: '' });
await service.getContainerLogs('test-id', { tail: 50 });
expect(mockExeca).toHaveBeenCalledWith('docker', [
'logs',
'--timestamps',
'--tail',
'50',
'test-id',
]);
});
it('should respect since option', async () => {
mockExeca.mockResolvedValue({ stdout: '' });
const since = new Date('2023-01-01T00:00:00Z');
await service.getContainerLogs('test-id', { since });
expect(mockExeca).toHaveBeenCalledWith('docker', [
'logs',
'--timestamps',
'--tail',
'200',
'--since',
since.toISOString(),
'test-id',
]);
});
it('should throw AppError on execa failure', async () => {
mockExeca.mockRejectedValue(new Error('Docker error'));
await expect(service.getContainerLogs('test-id')).rejects.toThrow(AppError);
});
});
});

View File

@@ -0,0 +1,149 @@
import { Injectable, Logger } from '@nestjs/common';
import { stat } from 'fs/promises';
import type { ExecaError } from 'execa';
import { execa } from 'execa';
import { AppError } from '@app/core/errors/app-error.js';
import {
DockerContainerLogLine,
DockerContainerLogs,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js';
@Injectable()
export class DockerLogService {
private readonly logger = new Logger(DockerLogService.name);
private readonly client = getDockerClient();
private static readonly DEFAULT_LOG_TAIL = 200;
private static readonly MAX_LOG_TAIL = 2000;
public async getContainerLogSizes(containerNames: string[]): Promise<Map<string, number>> {
const logSizes = new Map<string, number>();
if (!Array.isArray(containerNames) || containerNames.length === 0) {
return logSizes;
}
for (const rawName of containerNames) {
const normalized = (rawName ?? '').replace(/^\//, '');
if (!normalized) {
logSizes.set(normalized, 0);
continue;
}
try {
const container = this.client.getContainer(normalized);
const info = await container.inspect();
const logPath = info.LogPath;
if (!logPath || typeof logPath !== 'string' || !logPath.length) {
logSizes.set(normalized, 0);
continue;
}
const stats = await stat(logPath).catch(() => null);
logSizes.set(normalized, stats?.size ?? 0);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error ?? 'unknown error');
this.logger.debug(
`Failed to determine log size for container ${normalized}: ${message}`
);
logSizes.set(normalized, 0);
}
}
return logSizes;
}
public async getContainerLogs(
id: string,
options?: { since?: Date | null; tail?: number | null }
): Promise<DockerContainerLogs> {
const normalizedId = (id ?? '').trim();
if (!normalizedId) {
throw new AppError('Container id is required to fetch logs.', 400);
}
const tail = this.normalizeLogTail(options?.tail);
const args = ['logs', '--timestamps', '--tail', String(tail)];
const sinceIso = options?.since instanceof Date ? options.since.toISOString() : null;
if (sinceIso) {
args.push('--since', sinceIso);
}
args.push(normalizedId);
try {
const { stdout } = await execa('docker', args);
const lines = this.parseDockerLogOutput(stdout);
const cursor =
lines.length > 0 ? lines[lines.length - 1].timestamp : (options?.since ?? null);
return {
containerId: normalizedId,
lines,
cursor: cursor ?? undefined,
};
} catch (error: unknown) {
const execaError = error as ExecaError;
const stderr = typeof execaError?.stderr === 'string' ? execaError.stderr.trim() : '';
const message = stderr || execaError?.message || 'Unknown error';
this.logger.error(
`Failed to fetch logs for container ${normalizedId}: ${message}`,
execaError
);
throw new AppError(`Failed to fetch logs for container ${normalizedId}.`);
}
}
private normalizeLogTail(tail?: number | null): number {
if (typeof tail !== 'number' || Number.isNaN(tail)) {
return DockerLogService.DEFAULT_LOG_TAIL;
}
const coerced = Math.floor(tail);
if (!Number.isFinite(coerced) || coerced <= 0) {
return DockerLogService.DEFAULT_LOG_TAIL;
}
return Math.min(coerced, DockerLogService.MAX_LOG_TAIL);
}
private parseDockerLogOutput(output: string): DockerContainerLogLine[] {
if (!output) {
return [];
}
return output
.split(/\r?\n/g)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((line) => this.parseDockerLogLine(line))
.filter((entry): entry is DockerContainerLogLine => Boolean(entry));
}
private parseDockerLogLine(line: string): DockerContainerLogLine | null {
const trimmed = line.trim();
if (!trimmed.length) {
return null;
}
const firstSpaceIndex = trimmed.indexOf(' ');
if (firstSpaceIndex === -1) {
return {
timestamp: new Date(),
message: trimmed,
};
}
const potentialTimestamp = trimmed.slice(0, firstSpaceIndex);
const message = trimmed.slice(firstSpaceIndex + 1);
const parsedTimestamp = new Date(potentialTimestamp);
if (Number.isNaN(parsedTimestamp.getTime())) {
return {
timestamp: new Date(),
message: trimmed,
};
}
return {
timestamp: parsedTimestamp,
message,
};
}
}

View File

@@ -16,6 +16,14 @@ export class DockerManifestService {
return this.dockerPhpService.refreshDigestsViaPhp();
});
/**
* Reads the cached update status file and returns the parsed contents.
* Exposed so other services can reuse the parsed data when evaluating many containers.
*/
async getCachedUpdateStatuses(): Promise<Record<string, CachedStatusEntry>> {
return this.dockerPhpService.readCachedUpdateStatus();
}
/**
* Recomputes local/remote docker container digests and writes them to /var/lib/docker/unraid-update-status.json
* @param mutex - Optional mutex to use for the operation. If not provided, a default mutex will be used.
@@ -41,7 +49,22 @@ export class DockerManifestService {
cacheData ??= await this.dockerPhpService.readCachedUpdateStatus();
const containerData = cacheData[taggedRef];
if (!containerData) return null;
return containerData.status?.toLowerCase() === 'true';
const normalize = (digest?: string | null) => {
const value = digest?.trim().toLowerCase();
return value && value !== 'undef' ? value : null;
};
const localDigest = normalize(containerData.local);
const remoteDigest = normalize(containerData.remote);
if (localDigest && remoteDigest) {
return localDigest !== remoteDigest;
}
const status = containerData.status?.toLowerCase();
if (status === 'true') return true;
if (status === 'false') return false;
return null;
}
/**

View File

@@ -0,0 +1,89 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Test, TestingModule } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
const { mockDockerInstance, mockListNetworks } = vi.hoisted(() => {
const mockListNetworks = vi.fn();
const mockDockerInstance = {
listNetworks: mockListNetworks,
};
return { mockDockerInstance, mockListNetworks };
});
vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({
getDockerClient: vi.fn().mockReturnValue(mockDockerInstance),
}));
const mockCacheManager = {
get: vi.fn(),
set: vi.fn(),
};
describe('DockerNetworkService', () => {
let service: DockerNetworkService;
beforeEach(async () => {
mockListNetworks.mockReset();
mockCacheManager.get.mockReset();
mockCacheManager.set.mockReset();
const module: TestingModule = await Test.createTestingModule({
providers: [
DockerNetworkService,
{
provide: CACHE_MANAGER,
useValue: mockCacheManager,
},
],
}).compile();
service = module.get<DockerNetworkService>(DockerNetworkService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getNetworks', () => {
it('should return cached networks if available and not skipped', async () => {
const cached = [{ id: 'net1', name: 'test-net' }];
mockCacheManager.get.mockResolvedValue(cached);
const result = await service.getNetworks({ skipCache: false });
expect(result).toEqual(cached);
expect(mockListNetworks).not.toHaveBeenCalled();
});
it('should fetch networks from docker if cache skipped', async () => {
const rawNetworks = [
{
Id: 'net1',
Name: 'test-net',
Driver: 'bridge',
},
];
mockListNetworks.mockResolvedValue(rawNetworks);
const result = await service.getNetworks({ skipCache: true });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('net1');
expect(mockListNetworks).toHaveBeenCalled();
expect(mockCacheManager.set).toHaveBeenCalledWith(
DockerNetworkService.NETWORK_CACHE_KEY,
expect.anything(),
expect.anything()
);
});
it('should fetch networks from docker if cache miss', async () => {
mockCacheManager.get.mockResolvedValue(undefined);
mockListNetworks.mockResolvedValue([]);
await service.getNetworks({ skipCache: false });
expect(mockListNetworks).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,69 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type Cache } from 'cache-manager';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js';
import { DockerNetwork } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js';
interface NetworkListingOptions {
skipCache: boolean;
}
@Injectable()
export class DockerNetworkService {
private readonly logger = new Logger(DockerNetworkService.name);
private readonly client = getDockerClient();
public static readonly NETWORK_CACHE_KEY = 'docker_networks';
private static readonly CACHE_TTL_SECONDS = 60;
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
/**
* Get all Docker networks
* @returns All the in/active Docker networks on the system.
*/
public async getNetworks({ skipCache }: NetworkListingOptions): Promise<DockerNetwork[]> {
if (!skipCache) {
const cachedNetworks = await this.cacheManager.get<DockerNetwork[]>(
DockerNetworkService.NETWORK_CACHE_KEY
);
if (cachedNetworks) {
this.logger.debug('Using docker network cache');
return cachedNetworks;
}
}
this.logger.debug('Updating docker network cache');
const rawNetworks = await this.client.listNetworks().catch(catchHandlers.docker);
const networks = rawNetworks.map(
(network) =>
({
name: network.Name || '',
id: network.Id || '',
created: network.Created || '',
scope: network.Scope || '',
driver: network.Driver || '',
enableIPv6: network.EnableIPv6 || false,
ipam: network.IPAM || {},
internal: network.Internal || false,
attachable: network.Attachable || false,
ingress: network.Ingress || false,
configFrom: network.ConfigFrom || {},
configOnly: network.ConfigOnly || false,
containers: network.Containers || {},
options: network.Options || {},
labels: network.Labels || {},
}) as DockerNetwork
);
await this.cacheManager.set(
DockerNetworkService.NETWORK_CACHE_KEY,
networks,
DockerNetworkService.CACHE_TTL_SECONDS * 1000
);
return networks;
}
}

View File

@@ -0,0 +1,84 @@
import { Test, TestingModule } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
import {
ContainerPortType,
DockerContainer,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
vi.mock('@app/core/utils/network.js', () => ({
getLanIp: vi.fn().mockReturnValue('192.168.1.100'),
}));
describe('DockerPortService', () => {
let service: DockerPortService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [DockerPortService],
}).compile();
service = module.get<DockerPortService>(DockerPortService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('deduplicateContainerPorts', () => {
it('should deduplicate ports', () => {
const ports = [
{ PrivatePort: 80, PublicPort: 80, Type: 'tcp' },
{ PrivatePort: 80, PublicPort: 80, Type: 'tcp' },
{ PrivatePort: 443, PublicPort: 443, Type: 'tcp' },
];
// @ts-expect-error - types are loosely mocked
const result = service.deduplicateContainerPorts(ports);
expect(result).toHaveLength(2);
});
});
describe('calculateConflicts', () => {
it('should detect port conflicts', () => {
const containers = [
{
id: 'c1',
names: ['/web1'],
ports: [{ privatePort: 80, type: ContainerPortType.TCP }],
},
{
id: 'c2',
names: ['/web2'],
ports: [{ privatePort: 80, type: ContainerPortType.TCP }],
},
] as DockerContainer[];
const result = service.calculateConflicts(containers);
expect(result.containerPorts).toHaveLength(1);
expect(result.containerPorts[0].privatePort).toBe(80);
expect(result.containerPorts[0].containers).toHaveLength(2);
});
it('should detect lan port conflicts', () => {
const containers = [
{
id: 'c1',
names: ['/web1'],
ports: [{ publicPort: 8080, type: ContainerPortType.TCP }],
},
{
id: 'c2',
names: ['/web2'],
ports: [{ publicPort: 8080, type: ContainerPortType.TCP }],
},
] as DockerContainer[];
const result = service.calculateConflicts(containers);
expect(result.lanPorts).toHaveLength(1);
expect(result.lanPorts[0].publicPort).toBe(8080);
expect(result.lanPorts[0].containers).toHaveLength(2);
});
});
});

View File

@@ -0,0 +1,178 @@
import { Injectable } from '@nestjs/common';
import Docker from 'dockerode';
import { getLanIp } from '@app/core/utils/network.js';
import {
ContainerPortType,
DockerContainer,
DockerContainerPortConflict,
DockerLanPortConflict,
DockerPortConflictContainer,
DockerPortConflicts,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
@Injectable()
export class DockerPortService {
public deduplicateContainerPorts(
ports: Docker.ContainerInfo['Ports'] | undefined
): Docker.ContainerInfo['Ports'] {
if (!Array.isArray(ports)) {
return [];
}
const seen = new Set<string>();
const uniquePorts: Docker.ContainerInfo['Ports'] = [];
for (const port of ports) {
const key = `${port.PrivatePort ?? ''}-${port.PublicPort ?? ''}-${(port.Type ?? '').toLowerCase()}`;
if (seen.has(key)) {
continue;
}
seen.add(key);
uniquePorts.push(port);
}
return uniquePorts;
}
public calculateConflicts(containers: DockerContainer[]): DockerPortConflicts {
return {
containerPorts: this.buildContainerPortConflicts(containers),
lanPorts: this.buildLanPortConflicts(containers),
};
}
private buildPortConflictContainerRef(container: DockerContainer): DockerPortConflictContainer {
const primaryName = this.getContainerPrimaryName(container);
const fallback = container.names?.[0] ?? container.id;
const normalized = typeof fallback === 'string' ? fallback.replace(/^\//, '') : container.id;
return {
id: container.id,
name: primaryName || normalized,
};
}
private getContainerPrimaryName(container: DockerContainer): string | null {
const names = container.names;
const firstName = names?.[0] ?? '';
return firstName ? firstName.replace(/^\//, '') : null;
}
private buildContainerPortConflicts(containers: DockerContainer[]): DockerContainerPortConflict[] {
const groups = new Map<
string,
{
privatePort: number;
type: ContainerPortType;
containers: DockerContainer[];
seen: Set<string>;
}
>();
for (const container of containers) {
if (!Array.isArray(container.ports)) {
continue;
}
for (const port of container.ports) {
if (!port || typeof port.privatePort !== 'number') {
continue;
}
const type = port.type ?? ContainerPortType.TCP;
const key = `${port.privatePort}/${type}`;
let group = groups.get(key);
if (!group) {
group = {
privatePort: port.privatePort,
type,
containers: [],
seen: new Set<string>(),
};
groups.set(key, group);
}
if (group.seen.has(container.id)) {
continue;
}
group.seen.add(container.id);
group.containers.push(container);
}
}
return Array.from(groups.values())
.filter((group) => group.containers.length > 1)
.map((group) => ({
privatePort: group.privatePort,
type: group.type,
containers: group.containers.map((container) =>
this.buildPortConflictContainerRef(container)
),
}))
.sort((a, b) => {
if (a.privatePort !== b.privatePort) {
return a.privatePort - b.privatePort;
}
return a.type.localeCompare(b.type);
});
}
private buildLanPortConflicts(containers: DockerContainer[]): DockerLanPortConflict[] {
const lanIp = getLanIp();
const groups = new Map<
string,
{
lanIpPort: string;
publicPort: number;
type: ContainerPortType;
containers: DockerContainer[];
seen: Set<string>;
}
>();
for (const container of containers) {
if (!Array.isArray(container.ports)) {
continue;
}
for (const port of container.ports) {
if (!port || typeof port.publicPort !== 'number') {
continue;
}
const type = port.type ?? ContainerPortType.TCP;
const lanIpPort = lanIp ? `${lanIp}:${port.publicPort}` : `${port.publicPort}`;
const key = `${lanIpPort}/${type}`;
let group = groups.get(key);
if (!group) {
group = {
lanIpPort,
publicPort: port.publicPort,
type,
containers: [],
seen: new Set<string>(),
};
groups.set(key, group);
}
if (group.seen.has(container.id)) {
continue;
}
group.seen.add(container.id);
group.containers.push(container);
}
}
return Array.from(groups.values())
.filter((group) => group.containers.length > 1)
.map((group) => ({
lanIpPort: group.lanIpPort,
publicPort: group.publicPort,
type: group.type,
containers: group.containers.map((container) =>
this.buildPortConflictContainerRef(container)
),
}))
.sort((a, b) => {
if ((a.publicPort ?? 0) !== (b.publicPort ?? 0)) {
return (a.publicPort ?? 0) - (b.publicPort ?? 0);
}
return a.type.localeCompare(b.type);
});
}
}

View File

@@ -0,0 +1,117 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { createInterface } from 'readline';
import { execa } from 'execa';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js';
import { DockerContainerStats } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
@Injectable()
export class DockerStatsService implements OnModuleDestroy {
private readonly logger = new Logger(DockerStatsService.name);
private statsProcess: ReturnType<typeof execa> | null = null;
private readonly STATS_FORMAT =
'{{.ID}};{{.CPUPerc}};{{.MemUsage}};{{.MemPerc}};{{.NetIO}};{{.BlockIO}}';
onModuleDestroy() {
this.stopStatsStream();
}
public startStatsStream() {
if (this.statsProcess) {
return;
}
this.logger.log('Starting docker stats stream');
try {
this.statsProcess = execa('docker', ['stats', '--format', this.STATS_FORMAT, '--no-trunc'], {
all: true,
reject: false, // Don't throw on exit code != 0, handle via parsing/events
});
if (this.statsProcess.stdout) {
const rl = createInterface({
input: this.statsProcess.stdout,
crlfDelay: Infinity,
});
rl.on('line', (line) => {
if (!line.trim()) return;
this.processStatsLine(line);
});
rl.on('error', (err) => {
this.logger.error('Error reading docker stats stream', err);
});
}
if (this.statsProcess.stderr) {
this.statsProcess.stderr.on('data', (data: Buffer) => {
// Log docker stats errors but don't crash
this.logger.debug(`Docker stats stderr: ${data.toString()}`);
});
}
// Handle process exit
this.statsProcess
.then((result) => {
if (result.failed && !result.signal) {
this.logger.error('Docker stats process exited with error', result.shortMessage);
this.stopStatsStream();
}
})
.catch((err) => {
if (!err.killed) {
this.logger.error('Docker stats process ended unexpectedly', err);
this.stopStatsStream();
}
});
} catch (error) {
this.logger.error('Failed to start docker stats', error);
catchHandlers.docker(error as Error);
}
}
public stopStatsStream() {
if (this.statsProcess) {
this.logger.log('Stopping docker stats stream');
this.statsProcess.kill();
this.statsProcess = null;
}
}
private processStatsLine(line: string) {
try {
// format: ID;CPUPerc;MemUsage;MemPerc;NetIO;BlockIO
// Example: 123abcde;0.00%;10MiB / 100MiB;10.00%;1kB / 2kB;0B / 0B
// Remove ANSI escape codes if any (docker stats sometimes includes them)
// eslint-disable-next-line no-control-regex
const cleanLine = line.replace(/\x1B\[[0-9;]*[mK]/g, '');
const parts = cleanLine.split(';');
if (parts.length < 6) return;
const [id, cpuPercStr, memUsage, memPercStr, netIO, blockIO] = parts;
const stats: DockerContainerStats = {
id,
cpuPercent: this.parsePercentage(cpuPercStr),
memUsage,
memPercent: this.parsePercentage(memPercStr),
netIO,
blockIO,
};
pubsub.publish(PUBSUB_CHANNEL.DOCKER_STATS, { dockerContainerStats: stats });
} catch (error) {
this.logger.debug(`Failed to process stats line: ${line}`, error);
}
}
private parsePercentage(value: string): number {
return parseFloat(value.replace('%', '')) || 0;
}
}

View File

@@ -0,0 +1,358 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type Cache } from 'cache-manager';
import { TailscaleStatus } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js';
interface RawTailscaleStatus {
Self: {
Online: boolean;
DNSName: string;
TailscaleIPs?: string[];
Relay?: string;
PrimaryRoutes?: string[];
ExitNodeOption?: boolean;
KeyExpiry?: string;
};
ExitNodeStatus?: {
Online: boolean;
TailscaleIPs?: string[];
};
Version: string;
BackendState?: string;
AuthURL?: string;
}
interface DerpRegion {
RegionCode: string;
RegionName: string;
}
interface DerpMap {
Regions: Record<string, DerpRegion>;
}
interface TailscaleVersionResponse {
TarballsVersion: string;
}
@Injectable()
export class DockerTailscaleService {
private readonly logger = new Logger(DockerTailscaleService.name);
private readonly docker = getDockerClient();
private static readonly DERP_MAP_CACHE_KEY = 'tailscale_derp_map';
private static readonly VERSION_CACHE_KEY = 'tailscale_latest_version';
private static readonly STATUS_CACHE_PREFIX = 'tailscale_status_';
private static readonly DERP_MAP_TTL = 86400000; // 24 hours in ms
private static readonly VERSION_TTL = 86400000; // 24 hours in ms
private static readonly STATUS_TTL = 30000; // 30 seconds in ms
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
async getTailscaleStatus(
containerName: string,
labels: Record<string, string>,
forceRefresh = false
): Promise<TailscaleStatus | null> {
const hostname = labels['net.unraid.docker.tailscale.hostname'];
const webUiTemplate = labels['net.unraid.docker.tailscale.webui'];
const cacheKey = `${DockerTailscaleService.STATUS_CACHE_PREFIX}${containerName}`;
if (forceRefresh) {
await this.cacheManager.del(cacheKey);
} else {
const cached = await this.cacheManager.get<TailscaleStatus>(cacheKey);
if (cached) {
return cached;
}
}
const rawStatus = await this.execTailscaleStatus(containerName);
if (!rawStatus) {
// Don't cache failures - return without caching so next request retries
return {
online: false,
hostname: hostname || undefined,
isExitNode: false,
updateAvailable: false,
keyExpired: false,
};
}
const [derpMap, latestVersion] = await Promise.all([this.getDerpMap(), this.getLatestVersion()]);
const version = rawStatus.Version?.split('-')[0];
const updateAvailable = Boolean(
version && latestVersion && this.isVersionLessThan(version, latestVersion)
);
const dnsName = rawStatus.Self.DNSName;
const actualHostname = dnsName ? dnsName.split('.')[0] : undefined;
let relayName: string | undefined;
if (rawStatus.Self.Relay && derpMap) {
relayName = this.mapRelayToRegion(rawStatus.Self.Relay, derpMap);
}
let keyExpiry: Date | undefined;
let keyExpiryDays: number | undefined;
let keyExpired = false;
if (rawStatus.Self.KeyExpiry) {
keyExpiry = new Date(rawStatus.Self.KeyExpiry);
const now = new Date();
const diffMs = keyExpiry.getTime() - now.getTime();
keyExpiryDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
keyExpired = diffMs < 0;
}
const webUiUrl = webUiTemplate ? this.resolveWebUiUrl(webUiTemplate, rawStatus) : undefined;
const status: TailscaleStatus = {
online: rawStatus.Self.Online,
version,
latestVersion: latestVersion ?? undefined,
updateAvailable,
hostname,
dnsName: dnsName || undefined,
relay: rawStatus.Self.Relay,
relayName,
tailscaleIps: rawStatus.Self.TailscaleIPs,
primaryRoutes: rawStatus.Self.PrimaryRoutes,
isExitNode: Boolean(rawStatus.Self.ExitNodeOption),
exitNodeStatus: rawStatus.ExitNodeStatus
? {
online: rawStatus.ExitNodeStatus.Online,
tailscaleIps: rawStatus.ExitNodeStatus.TailscaleIPs,
}
: undefined,
webUiUrl,
keyExpiry,
keyExpiryDays,
keyExpired,
backendState: rawStatus.BackendState,
authUrl: rawStatus.AuthURL,
};
await this.cacheManager.set(cacheKey, status, DockerTailscaleService.STATUS_TTL);
return status;
}
async getDerpMap(): Promise<DerpMap | null> {
const cached = await this.cacheManager.get<DerpMap>(DockerTailscaleService.DERP_MAP_CACHE_KEY);
if (cached) {
return cached;
}
try {
const response = await fetch('https://login.tailscale.com/derpmap/default', {
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
this.logger.warn(`Failed to fetch DERP map: ${response.status}`);
return null;
}
const data = (await response.json()) as DerpMap;
await this.cacheManager.set(
DockerTailscaleService.DERP_MAP_CACHE_KEY,
data,
DockerTailscaleService.DERP_MAP_TTL
);
return data;
} catch (error) {
this.logger.warn('Failed to fetch DERP map', error);
return null;
}
}
async getLatestVersion(): Promise<string | null> {
const cached = await this.cacheManager.get<string>(DockerTailscaleService.VERSION_CACHE_KEY);
if (cached) {
return cached;
}
try {
const response = await fetch('https://pkgs.tailscale.com/stable/?mode=json', {
signal: AbortSignal.timeout(3000),
});
if (!response.ok) {
this.logger.warn(`Failed to fetch Tailscale version: ${response.status}`);
return null;
}
const data = (await response.json()) as TailscaleVersionResponse;
const version = data.TarballsVersion;
await this.cacheManager.set(
DockerTailscaleService.VERSION_CACHE_KEY,
version,
DockerTailscaleService.VERSION_TTL
);
return version;
} catch (error) {
this.logger.warn('Failed to fetch Tailscale version', error);
return null;
}
}
private async execTailscaleStatus(containerName: string): Promise<RawTailscaleStatus | null> {
try {
const cleanName = containerName.replace(/^\//, '');
const container = this.docker.getContainer(cleanName);
const exec = await container.exec({
Cmd: ['/bin/sh', '-c', 'tailscale status --json'],
AttachStdout: true,
AttachStderr: true,
});
const stream = await exec.start({ hijack: true, stdin: false });
const output = await this.collectStreamOutput(stream);
this.logger.debug(`Raw tailscale output for ${cleanName}: ${output.substring(0, 500)}...`);
if (!output.trim()) {
this.logger.warn(`Empty tailscale output for ${cleanName}`);
return null;
}
const parsed = JSON.parse(output) as RawTailscaleStatus;
this.logger.debug(
`Parsed tailscale status for ${cleanName}: DNSName=${parsed.Self?.DNSName}, Online=${parsed.Self?.Online}`
);
return parsed;
} catch (error) {
this.logger.debug(`Failed to get Tailscale status for ${containerName}: ${error}`);
return null;
}
}
private async collectStreamOutput(stream: NodeJS.ReadableStream): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
stream.on('end', () => {
const buffer = Buffer.concat(chunks);
const output = this.demuxDockerStream(buffer);
resolve(output);
});
stream.on('error', reject);
});
}
private demuxDockerStream(buffer: Buffer): string {
// Check if the buffer looks like it starts with JSON (not multiplexed)
// Docker multiplexed streams start with stream type byte (0, 1, or 2)
// followed by 3 zero bytes, then 4-byte size
if (buffer.length > 0) {
const firstChar = buffer.toString('utf8', 0, 1);
if (firstChar === '{' || firstChar === '[') {
// Already plain text/JSON, not multiplexed
return buffer.toString('utf8');
}
}
let offset = 0;
const output: string[] = [];
while (offset < buffer.length) {
if (offset + 8 > buffer.length) break;
const streamType = buffer.readUInt8(offset);
// Valid stream types are 0 (stdin), 1 (stdout), 2 (stderr)
if (streamType > 2) {
// Doesn't look like multiplexed stream, treat as raw
return buffer.toString('utf8');
}
const size = buffer.readUInt32BE(offset + 4);
offset += 8;
if (offset + size > buffer.length) break;
const chunk = buffer.slice(offset, offset + size).toString('utf8');
output.push(chunk);
offset += size;
}
return output.join('');
}
private mapRelayToRegion(relayCode: string, derpMap: DerpMap): string | undefined {
for (const region of Object.values(derpMap.Regions)) {
if (region.RegionCode === relayCode) {
return region.RegionName;
}
}
return undefined;
}
private isVersionLessThan(current: string, latest: string): boolean {
const currentParts = current.split('.').map(Number);
const latestParts = latest.split('.').map(Number);
for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const curr = currentParts[i] || 0;
const lat = latestParts[i] || 0;
if (curr < lat) return true;
if (curr > lat) return false;
}
return false;
}
private resolveWebUiUrl(template: string, status: RawTailscaleStatus): string | undefined {
if (!template) return undefined;
let url = template;
const dnsName = status.Self.DNSName?.replace(/\.$/, '');
// Handle [hostname][magicdns] or [hostname] - use MagicDNS name and port 443
if (url.includes('[hostname]')) {
if (dnsName) {
// Replace [hostname][magicdns] with the full DNS name
url = url.replace('[hostname][magicdns]', dnsName);
// Replace standalone [hostname] with the DNS name
url = url.replace('[hostname]', dnsName);
// When using MagicDNS, also replace [IP] with DNS name
url = url.replace(/\[IP\]/g, dnsName);
// When using MagicDNS with Serve/Funnel, port is always 443
url = url.replace(/\[PORT:\d+\]/g, '443');
} else {
// DNS name not available, can't resolve
return undefined;
}
} else if (url.includes('[noserve]')) {
// Handle [noserve] - use direct Tailscale IP
const ipv4 = status.Self.TailscaleIPs?.find((ip) => !ip.includes(':'));
if (ipv4) {
const portMatch = template.match(/\[PORT:(\d+)\]/);
const port = portMatch ? `:${portMatch[1]}` : '';
url = `http://${ipv4}${port}`;
} else {
return undefined;
}
} else {
// Custom URL - just do basic replacements
if (url.includes('[IP]') && status.Self.TailscaleIPs?.[0]) {
const ipv4 = status.Self.TailscaleIPs.find((ip) => !ip.includes(':'));
url = url.replace(/\[IP\]/g, ipv4 || status.Self.TailscaleIPs[0]);
}
const portMatch = url.match(/\[PORT:(\d+)\]/);
if (portMatch) {
url = url.replace(portMatch[0], portMatch[1]);
}
}
return url;
}
}

View File

@@ -0,0 +1,61 @@
import { Injectable, Logger } from '@nestjs/common';
import { readFile } from 'fs/promises';
import { XMLParser } from 'fast-xml-parser';
@Injectable()
export class DockerTemplateIconService {
private readonly logger = new Logger(DockerTemplateIconService.name);
private readonly xmlParser = new XMLParser({
ignoreAttributes: false,
parseAttributeValue: true,
trimValues: true,
});
async getIconFromTemplate(templatePath: string): Promise<string | null> {
try {
const content = await readFile(templatePath, 'utf-8');
const parsed = this.xmlParser.parse(content);
if (!parsed.Container) {
return null;
}
return parsed.Container.Icon || null;
} catch (error) {
this.logger.debug(
`Failed to read icon from template ${templatePath}: ${error instanceof Error ? error.message : 'Unknown error'}`
);
return null;
}
}
async getIconsForContainers(
containers: Array<{ id: string; templatePath?: string }>
): Promise<Map<string, string>> {
const iconMap = new Map<string, string>();
const iconPromises = containers.map(async (container) => {
if (!container.templatePath) {
return null;
}
const icon = await this.getIconFromTemplate(container.templatePath);
if (icon) {
return { id: container.id, icon };
}
return null;
});
const results = await Promise.all(iconPromises);
for (const result of results) {
if (result) {
iconMap.set(result.id, result.icon);
}
}
this.logger.debug(`Loaded ${iconMap.size} icons from ${containers.length} containers`);
return iconMap;
}
}

View File

@@ -0,0 +1,16 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class DockerTemplateSyncResult {
@Field(() => Int)
scanned!: number;
@Field(() => Int)
matched!: number;
@Field(() => Int)
skipped!: number;
@Field(() => [String])
errors!: string[];
}

View File

@@ -0,0 +1,425 @@
import { Test, TestingModule } from '@nestjs/testing';
import { mkdir, rm, writeFile } from 'fs/promises';
import { join } from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js';
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
vi.mock('@app/environment.js', () => ({
PATHS_DOCKER_TEMPLATES: ['/tmp/test-templates'],
ENABLE_NEXT_DOCKER_RELEASE: true,
}));
describe('DockerTemplateScannerService', () => {
let service: DockerTemplateScannerService;
let dockerConfigService: DockerConfigService;
let dockerService: DockerService;
const testTemplateDir = '/tmp/test-templates';
beforeEach(async () => {
await mkdir(testTemplateDir, { recursive: true });
const mockDockerService = {
getContainers: vi.fn(),
};
const mockDockerConfigService = {
getConfig: vi.fn(),
replaceConfig: vi.fn(),
validate: vi.fn((config) => Promise.resolve(config)),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
DockerTemplateScannerService,
{
provide: DockerConfigService,
useValue: mockDockerConfigService,
},
{
provide: DockerService,
useValue: mockDockerService,
},
],
}).compile();
service = module.get<DockerTemplateScannerService>(DockerTemplateScannerService);
dockerConfigService = module.get<DockerConfigService>(DockerConfigService);
dockerService = module.get<DockerService>(DockerService);
});
afterEach(async () => {
await rm(testTemplateDir, { recursive: true, force: true });
});
describe('parseTemplate', () => {
it('should parse valid XML template', async () => {
const templatePath = join(testTemplateDir, 'test.xml');
const templateContent = `<?xml version="1.0"?>
<Container version="2">
<Name>test-container</Name>
<Repository>test/image</Repository>
</Container>`;
await writeFile(templatePath, templateContent);
const result = await (service as any).parseTemplate(templatePath);
expect(result).toEqual({
filePath: templatePath,
name: 'test-container',
repository: 'test/image',
});
});
it('should handle invalid XML gracefully by returning null', async () => {
const templatePath = join(testTemplateDir, 'invalid.xml');
await writeFile(templatePath, 'not xml');
const result = await (service as any).parseTemplate(templatePath);
expect(result).toBeNull();
});
it('should return null for XML without Container element', async () => {
const templatePath = join(testTemplateDir, 'no-container.xml');
const templateContent = `<?xml version="1.0"?><Root></Root>`;
await writeFile(templatePath, templateContent);
const result = await (service as any).parseTemplate(templatePath);
expect(result).toBeNull();
});
});
describe('matchContainerToTemplate', () => {
it('should match by container name (exact match)', () => {
const container: DockerContainer = {
id: 'abc123',
names: ['/test-container'],
image: 'different/image:latest',
} as DockerContainer;
const templates = [
{ filePath: '/path/1', name: 'test-container', repository: 'some/repo' },
{ filePath: '/path/2', name: 'other', repository: 'other/repo' },
];
const result = (service as any).matchContainerToTemplate(container, templates);
expect(result).toEqual(templates[0]);
});
it('should match by repository when name does not match', () => {
const container: DockerContainer = {
id: 'abc123',
names: ['/my-container'],
image: 'test/image:v1.0',
} as DockerContainer;
const templates = [
{ filePath: '/path/1', name: 'different', repository: 'other/repo' },
{ filePath: '/path/2', name: 'also-different', repository: 'test/image' },
];
const result = (service as any).matchContainerToTemplate(container, templates);
expect(result).toEqual(templates[1]);
});
it('should strip tags when matching repository', () => {
const container: DockerContainer = {
id: 'abc123',
names: ['/my-container'],
image: 'test/image:latest',
} as DockerContainer;
const templates = [
{ filePath: '/path/1', name: 'different', repository: 'test/image:v1.0' },
];
const result = (service as any).matchContainerToTemplate(container, templates);
expect(result).toEqual(templates[0]);
});
it('should return null when no match found', () => {
const container: DockerContainer = {
id: 'abc123',
names: ['/my-container'],
image: 'test/image:latest',
} as DockerContainer;
const templates = [{ filePath: '/path/1', name: 'different', repository: 'other/image' }];
const result = (service as any).matchContainerToTemplate(container, templates);
expect(result).toBeNull();
});
it('should be case-insensitive', () => {
const container: DockerContainer = {
id: 'abc123',
names: ['/Test-Container'],
image: 'Test/Image:latest',
} as DockerContainer;
const templates = [
{ filePath: '/path/1', name: 'test-container', repository: 'test/image' },
];
const result = (service as any).matchContainerToTemplate(container, templates);
expect(result).toEqual(templates[0]);
});
});
describe('scanTemplates', () => {
it('should scan templates and create mappings', async () => {
const template1 = join(testTemplateDir, 'redis.xml');
await writeFile(
template1,
`<?xml version="1.0"?>
<Container version="2">
<Name>redis</Name>
<Repository>redis</Repository>
</Container>`
);
const containers: DockerContainer[] = [
{
id: 'container1',
names: ['/redis'],
image: 'redis:latest',
} as DockerContainer,
];
vi.mocked(dockerService.getContainers).mockResolvedValue(containers);
vi.mocked(dockerConfigService.getConfig).mockReturnValue({
updateCheckCronSchedule: '0 6 * * *',
templateMappings: {},
skipTemplatePaths: [],
});
const result = await service.scanTemplates();
expect(result.scanned).toBe(1);
expect(result.matched).toBe(1);
expect(result.errors).toHaveLength(0);
expect(dockerConfigService.replaceConfig).toHaveBeenCalledWith(
expect.objectContaining({
templateMappings: {
redis: template1,
},
})
);
});
it('should skip containers in skipTemplatePaths', async () => {
const template1 = join(testTemplateDir, 'redis.xml');
await writeFile(
template1,
`<?xml version="1.0"?>
<Container version="2">
<Name>redis</Name>
<Repository>redis</Repository>
</Container>`
);
const containers: DockerContainer[] = [
{
id: 'container1',
names: ['/redis'],
image: 'redis:latest',
} as DockerContainer,
];
vi.mocked(dockerService.getContainers).mockResolvedValue(containers);
vi.mocked(dockerConfigService.getConfig).mockReturnValue({
updateCheckCronSchedule: '0 6 * * *',
templateMappings: {},
skipTemplatePaths: ['redis'],
});
const result = await service.scanTemplates();
expect(result.skipped).toBe(1);
expect(result.matched).toBe(0);
});
it('should handle missing template directory gracefully', async () => {
await rm(testTemplateDir, { recursive: true, force: true });
const containers: DockerContainer[] = [];
vi.mocked(dockerService.getContainers).mockResolvedValue(containers);
vi.mocked(dockerConfigService.getConfig).mockReturnValue({
updateCheckCronSchedule: '0 6 * * *',
templateMappings: {},
skipTemplatePaths: [],
});
const result = await service.scanTemplates();
expect(result.scanned).toBe(0);
expect(result.errors.length).toBeGreaterThan(0);
});
it('should handle docker service errors gracefully', async () => {
vi.mocked(dockerService.getContainers).mockRejectedValue(new Error('Docker error'));
vi.mocked(dockerConfigService.getConfig).mockReturnValue({
updateCheckCronSchedule: '0 6 * * *',
templateMappings: {},
skipTemplatePaths: [],
});
const result = await service.scanTemplates();
expect(result.errors.length).toBeGreaterThan(0);
expect(result.errors[0]).toContain('Failed to get containers');
});
it('should set null mapping for unmatched containers', async () => {
const containers: DockerContainer[] = [
{
id: 'container1',
names: ['/unknown'],
image: 'unknown:latest',
} as DockerContainer,
];
vi.mocked(dockerService.getContainers).mockResolvedValue(containers);
vi.mocked(dockerConfigService.getConfig).mockReturnValue({
updateCheckCronSchedule: '0 6 * * *',
templateMappings: {},
skipTemplatePaths: [],
});
await service.scanTemplates();
expect(dockerConfigService.replaceConfig).toHaveBeenCalledWith(
expect.objectContaining({
templateMappings: {
unknown: null,
},
})
);
});
});
describe('syncMissingContainers', () => {
it('should return true and trigger scan when containers are missing mappings', async () => {
const containers: DockerContainer[] = [
{
id: 'container1',
names: ['/redis'],
image: 'redis:latest',
} as DockerContainer,
];
vi.mocked(dockerConfigService.getConfig).mockReturnValue({
updateCheckCronSchedule: '0 6 * * *',
templateMappings: {},
skipTemplatePaths: [],
});
vi.mocked(dockerService.getContainers).mockResolvedValue(containers);
const scanSpy = vi.spyOn(service, 'scanTemplates').mockResolvedValue({
scanned: 0,
matched: 0,
skipped: 0,
errors: [],
});
const result = await service.syncMissingContainers(containers);
expect(result).toBe(true);
expect(scanSpy).toHaveBeenCalled();
});
it('should return false when all containers have mappings', async () => {
const containers: DockerContainer[] = [
{
id: 'container1',
names: ['/redis'],
image: 'redis:latest',
} as DockerContainer,
];
vi.mocked(dockerConfigService.getConfig).mockReturnValue({
updateCheckCronSchedule: '0 6 * * *',
templateMappings: {
redis: '/path/to/template.xml',
},
skipTemplatePaths: [],
});
const scanSpy = vi.spyOn(service, 'scanTemplates');
const result = await service.syncMissingContainers(containers);
expect(result).toBe(false);
expect(scanSpy).not.toHaveBeenCalled();
});
it('should not trigger scan for containers in skip list', async () => {
const containers: DockerContainer[] = [
{
id: 'container1',
names: ['/redis'],
image: 'redis:latest',
} as DockerContainer,
];
vi.mocked(dockerConfigService.getConfig).mockReturnValue({
updateCheckCronSchedule: '0 6 * * *',
templateMappings: {},
skipTemplatePaths: ['redis'],
});
const scanSpy = vi.spyOn(service, 'scanTemplates');
const result = await service.syncMissingContainers(containers);
expect(result).toBe(false);
expect(scanSpy).not.toHaveBeenCalled();
});
});
describe('normalizeContainerName', () => {
it('should remove leading slash', () => {
const result = (service as any).normalizeContainerName('/container-name');
expect(result).toBe('container-name');
});
it('should convert to lowercase', () => {
const result = (service as any).normalizeContainerName('/Container-Name');
expect(result).toBe('container-name');
});
});
describe('normalizeRepository', () => {
it('should strip tag', () => {
const result = (service as any).normalizeRepository('redis:latest');
expect(result).toBe('redis');
});
it('should strip version tag', () => {
const result = (service as any).normalizeRepository('postgres:14.5');
expect(result).toBe('postgres');
});
it('should convert to lowercase', () => {
const result = (service as any).normalizeRepository('Redis:Latest');
expect(result).toBe('redis');
});
it('should handle repository without tag', () => {
const result = (service as any).normalizeRepository('nginx');
expect(result).toBe('nginx');
});
});
});

View File

@@ -0,0 +1,293 @@
import { Injectable, Logger } from '@nestjs/common';
import { Timeout } from '@nestjs/schedule';
import { readdir, readFile } from 'fs/promises';
import { join } from 'path';
import { XMLParser } from 'fast-xml-parser';
import { ENABLE_NEXT_DOCKER_RELEASE, PATHS_DOCKER_TEMPLATES } from '@app/environment.js';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerTemplateSyncResult } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.model.js';
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
interface ParsedTemplate {
filePath: string;
name?: string;
repository?: string;
}
@Injectable()
export class DockerTemplateScannerService {
private readonly logger = new Logger(DockerTemplateScannerService.name);
private readonly xmlParser = new XMLParser({
ignoreAttributes: false,
parseAttributeValue: true,
trimValues: true,
});
constructor(
private readonly dockerConfigService: DockerConfigService,
private readonly dockerService: DockerService
) {}
@Timeout(5_000)
async bootstrapScan(attempt = 1, maxAttempts = 5): Promise<void> {
if (!ENABLE_NEXT_DOCKER_RELEASE) {
return;
}
try {
this.logger.log(`Starting template scan (attempt ${attempt}/${maxAttempts})`);
const result = await this.scanTemplates();
this.logger.log(
`Template scan complete: ${result.matched} matched, ${result.scanned} scanned, ${result.skipped} skipped`
);
} catch (error) {
if (attempt < maxAttempts) {
this.logger.warn(
`Template scan failed (attempt ${attempt}/${maxAttempts}), retrying in 60s: ${error instanceof Error ? error.message : 'Unknown error'}`
);
setTimeout(() => this.bootstrapScan(attempt + 1, maxAttempts), 60_000);
} else {
this.logger.error(
`Template scan failed after ${maxAttempts} attempts: ${error instanceof Error ? error.message : 'Unknown error'}`
);
}
}
}
async syncMissingContainers(containers: DockerContainer[]): Promise<boolean> {
const config = this.dockerConfigService.getConfig();
const mappings = config.templateMappings || {};
const skipSet = new Set(config.skipTemplatePaths || []);
const needsSync = containers.filter((c) => {
const containerName = this.normalizeContainerName(c.names[0]);
return !mappings[containerName] && !skipSet.has(containerName);
});
if (needsSync.length > 0) {
this.logger.log(
`Found ${needsSync.length} containers without template mappings, triggering sync`
);
await this.scanTemplates();
return true;
}
return false;
}
async scanTemplates(): Promise<DockerTemplateSyncResult> {
const result: DockerTemplateSyncResult = {
scanned: 0,
matched: 0,
skipped: 0,
errors: [],
};
const templates = await this.loadAllTemplates(result);
try {
const containers = await this.dockerService.getContainers({ skipCache: true });
const config = this.dockerConfigService.getConfig();
const currentMappings = config.templateMappings || {};
const skipSet = new Set(config.skipTemplatePaths || []);
const newMappings: Record<string, string | null> = { ...currentMappings };
for (const container of containers) {
const containerName = this.normalizeContainerName(container.names[0]);
if (skipSet.has(containerName)) {
result.skipped++;
continue;
}
const match = this.matchContainerToTemplate(container, templates);
if (match) {
newMappings[containerName] = match.filePath;
result.matched++;
} else {
newMappings[containerName] = null;
}
}
await this.updateMappings(newMappings);
} catch (error) {
const errorMsg = `Failed to get containers: ${error instanceof Error ? error.message : 'Unknown error'}`;
this.logger.error(error, 'Failed to get containers');
result.errors.push(errorMsg);
}
return result;
}
async getTemplateDetails(filePath: string): Promise<{
project?: string;
registry?: string;
support?: string;
overview?: string;
icon?: string;
webUi?: string;
shell?: string;
ports?: Array<{ privatePort: number; publicPort: number; type: 'tcp' | 'udp' }>;
} | null> {
try {
const content = await readFile(filePath, 'utf-8');
const parsed = this.xmlParser.parse(content);
if (!parsed.Container) {
return null;
}
const container = parsed.Container;
const ports = this.extractTemplatePorts(container);
return {
project: container.Project,
registry: container.Registry,
support: container.Support,
overview: container.ReadMe || container.Overview,
icon: container.Icon,
webUi: container.WebUI,
shell: container.Shell,
ports,
};
} catch (error) {
this.logger.warn(
`Failed to parse template ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`
);
return null;
}
}
private extractTemplatePorts(
container: Record<string, unknown>
): Array<{ privatePort: number; publicPort: number; type: 'tcp' | 'udp' }> {
const ports: Array<{ privatePort: number; publicPort: number; type: 'tcp' | 'udp' }> = [];
const configs = container.Config;
if (!configs) {
return ports;
}
const configArray = Array.isArray(configs) ? configs : [configs];
for (const config of configArray) {
if (!config || typeof config !== 'object') continue;
const attrs = config['@_Type'];
if (attrs !== 'Port') continue;
const target = config['@_Target'];
const mode = config['@_Mode'];
const value = config['#text'];
if (target === undefined || value === undefined) continue;
const privatePort = parseInt(String(target), 10);
const publicPort = parseInt(String(value), 10);
if (isNaN(privatePort) || isNaN(publicPort)) continue;
const type = String(mode).toLowerCase() === 'udp' ? 'udp' : 'tcp';
ports.push({ privatePort, publicPort, type });
}
return ports;
}
private async loadAllTemplates(result: DockerTemplateSyncResult): Promise<ParsedTemplate[]> {
const allTemplates: ParsedTemplate[] = [];
for (const directory of PATHS_DOCKER_TEMPLATES) {
try {
const files = await readdir(directory);
const xmlFiles = files.filter((f) => f.endsWith('.xml'));
result.scanned += xmlFiles.length;
for (const file of xmlFiles) {
const filePath = join(directory, file);
try {
const template = await this.parseTemplate(filePath);
if (template) {
allTemplates.push(template);
}
} catch (error) {
const errorMsg = `Failed to parse template ${filePath}: ${error instanceof Error ? error.message : 'Unknown error'}`;
this.logger.warn(errorMsg);
result.errors.push(errorMsg);
}
}
} catch (error) {
const errorMsg = `Failed to read template directory ${directory}: ${error instanceof Error ? error.message : 'Unknown error'}`;
this.logger.warn(errorMsg);
result.errors.push(errorMsg);
}
}
return allTemplates;
}
private async parseTemplate(filePath: string): Promise<ParsedTemplate | null> {
const content = await readFile(filePath, 'utf-8');
const parsed = this.xmlParser.parse(content);
if (!parsed.Container) {
return null;
}
const container = parsed.Container;
return {
filePath,
name: container.Name,
repository: container.Repository,
};
}
private matchContainerToTemplate(
container: DockerContainer,
templates: ParsedTemplate[]
): ParsedTemplate | null {
const containerName = this.normalizeContainerName(container.names[0]);
const containerImage = this.normalizeRepository(container.image);
for (const template of templates) {
if (template.name && this.normalizeContainerName(template.name) === containerName) {
return template;
}
}
for (const template of templates) {
if (
template.repository &&
this.normalizeRepository(template.repository) === containerImage
) {
return template;
}
}
return null;
}
private normalizeContainerName(name: string): string {
return name.replace(/^\//, '').toLowerCase();
}
private normalizeRepository(repository: string): string {
// Strip digest if present (e.g., image@sha256:abc123)
const [withoutDigest] = repository.split('@');
// Only remove tag if colon appears after last slash (i.e., it's a tag, not a port)
const lastColon = withoutDigest.lastIndexOf(':');
const lastSlash = withoutDigest.lastIndexOf('/');
const withoutTag = lastColon > lastSlash ? withoutDigest.slice(0, lastColon) : withoutDigest;
return withoutTag.toLowerCase();
}
private async updateMappings(mappings: Record<string, string | null>): Promise<void> {
const config = this.dockerConfigService.getConfig();
const updated = await this.dockerConfigService.validate({
...config,
templateMappings: mappings,
});
this.dockerConfigService.replaceConfig(updated);
}
}

View File

@@ -1,6 +1,16 @@
import { Field, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
import {
Field,
Float,
GraphQLISODateTime,
ID,
InputType,
Int,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { Node } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import { GraphQLBigInt, GraphQLJSON, GraphQLPort } from 'graphql-scalars';
export enum ContainerPortType {
@@ -27,8 +37,54 @@ export class ContainerPort {
type!: ContainerPortType;
}
@ObjectType()
export class DockerPortConflictContainer {
@Field(() => PrefixedID)
id!: string;
@Field(() => String)
name!: string;
}
@ObjectType()
export class DockerContainerPortConflict {
@Field(() => GraphQLPort)
privatePort!: number;
@Field(() => ContainerPortType)
type!: ContainerPortType;
@Field(() => [DockerPortConflictContainer])
containers!: DockerPortConflictContainer[];
}
@ObjectType()
export class DockerLanPortConflict {
@Field(() => String)
lanIpPort!: string;
@Field(() => GraphQLPort, { nullable: true })
publicPort?: number;
@Field(() => ContainerPortType)
type!: ContainerPortType;
@Field(() => [DockerPortConflictContainer])
containers!: DockerPortConflictContainer[];
}
@ObjectType()
export class DockerPortConflicts {
@Field(() => [DockerContainerPortConflict])
containerPorts!: DockerContainerPortConflict[];
@Field(() => [DockerLanPortConflict])
lanPorts!: DockerLanPortConflict[];
}
export enum ContainerState {
RUNNING = 'RUNNING',
PAUSED = 'PAUSED',
EXITED = 'EXITED',
}
@@ -89,12 +145,30 @@ export class DockerContainer extends Node {
@Field(() => [ContainerPort])
ports!: ContainerPort[];
@Field(() => [String], {
nullable: true,
description: 'List of LAN-accessible host:port values',
})
lanIpPorts?: string[];
@Field(() => GraphQLBigInt, {
nullable: true,
description: 'Total size of all files in the container (in bytes)',
})
sizeRootFs?: number;
@Field(() => GraphQLBigInt, {
nullable: true,
description: 'Size of writable layer (in bytes)',
})
sizeRw?: number;
@Field(() => GraphQLBigInt, {
nullable: true,
description: 'Size of container logs (in bytes)',
})
sizeLog?: number;
@Field(() => GraphQLJSON, { nullable: true })
labels?: Record<string, any>;
@@ -115,6 +189,45 @@ export class DockerContainer extends Node {
@Field(() => Boolean)
autoStart!: boolean;
@Field(() => Int, { nullable: true, description: 'Zero-based order in the auto-start list' })
autoStartOrder?: number;
@Field(() => Int, { nullable: true, description: 'Wait time in seconds applied after start' })
autoStartWait?: number;
@Field(() => String, { nullable: true })
templatePath?: string;
@Field(() => String, { nullable: true, description: 'Project/Product homepage URL' })
projectUrl?: string;
@Field(() => String, { nullable: true, description: 'Registry/Docker Hub URL' })
registryUrl?: string;
@Field(() => String, { nullable: true, description: 'Support page/thread URL' })
supportUrl?: string;
@Field(() => String, { nullable: true, description: 'Icon URL' })
iconUrl?: string;
@Field(() => String, { nullable: true, description: 'Resolved WebUI URL from template' })
webUiUrl?: string;
@Field(() => String, {
nullable: true,
description: 'Shell to use for console access (from template)',
})
shell?: string;
@Field(() => [ContainerPort], {
nullable: true,
description: 'Port mappings from template (used when container is not running)',
})
templatePorts?: ContainerPort[];
@Field(() => Boolean, { description: 'Whether the container is orphaned (no template found)' })
isOrphaned!: boolean;
}
@ObjectType({ implements: () => Node })
@@ -162,6 +275,127 @@ export class DockerNetwork extends Node {
labels!: Record<string, any>;
}
@ObjectType()
export class DockerContainerLogLine {
@Field(() => GraphQLISODateTime)
timestamp!: Date;
@Field(() => String)
message!: string;
}
@ObjectType()
export class DockerContainerLogs {
@Field(() => PrefixedID)
containerId!: string;
@Field(() => [DockerContainerLogLine])
lines!: DockerContainerLogLine[];
@Field(() => GraphQLISODateTime, {
nullable: true,
description:
'Cursor that can be passed back through the since argument to continue streaming logs.',
})
cursor?: Date | null;
}
@ObjectType()
export class DockerContainerStats {
@Field(() => PrefixedID)
id!: string;
@Field(() => Float, { description: 'CPU Usage Percentage' })
cpuPercent!: number;
@Field(() => String, { description: 'Memory Usage String (e.g. 100MB / 1GB)' })
memUsage!: string;
@Field(() => Float, { description: 'Memory Usage Percentage' })
memPercent!: number;
@Field(() => String, { description: 'Network I/O String (e.g. 100MB / 1GB)' })
netIO!: string;
@Field(() => String, { description: 'Block I/O String (e.g. 100MB / 1GB)' })
blockIO!: string;
}
@ObjectType({ description: 'Tailscale exit node connection status' })
export class TailscaleExitNodeStatus {
@Field(() => Boolean, { description: 'Whether the exit node is online' })
online!: boolean;
@Field(() => [String], { nullable: true, description: 'Tailscale IPs of the exit node' })
tailscaleIps?: string[];
}
@ObjectType({ description: 'Tailscale status for a Docker container' })
export class TailscaleStatus {
@Field(() => Boolean, { description: 'Whether Tailscale is online in the container' })
online!: boolean;
@Field(() => String, { nullable: true, description: 'Current Tailscale version' })
version?: string;
@Field(() => String, { nullable: true, description: 'Latest available Tailscale version' })
latestVersion?: string;
@Field(() => Boolean, { description: 'Whether a Tailscale update is available' })
updateAvailable!: boolean;
@Field(() => String, { nullable: true, description: 'Configured Tailscale hostname' })
hostname?: string;
@Field(() => String, { nullable: true, description: 'Actual Tailscale DNS name' })
dnsName?: string;
@Field(() => String, { nullable: true, description: 'DERP relay code' })
relay?: string;
@Field(() => String, { nullable: true, description: 'DERP relay region name' })
relayName?: string;
@Field(() => [String], { nullable: true, description: 'Tailscale IPv4 and IPv6 addresses' })
tailscaleIps?: string[];
@Field(() => [String], { nullable: true, description: 'Advertised subnet routes' })
primaryRoutes?: string[];
@Field(() => Boolean, { description: 'Whether this container is an exit node' })
isExitNode!: boolean;
@Field(() => TailscaleExitNodeStatus, {
nullable: true,
description: 'Status of the connected exit node (if using one)',
})
exitNodeStatus?: TailscaleExitNodeStatus;
@Field(() => String, { nullable: true, description: 'Tailscale Serve/Funnel WebUI URL' })
webUiUrl?: string;
@Field(() => GraphQLISODateTime, { nullable: true, description: 'Tailscale key expiry date' })
keyExpiry?: Date;
@Field(() => Int, { nullable: true, description: 'Days until key expires' })
keyExpiryDays?: number;
@Field(() => Boolean, { description: 'Whether the Tailscale key has expired' })
keyExpired!: boolean;
@Field(() => String, {
nullable: true,
description: 'Tailscale backend state (Running, NeedsLogin, Stopped, etc.)',
})
backendState?: string;
@Field(() => String, {
nullable: true,
description: 'Authentication URL if Tailscale needs login',
})
authUrl?: string;
}
@ObjectType({
implements: () => Node,
})
@@ -171,4 +405,28 @@ export class Docker extends Node {
@Field(() => [DockerNetwork])
networks!: DockerNetwork[];
@Field(() => DockerPortConflicts)
portConflicts!: DockerPortConflicts;
@Field(() => DockerContainerLogs, {
description:
'Access container logs. Requires specifying a target container id through resolver arguments.',
})
logs!: DockerContainerLogs;
}
@InputType()
export class DockerAutostartEntryInput {
@Field(() => PrefixedID, { description: 'Docker container identifier' })
id!: string;
@Field(() => Boolean, { description: 'Whether the container should auto-start' })
autoStart!: boolean;
@Field(() => Int, {
nullable: true,
description: 'Number of seconds to wait after starting the container',
})
wait?: number | null;
}

View File

@@ -1,21 +1,28 @@
import { CacheModule } from '@nestjs/cache-manager';
import { Test, TestingModule } from '@nestjs/testing';
import { describe, expect, it, vi } from 'vitest';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerEventService } from '@app/unraid-api/graph/resolvers/docker/docker-event.service.js';
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docker-stats.service.js';
import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js';
import { DockerModule } from '@app/unraid-api/graph/resolvers/docker/docker.module.js';
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js';
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
describe('DockerModule', () => {
it('should compile the module', async () => {
const module = await Test.createTestingModule({
imports: [DockerModule],
imports: [CacheModule.register({ isGlobal: true }), DockerModule],
})
.overrideProvider(DockerService)
.useValue({ getDockerClient: vi.fn() })
@@ -23,6 +30,22 @@ describe('DockerModule', () => {
.useValue({ getConfig: vi.fn() })
.overrideProvider(DockerConfigService)
.useValue({ getConfig: vi.fn() })
.overrideProvider(DockerLogService)
.useValue({})
.overrideProvider(DockerNetworkService)
.useValue({})
.overrideProvider(DockerPortService)
.useValue({})
.overrideProvider(SubscriptionTrackerService)
.useValue({
registerTopic: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
})
.overrideProvider(SubscriptionHelperService)
.useValue({
createTrackedSubscription: vi.fn(),
})
.compile();
expect(module).toBeDefined();
@@ -46,25 +69,52 @@ describe('DockerModule', () => {
expect(service).toHaveProperty('getDockerClient');
});
it('should provide DockerEventService', async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DockerEventService,
{ provide: DockerService, useValue: { getDockerClient: vi.fn() } },
],
}).compile();
const service = module.get<DockerEventService>(DockerEventService);
expect(service).toBeInstanceOf(DockerEventService);
});
it('should provide DockerResolver', async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
DockerResolver,
{ provide: DockerService, useValue: {} },
{ provide: DockerService, useValue: { clearContainerCache: vi.fn() } },
{
provide: DockerConfigService,
useValue: {
defaultConfig: vi
.fn()
.mockReturnValue({ templateMappings: {}, skipTemplatePaths: [] }),
getConfig: vi
.fn()
.mockReturnValue({ templateMappings: {}, skipTemplatePaths: [] }),
validate: vi.fn().mockImplementation((config) => Promise.resolve(config)),
replaceConfig: vi.fn(),
},
},
{ provide: DockerOrganizerService, useValue: {} },
{ provide: DockerPhpService, useValue: { getContainerUpdateStatuses: vi.fn() } },
{
provide: DockerTemplateScannerService,
useValue: {
scanTemplates: vi.fn(),
syncMissingContainers: vi.fn(),
},
},
{
provide: DockerStatsService,
useValue: {
startStatsStream: vi.fn(),
stopStatsStream: vi.fn(),
},
},
{
provide: SubscriptionTrackerService,
useValue: {
registerTopic: vi.fn(),
},
},
{
provide: SubscriptionHelperService,
useValue: {
createTrackedSubscription: vi.fn(),
},
},
],
}).compile();

View File

@@ -2,27 +2,44 @@ import { Module } from '@nestjs/common';
import { JobModule } from '@app/unraid-api/cron/job.module.js';
import { ContainerStatusJob } from '@app/unraid-api/graph/resolvers/docker/container-status.job.js';
import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerContainerResolver } from '@app/unraid-api/graph/resolvers/docker/docker-container.resolver.js';
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docker-stats.service.js';
import { DockerTailscaleService } from '@app/unraid-api/graph/resolvers/docker/docker-tailscale.service.js';
import { DockerTemplateIconService } from '@app/unraid-api/graph/resolvers/docker/docker-template-icon.service.js';
import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js';
import { DockerMutationsResolver } from '@app/unraid-api/graph/resolvers/docker/docker.mutations.resolver.js';
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer-config.service.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
import { NotificationsModule } from '@app/unraid-api/graph/resolvers/notifications/notifications.module.js';
import { ServicesModule } from '@app/unraid-api/graph/services/services.module.js';
@Module({
imports: [JobModule],
imports: [JobModule, NotificationsModule, ServicesModule],
providers: [
// Services
DockerService,
DockerAutostartService,
DockerOrganizerConfigService,
DockerOrganizerService,
DockerManifestService,
DockerPhpService,
DockerConfigService,
// DockerEventService,
DockerTemplateScannerService,
DockerTemplateIconService,
DockerStatsService,
DockerTailscaleService,
DockerLogService,
DockerNetworkService,
DockerPortService,
// Jobs
ContainerStatusJob,

View File

@@ -45,6 +45,7 @@ describe('DockerMutationsResolver', () => {
state: ContainerState.RUNNING,
status: 'Up 2 hours',
names: ['test-container'],
isOrphaned: false,
};
vi.mocked(dockerService.start).mockResolvedValue(mockContainer);
@@ -65,6 +66,7 @@ describe('DockerMutationsResolver', () => {
state: ContainerState.EXITED,
status: 'Exited',
names: ['test-container'],
isOrphaned: false,
};
vi.mocked(dockerService.stop).mockResolvedValue(mockContainer);

View File

@@ -4,7 +4,11 @@ import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
import {
DockerAutostartEntryInput,
DockerContainer,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerMutations } from '@app/unraid-api/graph/resolvers/mutation/mutation.model.js';
@@ -32,4 +36,86 @@ export class DockerMutationsResolver {
public async stop(@Args('id', { type: () => PrefixedID }) id: string) {
return this.dockerService.stop(id);
}
@ResolveField(() => DockerContainer, { description: 'Pause (Suspend) a container' })
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
public async pause(@Args('id', { type: () => PrefixedID }) id: string) {
return this.dockerService.pause(id);
}
@ResolveField(() => DockerContainer, { description: 'Unpause (Resume) a container' })
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
public async unpause(@Args('id', { type: () => PrefixedID }) id: string) {
return this.dockerService.unpause(id);
}
@ResolveField(() => Boolean, { description: 'Remove a container' })
@UsePermissions({
action: AuthAction.DELETE_ANY,
resource: Resource.DOCKER,
})
public async removeContainer(
@Args('id', { type: () => PrefixedID }) id: string,
@Args('withImage', { type: () => Boolean, nullable: true }) withImage?: boolean
) {
return this.dockerService.removeContainer(id, { withImage });
}
@ResolveField(() => Boolean, {
description: 'Update auto-start configuration for Docker containers',
})
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
public async updateAutostartConfiguration(
@Args('entries', { type: () => [DockerAutostartEntryInput] })
entries: DockerAutostartEntryInput[],
@Args('persistUserPreferences', { type: () => Boolean, nullable: true })
persistUserPreferences?: boolean
) {
await this.dockerService.updateAutostartConfiguration(entries, {
persistUserPreferences,
});
return true;
}
@ResolveField(() => DockerContainer, { description: 'Update a container to the latest image' })
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
public async updateContainer(@Args('id', { type: () => PrefixedID }) id: string) {
return this.dockerService.updateContainer(id);
}
@ResolveField(() => [DockerContainer], {
description: 'Update multiple containers to the latest images',
})
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
public async updateContainers(
@Args('ids', { type: () => [PrefixedID] })
ids: string[]
) {
return this.dockerService.updateContainers(ids);
}
@ResolveField(() => [DockerContainer], {
description: 'Update all containers that have available updates',
})
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
public async updateAllContainers() {
return this.dockerService.updateAllContainers();
}
}

View File

@@ -3,11 +3,20 @@ import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docker-stats.service.js';
import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js';
import {
ContainerState,
DockerContainer,
DockerContainerLogs,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js';
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
import { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.js';
vi.mock('@app/unraid-api/utils/graphql-field-helper.js', () => ({
@@ -29,6 +38,22 @@ describe('DockerResolver', () => {
useValue: {
getContainers: vi.fn(),
getNetworks: vi.fn(),
getContainerLogSizes: vi.fn(),
getContainerLogs: vi.fn(),
clearContainerCache: vi.fn(),
},
},
{
provide: DockerConfigService,
useValue: {
defaultConfig: vi
.fn()
.mockReturnValue({ templateMappings: {}, skipTemplatePaths: [] }),
getConfig: vi
.fn()
.mockReturnValue({ templateMappings: {}, skipTemplatePaths: [] }),
validate: vi.fn().mockImplementation((config) => Promise.resolve(config)),
replaceConfig: vi.fn(),
},
},
{
@@ -43,6 +68,39 @@ describe('DockerResolver', () => {
getContainerUpdateStatuses: vi.fn(),
},
},
{
provide: DockerTemplateScannerService,
useValue: {
scanTemplates: vi.fn().mockResolvedValue({
scanned: 0,
matched: 0,
skipped: 0,
errors: [],
}),
syncMissingContainers: vi.fn().mockResolvedValue(false),
},
},
{
provide: DockerStatsService,
useValue: {
startStatsStream: vi.fn(),
stopStatsStream: vi.fn(),
},
},
{
provide: SubscriptionTrackerService,
useValue: {
registerTopic: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
},
},
{
provide: SubscriptionHelperService,
useValue: {
createTrackedSubscription: vi.fn(),
},
},
],
}).compile();
@@ -51,6 +109,8 @@ describe('DockerResolver', () => {
// Reset mocks before each test
vi.clearAllMocks();
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation(() => false);
vi.mocked(dockerService.getContainerLogSizes).mockResolvedValue(new Map());
});
it('should be defined', () => {
@@ -75,6 +135,7 @@ describe('DockerResolver', () => {
ports: [],
state: ContainerState.EXITED,
status: 'Exited',
isOrphaned: false,
},
{
id: '2',
@@ -87,16 +148,19 @@ describe('DockerResolver', () => {
ports: [],
state: ContainerState.RUNNING,
status: 'Up 2 hours',
isOrphaned: false,
},
];
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(false);
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation(() => false);
const mockInfo = {} as any;
const result = await resolver.containers(false, mockInfo);
expect(result).toEqual(mockContainers);
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs');
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRw');
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeLog');
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: false });
});
@@ -114,10 +178,13 @@ describe('DockerResolver', () => {
sizeRootFs: 1024000,
state: ContainerState.EXITED,
status: 'Exited',
isOrphaned: false,
},
];
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(true);
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => {
return field === 'sizeRootFs';
});
const mockInfo = {} as any;
@@ -127,10 +194,61 @@ describe('DockerResolver', () => {
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: true });
});
it('should request size when sizeRw field is requested', async () => {
const mockContainers: DockerContainer[] = [];
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => {
return field === 'sizeRw';
});
const mockInfo = {} as any;
await resolver.containers(false, mockInfo);
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRw');
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: true });
});
it('should fetch log sizes when sizeLog field is requested', async () => {
const mockContainers: DockerContainer[] = [
{
id: '1',
autoStart: false,
command: 'test',
names: ['/test-container'],
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
state: ContainerState.EXITED,
status: 'Exited',
isOrphaned: false,
},
];
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => {
if (field === 'sizeLog') return true;
return false;
});
const logSizeMap = new Map<string, number>([['test-container', 42]]);
vi.mocked(dockerService.getContainerLogSizes).mockResolvedValue(logSizeMap);
const mockInfo = {} as any;
const result = await resolver.containers(false, mockInfo);
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeLog');
expect(dockerService.getContainerLogSizes).toHaveBeenCalledWith(['test-container']);
expect(result[0]?.sizeLog).toBe(42);
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: false });
});
it('should request size when GraphQLFieldHelper indicates sizeRootFs is requested', async () => {
const mockContainers: DockerContainer[] = [];
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(true);
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation((_, field) => {
return field === 'sizeRootFs';
});
const mockInfo = {} as any;
@@ -142,7 +260,7 @@ describe('DockerResolver', () => {
it('should not request size when GraphQLFieldHelper indicates sizeRootFs is not requested', async () => {
const mockContainers: DockerContainer[] = [];
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(false);
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockImplementation(() => false);
const mockInfo = {} as any;
@@ -161,4 +279,22 @@ describe('DockerResolver', () => {
await resolver.containers(true, mockInfo);
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: true, size: false });
});
it('should fetch container logs with provided arguments', async () => {
const since = new Date('2024-01-01T00:00:00.000Z');
const logResult: DockerContainerLogs = {
containerId: '1',
lines: [],
cursor: since,
};
vi.mocked(dockerService.getContainerLogs).mockResolvedValue(logResult);
const result = await resolver.logs('1', since, 25);
expect(result).toEqual(logResult);
expect(dockerService.getContainerLogs).toHaveBeenCalledWith('1', {
since,
tail: 25,
});
});
});

View File

@@ -1,19 +1,41 @@
import { Args, Info, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
import {
Args,
GraphQLISODateTime,
Info,
Int,
Mutation,
Query,
ResolveField,
Resolver,
Subscription,
} from '@nestjs/graphql';
import type { GraphQLResolveInfo } from 'graphql';
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js';
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
import { GraphQLJSON } from 'graphql-scalars';
import { PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { UseFeatureFlag } from '@app/unraid-api/decorators/use-feature-flag.decorator.js';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerPhpService } from '@app/unraid-api/graph/resolvers/docker/docker-php.service.js';
import { DockerStatsService } from '@app/unraid-api/graph/resolvers/docker/docker-stats.service.js';
import { DockerTemplateSyncResult } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.model.js';
import { DockerTemplateScannerService } from '@app/unraid-api/graph/resolvers/docker/docker-template-scanner.service.js';
import { ExplicitStatusItem } from '@app/unraid-api/graph/resolvers/docker/docker-update-status.model.js';
import {
Docker,
DockerContainer,
DockerContainerLogs,
DockerContainerStats,
DockerNetwork,
DockerPortConflicts,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.service.js';
import { SubscriptionHelperService } from '@app/unraid-api/graph/services/subscription-helper.service.js';
import { SubscriptionTrackerService } from '@app/unraid-api/graph/services/subscription-tracker.service.js';
import { DEFAULT_ORGANIZER_ROOT_ID } from '@app/unraid-api/organizer/organizer.js';
import { ResolvedOrganizerV1 } from '@app/unraid-api/organizer/organizer.model.js';
import { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.js';
@@ -22,9 +44,20 @@ import { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.j
export class DockerResolver {
constructor(
private readonly dockerService: DockerService,
private readonly dockerConfigService: DockerConfigService,
private readonly dockerOrganizerService: DockerOrganizerService,
private readonly dockerPhpService: DockerPhpService
) {}
private readonly dockerPhpService: DockerPhpService,
private readonly dockerTemplateScannerService: DockerTemplateScannerService,
private readonly dockerStatsService: DockerStatsService,
private readonly subscriptionTracker: SubscriptionTrackerService,
private readonly subscriptionHelper: SubscriptionHelperService
) {
this.subscriptionTracker.registerTopic(
PUBSUB_CHANNEL.DOCKER_STATS,
() => this.dockerStatsService.startStatsStream(),
() => this.dockerStatsService.stopStatsStream()
);
}
@UsePermissions({
action: AuthAction.READ_ANY,
@@ -37,6 +70,17 @@ export class DockerResolver {
};
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => DockerContainer, { nullable: true })
public async container(@Args('id', { type: () => PrefixedID }) id: string) {
const containers = await this.dockerService.getContainers({ skipCache: false });
return containers.find((c) => c.id === id) ?? null;
}
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
@@ -46,8 +90,47 @@ export class DockerResolver {
@Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean,
@Info() info: GraphQLResolveInfo
) {
const requestsSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeRootFs');
return this.dockerService.getContainers({ skipCache, size: requestsSize });
const requestsRootFsSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeRootFs');
const requestsRwSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeRw');
const requestsLogSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeLog');
const containers = await this.dockerService.getContainers({
skipCache,
size: requestsRootFsSize || requestsRwSize,
});
if (requestsLogSize) {
const names = Array.from(
new Set(
containers
.map((container) => container.names?.[0]?.replace(/^\//, '') || null)
.filter((name): name is string => Boolean(name))
)
);
const logSizes = await this.dockerService.getContainerLogSizes(names);
containers.forEach((container) => {
const normalized = container.names?.[0]?.replace(/^\//, '') || '';
container.sizeLog = normalized ? (logSizes.get(normalized) ?? 0) : 0;
});
}
const wasSynced = await this.dockerTemplateScannerService.syncMissingContainers(containers);
return wasSynced ? await this.dockerService.getContainers({ skipCache: true }) : containers;
}
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => DockerContainerLogs)
public async logs(
@Args('id', { type: () => PrefixedID }) id: string,
@Args('since', { type: () => GraphQLISODateTime, nullable: true }) since?: Date | null,
@Args('tail', { type: () => Int, nullable: true }) tail?: number | null
) {
return this.dockerService.getContainerLogs(id, {
since: since ?? undefined,
tail,
});
}
@UsePermissions({
@@ -61,14 +144,27 @@ export class DockerResolver {
return this.dockerService.getNetworks({ skipCache });
}
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => DockerPortConflicts)
public async portConflicts(
@Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean
) {
return this.dockerService.getPortConflicts({ skipCache });
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@ResolveField(() => ResolvedOrganizerV1)
public async organizer() {
return this.dockerOrganizerService.resolveOrganizer();
public async organizer(
@Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean
) {
return this.dockerOrganizerService.resolveOrganizer(undefined, { skipCache });
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@@ -107,6 +203,11 @@ export class DockerResolver {
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
/**
* Deletes organizer entries (folders). When a folder is deleted, its container
* children are automatically appended to the end of the root folder via
* `addMissingResourcesToView`. Containers are never permanently deleted by this operation.
*/
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
@@ -137,6 +238,80 @@ export class DockerResolver {
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
@Mutation(() => ResolvedOrganizerV1)
public async moveDockerItemsToPosition(
@Args('sourceEntryIds', { type: () => [String] }) sourceEntryIds: string[],
@Args('destinationFolderId') destinationFolderId: string,
@Args('position', { type: () => Number }) position: number
) {
const organizer = await this.dockerOrganizerService.moveItemsToPosition({
sourceEntryIds,
destinationFolderId,
position,
});
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
@Mutation(() => ResolvedOrganizerV1)
public async renameDockerFolder(
@Args('folderId') folderId: string,
@Args('newName') newName: string
) {
const organizer = await this.dockerOrganizerService.renameFolderById({
folderId,
newName,
});
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
@Mutation(() => ResolvedOrganizerV1)
public async createDockerFolderWithItems(
@Args('name') name: string,
@Args('parentId', { nullable: true }) parentId?: string,
@Args('sourceEntryIds', { type: () => [String], nullable: true }) sourceEntryIds?: string[],
@Args('position', { type: () => Number, nullable: true }) position?: number
) {
const organizer = await this.dockerOrganizerService.createFolderWithItems({
name,
parentId: parentId ?? DEFAULT_ORGANIZER_ROOT_ID,
sourceEntryIds: sourceEntryIds ?? [],
position,
});
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
@Mutation(() => ResolvedOrganizerV1)
public async updateDockerViewPreferences(
@Args('viewId', { nullable: true, defaultValue: 'default' }) viewId: string,
@Args('prefs', { type: () => GraphQLJSON }) prefs: Record<string, unknown>
) {
const organizer = await this.dockerOrganizerService.updateViewPreferences({
viewId,
prefs,
});
return this.dockerOrganizerService.resolveOrganizer(organizer);
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.READ_ANY,
@@ -146,4 +321,48 @@ export class DockerResolver {
public async containerUpdateStatuses() {
return this.dockerPhpService.getContainerUpdateStatuses();
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
@Mutation(() => DockerTemplateSyncResult)
public async syncDockerTemplatePaths() {
return this.dockerTemplateScannerService.scanTemplates();
}
@UseFeatureFlag('ENABLE_NEXT_DOCKER_RELEASE')
@UsePermissions({
action: AuthAction.UPDATE_ANY,
resource: Resource.DOCKER,
})
@Mutation(() => Boolean, {
description:
'Reset Docker template mappings to defaults. Use this to recover from corrupted state.',
})
public async resetDockerTemplateMappings(): Promise<boolean> {
const defaultConfig = this.dockerConfigService.defaultConfig();
const currentConfig = this.dockerConfigService.getConfig();
const resetConfig = {
...currentConfig,
templateMappings: defaultConfig.templateMappings,
skipTemplatePaths: defaultConfig.skipTemplatePaths,
};
const validated = await this.dockerConfigService.validate(resetConfig);
this.dockerConfigService.replaceConfig(validated);
await this.dockerService.clearContainerCache();
return true;
}
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.DOCKER,
})
@Subscription(() => DockerContainerStats, {
resolve: (payload) => payload.dockerContainerStats,
})
public dockerContainerStats() {
return this.subscriptionHelper.createTrackedSubscription(PUBSUB_CHANNEL.DOCKER_STATS);
}
}

View File

@@ -0,0 +1,169 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Test, TestingModule } from '@nestjs/testing';
import { mkdtemp, readFile, rm } from 'fs/promises';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
// Mock dependencies that are not focus of integration
const mockNotificationsService = {
notifyIfUnique: vi.fn(),
};
const mockDockerConfigService = {
getConfig: vi.fn().mockReturnValue({ templateMappings: {} }),
};
const mockDockerManifestService = {
getCachedUpdateStatuses: vi.fn().mockResolvedValue({}),
isUpdateAvailableCached: vi.fn().mockResolvedValue(false),
};
const mockCacheManager = {
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
};
// Hoisted mock for paths
const { mockPaths } = vi.hoisted(() => ({
mockPaths: {
'docker-autostart': '',
'docker-userprefs': '',
'docker-socket': '/var/run/docker.sock',
},
}));
vi.mock('@app/store/index.js', () => ({
getters: {
paths: () => mockPaths,
emhttp: () => ({ networks: [] }),
},
}));
// Check for Docker availability
let dockerAvailable = false;
try {
const Docker = (await import('dockerode')).default;
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
await docker.ping();
dockerAvailable = true;
} catch {
console.warn('Docker not available or not accessible at /var/run/docker.sock');
}
describe.runIf(dockerAvailable)('DockerService Integration', () => {
let service: DockerService;
let autostartService: DockerAutostartService;
let module: TestingModule;
let tempDir: string;
beforeAll(async () => {
// Setup temp dir for config files
tempDir = await mkdtemp(join(tmpdir(), 'unraid-api-docker-test-'));
mockPaths['docker-autostart'] = join(tempDir, 'docker-autostart');
mockPaths['docker-userprefs'] = join(tempDir, 'docker-userprefs');
module = await Test.createTestingModule({
providers: [
DockerService,
DockerAutostartService,
DockerLogService,
DockerNetworkService,
DockerPortService,
{ provide: CACHE_MANAGER, useValue: mockCacheManager },
{ provide: DockerConfigService, useValue: mockDockerConfigService },
{ provide: DockerManifestService, useValue: mockDockerManifestService },
{ provide: NotificationsService, useValue: mockNotificationsService },
],
}).compile();
service = module.get<DockerService>(DockerService);
autostartService = module.get<DockerAutostartService>(DockerAutostartService);
});
afterAll(async () => {
if (tempDir) {
await rm(tempDir, { recursive: true, force: true });
}
});
it('should fetch containers from docker daemon', async () => {
const containers = await service.getContainers({ skipCache: true });
expect(Array.isArray(containers)).toBe(true);
if (containers.length > 0) {
expect(containers[0]).toHaveProperty('id');
expect(containers[0]).toHaveProperty('names');
expect(containers[0].state).toBeDefined();
}
});
it('should fetch networks from docker daemon', async () => {
const networks = await service.getNetworks({ skipCache: true });
expect(Array.isArray(networks)).toBe(true);
// Default networks (bridge, host, null) should always exist
expect(networks.length).toBeGreaterThan(0);
const bridge = networks.find((n) => n.name === 'bridge');
expect(bridge).toBeDefined();
});
it('should manage autostart configuration in temp files', async () => {
const containers = await service.getContainers({ skipCache: true });
if (containers.length === 0) {
console.warn('No containers found, skipping autostart write test');
return;
}
const target = containers[0];
// Ensure name is valid for autostart file (strip /)
const primaryName = autostartService.getContainerPrimaryName(target as any);
expect(primaryName).toBeTruthy();
const entry = {
id: target.id,
autoStart: true,
wait: 10,
};
await service.updateAutostartConfiguration([entry], { persistUserPreferences: true });
// Verify file content
try {
const content = await readFile(mockPaths['docker-autostart'], 'utf8');
expect(content).toContain(primaryName);
expect(content).toContain('10');
} catch (error: any) {
// If file doesn't exist, it might be because logic didn't write anything (e.g. name issue)
// But we expect it to write if container exists and we passed valid entry
throw new Error(`Failed to read autostart file: ${error.message}`);
}
});
it('should get container logs using dockerode', async () => {
const containers = await service.getContainers({ skipCache: true });
const running = containers.find((c) => c.state === 'RUNNING'); // Enum value is string 'RUNNING'
if (!running) {
console.warn('No running containers found, skipping log test');
return;
}
// This test verifies that the execa -> dockerode switch works for logs
// If it fails, it likely means the log parsing or dockerode interaction is wrong.
const logs = await service.getContainerLogs(running.id, { tail: 10 });
expect(logs).toBeDefined();
expect(logs.containerId).toBe(running.id);
expect(Array.isArray(logs.lines)).toBe(true);
// We can't guarantee lines length > 0 if container is silent, but it shouldn't throw.
});
});

View File

@@ -7,8 +7,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
// Import the mocked pubsub parts
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
import {
ContainerPortType,
ContainerState,
DockerContainer,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.service.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
// Mock pubsub
vi.mock('@app/core/pubsub.js', () => ({
@@ -24,36 +35,58 @@ interface DockerError extends NodeJS.ErrnoException {
address: string;
}
const mockContainer = {
start: vi.fn(),
stop: vi.fn(),
};
const { mockDockerInstance, mockListContainers, mockGetContainer, mockListNetworks, mockContainer } =
vi.hoisted(() => {
const mockContainer = {
start: vi.fn(),
stop: vi.fn(),
pause: vi.fn(),
unpause: vi.fn(),
inspect: vi.fn(),
};
// Create properly typed mock functions
const mockListContainers = vi.fn();
const mockGetContainer = vi.fn().mockReturnValue(mockContainer);
const mockListNetworks = vi.fn();
const mockListContainers = vi.fn();
const mockGetContainer = vi.fn().mockReturnValue(mockContainer);
const mockListNetworks = vi.fn();
const mockDockerInstance = {
getContainer: mockGetContainer,
listContainers: mockListContainers,
listNetworks: mockListNetworks,
modem: {
Promise: Promise,
protocol: 'http',
socketPath: '/var/run/docker.sock',
headers: {},
sshOptions: {
agentForward: undefined,
},
},
} as unknown as Docker;
const mockDockerInstance = {
getContainer: mockGetContainer,
listContainers: mockListContainers,
listNetworks: mockListNetworks,
modem: {
Promise: Promise,
protocol: 'http',
socketPath: '/var/run/docker.sock',
headers: {},
sshOptions: {
agentForward: undefined,
},
},
} as unknown as Docker;
vi.mock('dockerode', () => {
return {
default: vi.fn().mockImplementation(() => mockDockerInstance),
};
});
return {
mockDockerInstance,
mockListContainers,
mockGetContainer,
mockListNetworks,
mockContainer,
};
});
vi.mock('@app/unraid-api/graph/resolvers/docker/utils/docker-client.js', () => ({
getDockerClient: vi.fn().mockReturnValue(mockDockerInstance),
}));
vi.mock('execa', () => ({
execa: vi.fn(),
}));
const { mockEmhttpGetter } = vi.hoisted(() => ({
mockEmhttpGetter: vi.fn().mockReturnValue({
networks: [],
var: {},
}),
}));
// Mock the store getters
vi.mock('@app/store/index.js', () => ({
@@ -61,15 +94,21 @@ vi.mock('@app/store/index.js', () => ({
docker: vi.fn().mockReturnValue({ containers: [] }),
paths: vi.fn().mockReturnValue({
'docker-autostart': '/path/to/docker-autostart',
'docker-userprefs': '/path/to/docker-userprefs',
'docker-socket': '/var/run/docker.sock',
'var-run': '/var/run',
}),
emhttp: mockEmhttpGetter,
},
}));
// Mock fs/promises
// Mock fs/promises (stat only)
const { statMock } = vi.hoisted(() => ({
statMock: vi.fn().mockResolvedValue({ size: 0 }),
}));
vi.mock('fs/promises', () => ({
readFile: vi.fn().mockResolvedValue(''),
stat: statMock,
}));
// Mock Cache Manager
@@ -79,6 +118,67 @@ const mockCacheManager = {
del: vi.fn(),
};
// Mock DockerConfigService
const mockDockerConfigService = {
getConfig: vi.fn().mockReturnValue({
updateCheckCronSchedule: '0 6 * * *',
templateMappings: {},
skipTemplatePaths: [],
}),
replaceConfig: vi.fn(),
validate: vi.fn((config) => Promise.resolve(config)),
};
const mockDockerManifestService = {
refreshDigests: vi.fn().mockResolvedValue(true),
getCachedUpdateStatuses: vi.fn().mockResolvedValue({}),
isUpdateAvailableCached: vi.fn().mockResolvedValue(false),
};
// Mock NotificationsService
const mockNotificationsService = {
notifyIfUnique: vi.fn().mockResolvedValue(null),
};
// Mock DockerAutostartService
const mockDockerAutostartService = {
refreshAutoStartEntries: vi.fn().mockResolvedValue(undefined),
getAutoStarts: vi.fn().mockResolvedValue([]),
getContainerPrimaryName: vi.fn((c) => {
if ('Names' in c) return c.Names[0]?.replace(/^\//, '') || null;
if ('names' in c) return c.names[0]?.replace(/^\//, '') || null;
return null;
}),
getAutoStartEntry: vi.fn(),
updateAutostartConfiguration: vi.fn().mockResolvedValue(undefined),
};
// Mock new services
const mockDockerLogService = {
getContainerLogSizes: vi.fn().mockResolvedValue(new Map([['test-container', 1024]])),
getContainerLogs: vi.fn().mockResolvedValue({ lines: [], cursor: null }),
};
const mockDockerNetworkService = {
getNetworks: vi.fn().mockResolvedValue([]),
};
// Use a real-ish mock for DockerPortService since it is used in transformContainer
const mockDockerPortService = {
deduplicateContainerPorts: vi.fn((ports) => {
if (!ports) return [];
// Simple dedupe logic for test
const seen = new Set();
return ports.filter((p) => {
const key = `${p.PrivatePort}-${p.PublicPort}-${p.Type}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}),
calculateConflicts: vi.fn().mockReturnValue({ containerPorts: [], lanPorts: [] }),
};
describe('DockerService', () => {
let service: DockerService;
@@ -88,9 +188,41 @@ describe('DockerService', () => {
mockListNetworks.mockReset();
mockContainer.start.mockReset();
mockContainer.stop.mockReset();
mockContainer.pause.mockReset();
mockContainer.unpause.mockReset();
mockContainer.inspect.mockReset();
mockCacheManager.get.mockReset();
mockCacheManager.set.mockReset();
mockCacheManager.del.mockReset();
statMock.mockReset();
statMock.mockResolvedValue({ size: 0 });
mockEmhttpGetter.mockReset();
mockEmhttpGetter.mockReturnValue({
networks: [],
var: {},
});
mockDockerConfigService.getConfig.mockReturnValue({
updateCheckCronSchedule: '0 6 * * *',
templateMappings: {},
skipTemplatePaths: [],
});
mockDockerManifestService.refreshDigests.mockReset();
mockDockerManifestService.refreshDigests.mockResolvedValue(true);
mockDockerAutostartService.refreshAutoStartEntries.mockReset();
mockDockerAutostartService.getAutoStarts.mockReset();
mockDockerAutostartService.getAutoStartEntry.mockReset();
mockDockerAutostartService.updateAutostartConfiguration.mockReset();
mockDockerLogService.getContainerLogSizes.mockReset();
mockDockerLogService.getContainerLogSizes.mockResolvedValue(new Map([['test-container', 1024]]));
mockDockerLogService.getContainerLogs.mockReset();
mockDockerNetworkService.getNetworks.mockReset();
mockDockerPortService.deduplicateContainerPorts.mockClear();
mockDockerPortService.calculateConflicts.mockReset();
const module: TestingModule = await Test.createTestingModule({
providers: [
@@ -99,6 +231,34 @@ describe('DockerService', () => {
provide: CACHE_MANAGER,
useValue: mockCacheManager,
},
{
provide: DockerConfigService,
useValue: mockDockerConfigService,
},
{
provide: DockerManifestService,
useValue: mockDockerManifestService,
},
{
provide: NotificationsService,
useValue: mockNotificationsService,
},
{
provide: DockerAutostartService,
useValue: mockDockerAutostartService,
},
{
provide: DockerLogService,
useValue: mockDockerLogService,
},
{
provide: DockerNetworkService,
useValue: mockDockerNetworkService,
},
{
provide: DockerPortService,
useValue: mockDockerPortService,
},
],
}).compile();
@@ -109,65 +269,6 @@ describe('DockerService', () => {
expect(service).toBeDefined();
});
it('should use separate cache keys for containers with and without size', async () => {
const mockContainersWithoutSize = [
{
Id: 'abc123',
Names: ['/test-container'],
Image: 'test-image',
ImageID: 'test-image-id',
Command: 'test',
Created: 1234567890,
State: 'exited',
Status: 'Exited',
Ports: [],
Labels: {},
HostConfig: { NetworkMode: 'bridge' },
NetworkSettings: {},
Mounts: [],
},
];
const mockContainersWithSize = [
{
Id: 'abc123',
Names: ['/test-container'],
Image: 'test-image',
ImageID: 'test-image-id',
Command: 'test',
Created: 1234567890,
State: 'exited',
Status: 'Exited',
Ports: [],
Labels: {},
HostConfig: { NetworkMode: 'bridge' },
NetworkSettings: {},
Mounts: [],
SizeRootFs: 1024000,
},
];
// First call without size
mockListContainers.mockResolvedValue(mockContainersWithoutSize);
mockCacheManager.get.mockResolvedValue(undefined);
await service.getContainers({ size: false });
expect(mockCacheManager.set).toHaveBeenCalledWith('docker_containers', expect.any(Array), 60000);
// Second call with size
mockListContainers.mockResolvedValue(mockContainersWithSize);
mockCacheManager.get.mockResolvedValue(undefined);
await service.getContainers({ size: true });
expect(mockCacheManager.set).toHaveBeenCalledWith(
'docker_containers_with_size',
expect.any(Array),
60000
);
});
it('should get containers', async () => {
const mockContainers = [
{
@@ -190,308 +291,100 @@ describe('DockerService', () => {
];
mockListContainers.mockResolvedValue(mockContainers);
mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss
mockCacheManager.get.mockResolvedValue(undefined);
const result = await service.getContainers({ skipCache: true }); // Skip cache for direct fetch test
const result = await service.getContainers({ skipCache: true });
expect(result).toEqual([
expect(result).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'abc123def456',
names: ['/test-container'],
}),
])
);
expect(mockListContainers).toHaveBeenCalled();
expect(mockDockerAutostartService.refreshAutoStartEntries).toHaveBeenCalled();
expect(mockDockerPortService.deduplicateContainerPorts).toHaveBeenCalled();
});
it('should update auto-start configuration', async () => {
mockListContainers.mockResolvedValue([
{
id: 'abc123def456',
autoStart: false,
command: 'test',
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
sizeRootFs: undefined,
state: ContainerState.EXITED,
status: 'Exited',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
names: ['/test-container'],
Id: 'abc123',
Names: ['/alpha'],
State: 'running',
},
]);
expect(mockListContainers).toHaveBeenCalledWith({
all: true,
size: false,
});
expect(mockCacheManager.set).toHaveBeenCalled(); // Ensure cache is set
});
const input = [{ id: 'abc123', autoStart: true, wait: 15 }];
await service.updateAutostartConfiguration(input, { persistUserPreferences: true });
it('should start container', async () => {
const mockContainers = [
{
Id: 'abc123def456',
Names: ['/test-container'],
Image: 'test-image',
ImageID: 'test-image-id',
Command: 'test',
Created: 1234567890,
State: 'running',
Status: 'Up 2 hours',
Ports: [],
Labels: {},
HostConfig: {
NetworkMode: 'bridge',
},
NetworkSettings: {},
Mounts: [],
},
];
mockListContainers.mockResolvedValue(mockContainers);
mockContainer.start.mockResolvedValue(undefined);
mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss for getContainers call
const result = await service.start('abc123def456');
expect(result).toEqual({
id: 'abc123def456',
autoStart: false,
command: 'test',
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
sizeRootFs: undefined,
state: ContainerState.RUNNING,
status: 'Up 2 hours',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
names: ['/test-container'],
});
expect(mockContainer.start).toHaveBeenCalled();
expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY);
expect(mockListContainers).toHaveBeenCalled();
expect(mockCacheManager.set).toHaveBeenCalled();
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, {
info: {
apps: { installed: 1, running: 1 },
},
});
});
it('should stop container', async () => {
const mockContainers = [
{
Id: 'abc123def456',
Names: ['/test-container'],
Image: 'test-image',
ImageID: 'test-image-id',
Command: 'test',
Created: 1234567890,
State: 'exited',
Status: 'Exited',
Ports: [],
Labels: {},
HostConfig: {
NetworkMode: 'bridge',
},
NetworkSettings: {},
Mounts: [],
},
];
mockListContainers.mockResolvedValue(mockContainers);
mockContainer.stop.mockResolvedValue(undefined);
mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss for getContainers calls
const result = await service.stop('abc123def456');
expect(result).toEqual({
id: 'abc123def456',
autoStart: false,
command: 'test',
created: 1234567890,
image: 'test-image',
imageId: 'test-image-id',
ports: [],
sizeRootFs: undefined,
state: ContainerState.EXITED,
status: 'Exited',
labels: {},
hostConfig: {
networkMode: 'bridge',
},
networkSettings: {},
mounts: [],
names: ['/test-container'],
});
expect(mockContainer.stop).toHaveBeenCalledWith({ t: 10 });
expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY);
expect(mockListContainers).toHaveBeenCalled();
expect(mockCacheManager.set).toHaveBeenCalled();
expect(pubsub.publish).toHaveBeenCalledWith(PUBSUB_CHANNEL.INFO, {
info: {
apps: { installed: 1, running: 0 },
},
});
});
it('should throw error if container not found after start', async () => {
mockListContainers.mockResolvedValue([]);
mockContainer.start.mockResolvedValue(undefined);
mockCacheManager.get.mockResolvedValue(undefined);
await expect(service.start('not-found')).rejects.toThrow(
'Container not-found not found after starting'
expect(mockDockerAutostartService.updateAutostartConfiguration).toHaveBeenCalledWith(
input,
expect.any(Array),
{ persistUserPreferences: true }
);
expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY);
});
it('should throw error if container not found after stop', async () => {
mockListContainers.mockResolvedValue([]);
mockContainer.stop.mockResolvedValue(undefined);
mockCacheManager.get.mockResolvedValue(undefined);
await expect(service.stop('not-found')).rejects.toThrow(
'Container not-found not found after stopping'
);
expect(mockCacheManager.del).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY);
});
it('should get networks', async () => {
const mockNetworks = [
{
Id: 'network1',
Name: 'bridge',
Created: '2023-01-01T00:00:00Z',
Scope: 'local',
Driver: 'bridge',
EnableIPv6: false,
IPAM: {
Driver: 'default',
Config: [
{
Subnet: '172.17.0.0/16',
Gateway: '172.17.0.1',
},
],
},
Internal: false,
Attachable: false,
Ingress: false,
ConfigFrom: {
Network: '',
},
ConfigOnly: false,
Containers: {},
Options: {
'com.docker.network.bridge.default_bridge': 'true',
'com.docker.network.bridge.enable_icc': 'true',
'com.docker.network.bridge.enable_ip_masquerade': 'true',
'com.docker.network.bridge.host_binding_ipv4': '0.0.0.0',
'com.docker.network.bridge.name': 'docker0',
'com.docker.network.driver.mtu': '1500',
},
Labels: {},
},
];
mockListNetworks.mockResolvedValue(mockNetworks);
mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss
const result = await service.getNetworks({ skipCache: true }); // Skip cache for direct fetch test
expect(result).toMatchInlineSnapshot(`
[
{
"attachable": false,
"configFrom": {
"Network": "",
},
"configOnly": false,
"containers": {},
"created": "2023-01-01T00:00:00Z",
"driver": "bridge",
"enableIPv6": false,
"id": "network1",
"ingress": false,
"internal": false,
"ipam": {
"Config": [
{
"Gateway": "172.17.0.1",
"Subnet": "172.17.0.0/16",
},
],
"Driver": "default",
},
"labels": {},
"name": "bridge",
"options": {
"com.docker.network.bridge.default_bridge": "true",
"com.docker.network.bridge.enable_icc": "true",
"com.docker.network.bridge.enable_ip_masquerade": "true",
"com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
"com.docker.network.bridge.name": "docker0",
"com.docker.network.driver.mtu": "1500",
},
"scope": "local",
},
]
`);
expect(mockListNetworks).toHaveBeenCalled();
expect(mockCacheManager.set).toHaveBeenCalled(); // Ensure cache is set
});
it('should handle empty networks list', async () => {
mockListNetworks.mockResolvedValue([]);
mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss
const result = await service.getNetworks({ skipCache: true }); // Skip cache for direct fetch test
expect(result).toEqual([]);
expect(mockListNetworks).toHaveBeenCalled();
expect(mockCacheManager.set).toHaveBeenCalled(); // Ensure cache is set
});
it('should handle docker error when getting networks', async () => {
const error = new Error('Docker error') as DockerError;
error.code = 'ENOENT';
error.address = '/var/run/docker.sock';
mockListNetworks.mockRejectedValue(error);
mockCacheManager.get.mockResolvedValue(undefined); // Simulate cache miss
await expect(service.getNetworks({ skipCache: true })).rejects.toThrow(
'Docker socket unavailable.'
);
expect(mockListNetworks).toHaveBeenCalled();
expect(mockCacheManager.set).not.toHaveBeenCalled(); // Ensure cache is NOT set on error
it('should delegate getContainerLogSizes to DockerLogService', async () => {
const sizes = await service.getContainerLogSizes(['test-container']);
expect(mockDockerLogService.getContainerLogSizes).toHaveBeenCalledWith(['test-container']);
expect(sizes.get('test-container')).toBe(1024);
});
describe('getAppInfo', () => {
// Common mock containers for these tests
const mockContainersForMethods = [
{ id: 'abc1', state: ContainerState.RUNNING },
{ id: 'def2', state: ContainerState.EXITED },
] as DockerContainer[];
it('should return correct app info object', async () => {
// Mock cache response for getContainers call
mockCacheManager.get.mockResolvedValue(mockContainersForMethods);
const result = await service.getAppInfo(); // Call the renamed method
const result = await service.getAppInfo();
expect(result).toEqual({
info: {
apps: { installed: 2, running: 1 },
},
});
// getContainers should now be called only ONCE from cache
expect(mockCacheManager.get).toHaveBeenCalledTimes(1);
expect(mockCacheManager.get).toHaveBeenCalledWith(DockerService.CONTAINER_CACHE_KEY);
});
});
describe('transformContainer', () => {
it('deduplicates ports that only differ by bound IP addresses', () => {
mockEmhttpGetter.mockReturnValue({
networks: [{ ipaddr: ['192.168.0.10'] }],
var: {},
});
const container = {
Id: 'duplicate-ports',
Names: ['/duplicate-ports'],
Image: 'test-image',
ImageID: 'sha256:123',
Command: 'test',
Created: 1700000000,
State: 'running',
Status: 'Up 2 hours',
Ports: [
{ IP: '0.0.0.0', PrivatePort: 8080, PublicPort: 8080, Type: 'tcp' },
{ IP: '::', PrivatePort: 8080, PublicPort: 8080, Type: 'tcp' },
{ IP: '0.0.0.0', PrivatePort: 5000, PublicPort: 5000, Type: 'udp' },
],
Labels: {},
HostConfig: { NetworkMode: 'bridge' },
NetworkSettings: { Networks: {} },
Mounts: [],
} as Docker.ContainerInfo;
service.transformContainer(container);
expect(mockDockerPortService.deduplicateContainerPorts).toHaveBeenCalledWith(
container.Ports
);
});
});
});

View File

@@ -1,20 +1,30 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { readFile } from 'fs/promises';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type Cache } from 'cache-manager';
import Docker from 'dockerode';
import { execa } from 'execa';
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
import { catchHandlers } from '@app/core/utils/misc/catch-handlers.js';
import { sleep } from '@app/core/utils/misc/sleep.js';
import { getters } from '@app/store/index.js';
import { getLanIp } from '@app/core/utils/network.js';
import { DockerAutostartService } from '@app/unraid-api/graph/resolvers/docker/docker-autostart.service.js';
import { DockerConfigService } from '@app/unraid-api/graph/resolvers/docker/docker-config.service.js';
import { DockerLogService } from '@app/unraid-api/graph/resolvers/docker/docker-log.service.js';
import { DockerManifestService } from '@app/unraid-api/graph/resolvers/docker/docker-manifest.service.js';
import { DockerNetworkService } from '@app/unraid-api/graph/resolvers/docker/docker-network.service.js';
import { DockerPortService } from '@app/unraid-api/graph/resolvers/docker/docker-port.service.js';
import {
ContainerPortType,
ContainerState,
DockerAutostartEntryInput,
DockerContainer,
DockerContainerLogs,
DockerNetwork,
DockerPortConflicts,
} from '@app/unraid-api/graph/resolvers/docker/docker.model.js';
import { getDockerClient } from '@app/unraid-api/graph/resolvers/docker/utils/docker-client.js';
interface ContainerListingOptions extends Docker.ContainerListOptions {
skipCache: boolean;
@@ -27,25 +37,26 @@ interface NetworkListingOptions {
@Injectable()
export class DockerService {
private client: Docker;
private autoStarts: string[] = [];
private readonly logger = new Logger(DockerService.name);
public static readonly CONTAINER_CACHE_KEY = 'docker_containers';
public static readonly CONTAINER_WITH_SIZE_CACHE_KEY = 'docker_containers_with_size';
public static readonly NETWORK_CACHE_KEY = 'docker_networks';
public static readonly CACHE_TTL_SECONDS = 60; // Cache for 60 seconds
public static readonly CACHE_TTL_SECONDS = 60;
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {
this.client = this.getDockerClient();
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
private readonly dockerConfigService: DockerConfigService,
private readonly dockerManifestService: DockerManifestService,
private readonly autostartService: DockerAutostartService,
private readonly dockerLogService: DockerLogService,
private readonly dockerNetworkService: DockerNetworkService,
private readonly dockerPortService: DockerPortService
) {
this.client = getDockerClient();
}
public getDockerClient() {
return new Docker({
socketPath: '/var/run/docker.sock',
});
}
async getAppInfo() {
public async getAppInfo() {
const containers = await this.getContainers({ skipCache: false });
const installedCount = containers.length;
const runningCount = containers.filter(
@@ -65,31 +76,47 @@ export class DockerService {
* @see https://github.com/limetech/webgui/issues/502#issue-480992547
*/
public async getAutoStarts(): Promise<string[]> {
const autoStartFile = await readFile(getters.paths()['docker-autostart'], 'utf8')
.then((file) => file.toString())
.catch(() => '');
return autoStartFile.split('\n');
return this.autostartService.getAutoStarts();
}
public transformContainer(container: Docker.ContainerInfo): DockerContainer {
public transformContainer(container: Docker.ContainerInfo): Omit<DockerContainer, 'isOrphaned'> {
const sizeValue = (container as Docker.ContainerInfo & { SizeRootFs?: number }).SizeRootFs;
const primaryName = this.autostartService.getContainerPrimaryName(container) ?? '';
const autoStartEntry = primaryName
? this.autostartService.getAutoStartEntry(primaryName)
: undefined;
const lanIp = getLanIp();
const lanPortStrings: string[] = [];
const uniquePorts = this.dockerPortService.deduplicateContainerPorts(container.Ports);
const transformed: DockerContainer = {
const transformedPorts = uniquePorts.map((port) => {
if (port.PublicPort) {
const lanPort = lanIp ? `${lanIp}:${port.PublicPort}` : `${port.PublicPort}`;
if (lanPort) {
lanPortStrings.push(lanPort);
}
}
return {
ip: port.IP || '',
privatePort: port.PrivatePort,
publicPort: port.PublicPort,
type:
ContainerPortType[
(port.Type || 'tcp').toUpperCase() as keyof typeof ContainerPortType
] || ContainerPortType.TCP,
};
});
const transformed: Omit<DockerContainer, 'isOrphaned'> = {
id: container.Id,
names: container.Names,
image: container.Image,
imageId: container.ImageID,
command: container.Command,
created: container.Created,
ports: container.Ports.map((port) => ({
ip: port.IP || '',
privatePort: port.PrivatePort,
publicPort: port.PublicPort,
type:
ContainerPortType[port.Type.toUpperCase() as keyof typeof ContainerPortType] ||
ContainerPortType.TCP,
})),
ports: transformedPorts,
sizeRootFs: sizeValue,
sizeRw: (container as Docker.ContainerInfo & { SizeRw?: number }).SizeRw,
labels: container.Labels ?? {},
state:
typeof container.State === 'string'
@@ -102,9 +129,15 @@ export class DockerService {
},
networkSettings: container.NetworkSettings,
mounts: container.Mounts,
autoStart: this.autoStarts.includes(container.Names[0].split('/')[1]),
autoStart: Boolean(autoStartEntry),
autoStartOrder: autoStartEntry?.order,
autoStartWait: autoStartEntry?.wait,
};
if (lanPortStrings.length > 0) {
transformed.lanIpPorts = lanPortStrings;
}
return transformed;
}
@@ -129,66 +162,65 @@ export class DockerService {
}
this.logger.debug(`Updating docker container cache (${size ? 'with' : 'without'} size)`);
const rawContainers =
(await this.client
.listContainers({
all,
size,
...listOptions,
})
.catch(catchHandlers.docker)) ?? [];
let rawContainers: Docker.ContainerInfo[] = [];
try {
rawContainers = await this.client.listContainers({
all,
size,
...listOptions,
});
} catch (error) {
this.handleDockerListError(error);
}
this.autoStarts = await this.getAutoStarts();
await this.autostartService.refreshAutoStartEntries();
const containers = rawContainers.map((container) => this.transformContainer(container));
await this.cacheManager.set(cacheKey, containers, DockerService.CACHE_TTL_SECONDS * 1000);
return containers;
const config = this.dockerConfigService.getConfig();
const containersWithTemplatePaths = containers.map((c) => {
const containerName = c.names[0]?.replace(/^\//, '').toLowerCase() ?? '';
const templatePath = config.templateMappings?.[containerName] || undefined;
return {
...c,
templatePath,
isOrphaned: !templatePath,
};
});
await this.cacheManager.set(
cacheKey,
containersWithTemplatePaths,
DockerService.CACHE_TTL_SECONDS * 1000
);
return containersWithTemplatePaths;
}
public async getPortConflicts({
skipCache = false,
}: {
skipCache?: boolean;
} = {}): Promise<DockerPortConflicts> {
const containers = await this.getContainers({ skipCache });
return this.dockerPortService.calculateConflicts(containers);
}
public async getContainerLogSizes(containerNames: string[]): Promise<Map<string, number>> {
return this.dockerLogService.getContainerLogSizes(containerNames);
}
public async getContainerLogs(
id: string,
options?: { since?: Date | null; tail?: number | null }
): Promise<DockerContainerLogs> {
return this.dockerLogService.getContainerLogs(id, options);
}
/**
* Get all Docker networks
* @returns All the in/active Docker networks on the system.
*/
public async getNetworks({ skipCache }: NetworkListingOptions): Promise<DockerNetwork[]> {
if (!skipCache) {
const cachedNetworks = await this.cacheManager.get<DockerNetwork[]>(
DockerService.NETWORK_CACHE_KEY
);
if (cachedNetworks) {
this.logger.debug('Using docker network cache');
return cachedNetworks;
}
}
this.logger.debug('Updating docker network cache');
const rawNetworks = await this.client.listNetworks().catch(catchHandlers.docker);
const networks = rawNetworks.map(
(network) =>
({
name: network.Name || '',
id: network.Id || '',
created: network.Created || '',
scope: network.Scope || '',
driver: network.Driver || '',
enableIPv6: network.EnableIPv6 || false,
ipam: network.IPAM || {},
internal: network.Internal || false,
attachable: network.Attachable || false,
ingress: network.Ingress || false,
configFrom: network.ConfigFrom || {},
configOnly: network.ConfigOnly || false,
containers: network.Containers || {},
options: network.Options || {},
labels: network.Labels || {},
}) as DockerNetwork
);
await this.cacheManager.set(
DockerService.NETWORK_CACHE_KEY,
networks,
DockerService.CACHE_TTL_SECONDS * 1000
);
return networks;
public async getNetworks(options: NetworkListingOptions): Promise<DockerNetwork[]> {
return this.dockerNetworkService.getNetworks(options);
}
public async clearContainerCache(): Promise<void> {
@@ -214,6 +246,45 @@ export class DockerService {
return updatedContainer;
}
public async removeContainer(id: string, options?: { withImage?: boolean }): Promise<boolean> {
const container = this.client.getContainer(id);
try {
const inspectData = options?.withImage ? await container.inspect() : null;
const imageId = inspectData?.Image;
await container.remove({ force: true });
this.logger.debug(`Removed container ${id}`);
if (options?.withImage && imageId) {
try {
const image = this.client.getImage(imageId);
await image.remove({ force: true });
this.logger.debug(`Removed image ${imageId} for container ${id}`);
} catch (imageError) {
this.logger.warn(`Failed to remove image ${imageId}:`, imageError);
}
}
await this.clearContainerCache();
this.logger.debug(`Invalidated container caches after removing ${id}`);
const appInfo = await this.getAppInfo();
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
return true;
} catch (error) {
this.logger.error(`Failed to remove container ${id}:`, error);
throw new Error(`Failed to remove container ${id}`);
}
}
public async updateAutostartConfiguration(
entries: DockerAutostartEntryInput[],
options?: { persistUserPreferences?: boolean }
): Promise<void> {
const containers = await this.getContainers({ skipCache: true });
await this.autostartService.updateAutostartConfiguration(entries, containers, options);
await this.clearContainerCache();
}
public async stop(id: string): Promise<DockerContainer> {
const container = this.client.getContainer(id);
await container.stop({ t: 10 });
@@ -243,4 +314,162 @@ export class DockerService {
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
return updatedContainer;
}
public async pause(id: string): Promise<DockerContainer> {
const container = this.client.getContainer(id);
await container.pause();
await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY);
this.logger.debug(`Invalidated container cache after pausing ${id}`);
let containers = await this.getContainers({ skipCache: true });
let updatedContainer: DockerContainer | undefined;
for (let i = 0; i < 5; i++) {
await sleep(500);
containers = await this.getContainers({ skipCache: true });
updatedContainer = containers.find((c) => c.id === id);
this.logger.debug(
`Container ${id} state after pause attempt ${i + 1}: ${updatedContainer?.state}`
);
if (updatedContainer?.state === ContainerState.PAUSED) {
break;
}
}
if (!updatedContainer) {
throw new Error(`Container ${id} not found after pausing`);
}
const appInfo = await this.getAppInfo();
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
return updatedContainer;
}
public async unpause(id: string): Promise<DockerContainer> {
const container = this.client.getContainer(id);
await container.unpause();
await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY);
this.logger.debug(`Invalidated container cache after unpausing ${id}`);
let containers = await this.getContainers({ skipCache: true });
let updatedContainer: DockerContainer | undefined;
for (let i = 0; i < 5; i++) {
await sleep(500);
containers = await this.getContainers({ skipCache: true });
updatedContainer = containers.find((c) => c.id === id);
this.logger.debug(
`Container ${id} state after unpause attempt ${i + 1}: ${updatedContainer?.state}`
);
if (updatedContainer?.state === ContainerState.RUNNING) {
break;
}
}
if (!updatedContainer) {
throw new Error(`Container ${id} not found after unpausing`);
}
const appInfo = await this.getAppInfo();
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
return updatedContainer;
}
public async updateContainer(id: string): Promise<DockerContainer> {
const containers = await this.getContainers({ skipCache: true });
const container = containers.find((c) => c.id === id);
if (!container) {
throw new Error(`Container ${id} not found`);
}
const containerName = container.names?.[0]?.replace(/^\//, '');
if (!containerName) {
throw new Error(`Container ${id} has no name`);
}
this.logger.log(`Updating container ${containerName} (${id})`);
try {
await execa(
'/usr/local/emhttp/plugins/dynamix.docker.manager/scripts/update_container',
[encodeURIComponent(containerName)],
{ shell: 'bash' }
);
} catch (error) {
this.logger.error(`Failed to update container ${containerName}:`, error);
throw new Error(`Failed to update container ${containerName}`);
}
await this.clearContainerCache();
this.logger.debug(`Invalidated container caches after updating ${id}`);
const updatedContainers = await this.getContainers({ skipCache: true });
const updatedContainer = updatedContainers.find(
(c) => c.names?.some((name) => name.replace(/^\//, '') === containerName) || c.id === id
);
if (!updatedContainer) {
throw new Error(`Container ${id} not found after update`);
}
const appInfo = await this.getAppInfo();
await pubsub.publish(PUBSUB_CHANNEL.INFO, appInfo);
return updatedContainer;
}
public async updateContainers(ids: string[]): Promise<DockerContainer[]> {
const uniqueIds = Array.from(new Set(ids.filter((id) => typeof id === 'string' && id.length)));
const updatedContainers: DockerContainer[] = [];
for (const id of uniqueIds) {
const updated = await this.updateContainer(id);
updatedContainers.push(updated);
}
return updatedContainers;
}
/**
* Updates every container with an available update. Mirrors the legacy webgui "Update All" flow.
*/
public async updateAllContainers(): Promise<DockerContainer[]> {
const containers = await this.getContainers({ skipCache: true });
if (!containers.length) {
return [];
}
const cachedStatuses = await this.dockerManifestService.getCachedUpdateStatuses();
const idsWithUpdates: string[] = [];
for (const container of containers) {
if (!container.image) {
continue;
}
const hasUpdate = await this.dockerManifestService.isUpdateAvailableCached(
container.image,
cachedStatuses
);
if (hasUpdate) {
idsWithUpdates.push(container.id);
}
}
if (!idsWithUpdates.length) {
this.logger.log('Update-all requested but no containers have available updates');
return [];
}
this.logger.log(`Updating ${idsWithUpdates.length} container(s) via updateAllContainers`);
return this.updateContainers(idsWithUpdates);
}
private handleDockerListError(error: unknown): never {
const message = this.getDockerErrorMessage(error);
this.logger.warn(`Docker container query failed: ${message}`);
catchHandlers.docker(error as NodeJS.ErrnoException);
throw error instanceof Error ? error : new Error('Docker list error');
}
private getDockerErrorMessage(error: unknown): string {
if (error instanceof Error && error.message) {
return error.message;
}
if (typeof error === 'string' && error.length) {
return error;
}
return 'Unknown error occurred.';
}
}

View File

@@ -2,6 +2,7 @@ import { Test } from '@nestjs/testing';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DockerTemplateIconService } from '@app/unraid-api/graph/resolvers/docker/docker-template-icon.service.js';
import {
ContainerPortType,
ContainerState,
@@ -38,6 +39,7 @@ describe('containerToResource', () => {
labels: {
'com.docker.compose.service': 'web',
},
isOrphaned: false,
};
const result = containerToResource(container);
@@ -62,6 +64,7 @@ describe('containerToResource', () => {
state: ContainerState.EXITED,
status: 'Exited (0) 1 hour ago',
autoStart: false,
isOrphaned: false,
};
const result = containerToResource(container);
@@ -83,6 +86,7 @@ describe('containerToResource', () => {
state: ContainerState.EXITED,
status: 'Exited (0) 5 minutes ago',
autoStart: false,
isOrphaned: false,
};
const result = containerToResource(container);
@@ -124,6 +128,7 @@ describe('containerToResource', () => {
maintainer: 'dev-team',
version: '1.0.0',
},
isOrphaned: false,
};
const result = containerToResource(container);
@@ -216,6 +221,12 @@ describe('DockerOrganizerService', () => {
]),
},
},
{
provide: DockerTemplateIconService,
useValue: {
getIconsForContainers: vi.fn().mockResolvedValue(new Map()),
},
},
],
}).compile();
@@ -674,16 +685,31 @@ describe('DockerOrganizerService', () => {
const TO_DELETE = ['entryB', 'entryD'];
const EXPECTED_REMAINING = ['entryA', 'entryC'];
// Mock getContainers to return containers matching our test entries
const mockContainers = ENTRIES.map((entryId, i) => ({
id: `container-${entryId}`,
names: [`/${entryId}`],
image: 'test:latest',
imageId: `sha256:${i}`,
command: 'test',
created: 1640995200 + i,
ports: [],
state: 'running',
status: 'Up 1 hour',
autoStart: true,
}));
(dockerService.getContainers as any).mockResolvedValue(mockContainers);
const organizerWithOrdering = createTestOrganizer();
const rootFolder = getRootFolder(organizerWithOrdering);
rootFolder.children = [...ENTRIES];
// Create the test entries
// Create refs pointing to the container names (which will be /{entryId})
ENTRIES.forEach((entryId) => {
organizerWithOrdering.views.default.entries[entryId] = {
id: entryId,
type: 'ref',
target: `target_${entryId}`,
target: `/${entryId}`,
};
});

View File

@@ -9,10 +9,13 @@ import { DockerOrganizerConfigService } from '@app/unraid-api/graph/resolvers/do
import {
addMissingResourcesToView,
createFolderInView,
createFolderWithItems,
DEFAULT_ORGANIZER_ROOT_ID,
DEFAULT_ORGANIZER_VIEW_ID,
deleteOrganizerEntries,
moveEntriesToFolder,
moveItemsToPosition,
renameFolder,
resolveOrganizer,
setFolderChildrenInView,
} from '@app/unraid-api/organizer/organizer.js';
@@ -51,8 +54,14 @@ export class DockerOrganizerService {
private readonly dockerService: DockerService
) {}
async getResources(opts?: ContainerListOptions): Promise<OrganizerV1['resources']> {
const containers = await this.dockerService.getContainers(opts);
async getResources(
opts?: Partial<ContainerListOptions> & { skipCache?: boolean }
): Promise<OrganizerV1['resources']> {
const { skipCache = false, ...listOptions } = opts ?? {};
const containers = await this.dockerService.getContainers({
skipCache,
...(listOptions as any),
});
return containerListToResourcesObject(containers);
}
@@ -74,17 +83,20 @@ export class DockerOrganizerService {
return newOrganizer;
}
async syncAndGetOrganizer(): Promise<OrganizerV1> {
async syncAndGetOrganizer(opts?: { skipCache?: boolean }): Promise<OrganizerV1> {
let organizer = this.dockerConfigService.getConfig();
organizer.resources = await this.getResources();
organizer.resources = await this.getResources(opts);
organizer = await this.syncDefaultView(organizer, organizer.resources);
organizer = await this.dockerConfigService.validate(organizer);
this.dockerConfigService.replaceConfig(organizer);
return organizer;
}
async resolveOrganizer(organizer?: OrganizerV1): Promise<ResolvedOrganizerV1> {
organizer ??= await this.syncAndGetOrganizer();
async resolveOrganizer(
organizer?: OrganizerV1,
opts?: { skipCache?: boolean }
): Promise<ResolvedOrganizerV1> {
organizer ??= await this.syncAndGetOrganizer(opts);
return resolveOrganizer(organizer);
}
@@ -192,7 +204,10 @@ export class DockerOrganizerService {
const newOrganizer = structuredClone(organizer);
deleteOrganizerEntries(newOrganizer.views.default, entryIds, { mutate: true });
addMissingResourcesToView(newOrganizer.resources, newOrganizer.views.default);
newOrganizer.views.default = addMissingResourcesToView(
newOrganizer.resources,
newOrganizer.views.default
);
const validated = await this.dockerConfigService.validate(newOrganizer);
this.dockerConfigService.replaceConfig(validated);
@@ -222,4 +237,119 @@ export class DockerOrganizerService {
this.dockerConfigService.replaceConfig(validated);
return validated;
}
async moveItemsToPosition(params: {
sourceEntryIds: string[];
destinationFolderId: string;
position: number;
}): Promise<OrganizerV1> {
const { sourceEntryIds, destinationFolderId, position } = params;
const organizer = await this.syncAndGetOrganizer();
const newOrganizer = structuredClone(organizer);
const defaultView = newOrganizer.views.default;
if (!defaultView) {
throw new AppError('Default view not found');
}
newOrganizer.views.default = moveItemsToPosition({
view: defaultView,
sourceEntryIds: new Set(sourceEntryIds),
destinationFolderId,
position,
resources: newOrganizer.resources,
});
const validated = await this.dockerConfigService.validate(newOrganizer);
this.dockerConfigService.replaceConfig(validated);
return validated;
}
async renameFolderById(params: { folderId: string; newName: string }): Promise<OrganizerV1> {
const { folderId, newName } = params;
const organizer = await this.syncAndGetOrganizer();
const newOrganizer = structuredClone(organizer);
const defaultView = newOrganizer.views.default;
if (!defaultView) {
throw new AppError('Default view not found');
}
newOrganizer.views.default = renameFolder({
view: defaultView,
folderId,
newName,
});
const validated = await this.dockerConfigService.validate(newOrganizer);
this.dockerConfigService.replaceConfig(validated);
return validated;
}
async createFolderWithItems(params: {
name: string;
parentId?: string;
sourceEntryIds?: string[];
position?: number;
}): Promise<OrganizerV1> {
const { name, parentId = DEFAULT_ORGANIZER_ROOT_ID, sourceEntryIds = [], position } = params;
if (name === DEFAULT_ORGANIZER_ROOT_ID) {
throw new AppError(`Folder name '${name}' is reserved`);
} else if (name === parentId) {
throw new AppError(`Folder ID '${name}' cannot be the same as the parent ID`);
} else if (!name) {
throw new AppError(`Folder name cannot be empty`);
}
const organizer = await this.syncAndGetOrganizer();
const defaultView = organizer.views.default;
if (!defaultView) {
throw new AppError('Default view not found');
}
const parentEntry = defaultView.entries[parentId];
if (!parentEntry || parentEntry.type !== 'folder') {
throw new AppError(`Parent '${parentId}' not found or is not a folder`);
}
if (parentEntry.children.includes(name)) {
return organizer;
}
const newOrganizer = structuredClone(organizer);
newOrganizer.views.default = createFolderWithItems({
view: defaultView,
folderId: name,
folderName: name,
parentId,
sourceEntryIds,
position,
resources: newOrganizer.resources,
});
const validated = await this.dockerConfigService.validate(newOrganizer);
this.dockerConfigService.replaceConfig(validated);
return validated;
}
async updateViewPreferences(params: {
viewId?: string;
prefs: Record<string, unknown>;
}): Promise<OrganizerV1> {
const { viewId = DEFAULT_ORGANIZER_VIEW_ID, prefs } = params;
const organizer = await this.syncAndGetOrganizer();
const newOrganizer = structuredClone(organizer);
const view = newOrganizer.views[viewId];
if (!view) {
throw new AppError(`View '${viewId}' not found`);
}
view.prefs = prefs;
const validated = await this.dockerConfigService.validate(newOrganizer);
this.dockerConfigService.replaceConfig(validated);
return validated;
}
}

View File

@@ -0,0 +1,12 @@
import Docker from 'dockerode';
let instance: Docker | undefined;
export function getDockerClient(): Docker {
if (!instance) {
instance = new Docker({
socketPath: '/var/run/docker.sock',
});
}
return instance;
}

View File

@@ -164,4 +164,10 @@ export class Notifications extends Node {
@Field(() => [Notification])
@IsNotEmpty()
list!: Notification[];
@Field(() => [Notification], {
description: 'Deduplicated list of unread warning and alert notifications, sorted latest first.',
})
@IsNotEmpty()
warningsAndAlerts!: Notification[];
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
@Module({
providers: [NotificationsService],
exports: [NotificationsService],
})
export class NotificationsModule {}

View File

@@ -49,6 +49,13 @@ export class NotificationsResolver {
return await this.notificationsService.getNotifications(filters);
}
@ResolveField(() => [Notification], {
description: 'Deduplicated list of unread warning and alert notifications.',
})
public async warningsAndAlerts(): Promise<Notification[]> {
return this.notificationsService.getWarningsAndAlerts();
}
/**============================================
* Mutations
*=============================================**/
@@ -96,6 +103,18 @@ export class NotificationsResolver {
return this.notificationsService.getOverview();
}
@Mutation(() => Notification, {
nullable: true,
description:
'Creates a notification if an equivalent unread notification does not already exist.',
})
public notifyIfUnique(
@Args('input', { type: () => NotificationData })
data: NotificationData
): Promise<Notification | null> {
return this.notificationsService.notifyIfUnique(data);
}
@Mutation(() => NotificationOverview)
public async archiveAll(
@Args('importance', { type: () => NotificationImportance, nullable: true })
@@ -163,4 +182,13 @@ export class NotificationsResolver {
async notificationsOverview() {
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_OVERVIEW);
}
@Subscription(() => [Notification])
@UsePermissions({
action: AuthAction.READ_ANY,
resource: Resource.NOTIFICATIONS,
})
async notificationsWarningsAndAlerts() {
return createSubscription(PUBSUB_CHANNEL.NOTIFICATION_WARNINGS_AND_ALERTS);
}
}

View File

@@ -289,6 +289,112 @@ describe.sequential('NotificationsService', () => {
expect(loaded.length).toEqual(3);
});
describe('getWarningsAndAlerts', () => {
it('deduplicates unread warning and alert notifications', async ({ expect }) => {
const duplicateData = {
title: 'Array Status',
subject: 'Disk 1 is getting warm',
description: 'Disk temperature has exceeded threshold.',
importance: NotificationImportance.WARNING,
} as const;
// Create duplicate warnings and an alert with different content
await createNotification(duplicateData);
await createNotification(duplicateData);
await createNotification({
title: 'UPS Disconnected',
subject: 'The UPS connection has been lost',
description: 'Reconnect the UPS to restore protection.',
importance: NotificationImportance.ALERT,
});
await createNotification({
title: 'Parity Check Complete',
subject: 'A parity check has completed successfully',
description: 'No sync errors were detected.',
importance: NotificationImportance.INFO,
});
const results = await service.getWarningsAndAlerts();
const warningMatches = results.filter(
(notification) => notification.subject === duplicateData.subject
);
const alertMatches = results.filter((notification) =>
notification.subject.includes('UPS connection')
);
expect(results.length).toEqual(2);
expect(warningMatches).toHaveLength(1);
expect(alertMatches).toHaveLength(1);
expect(
results.every((notification) => notification.importance !== NotificationImportance.INFO)
).toBe(true);
});
it('respects the provided limit', async ({ expect }) => {
const limit = 2;
await createNotification({
title: 'Array Warning',
subject: 'Disk 2 is getting warm',
description: 'Disk temperature has exceeded threshold.',
importance: NotificationImportance.WARNING,
});
await createNotification({
title: 'Network Down',
subject: 'Ethernet link is down',
description: 'Physical link failure detected.',
importance: NotificationImportance.ALERT,
});
await createNotification({
title: 'Critical Temperature',
subject: 'CPU temperature exceeded',
description: 'CPU temperature has exceeded safe operating limits.',
importance: NotificationImportance.ALERT,
});
const results = await service.getWarningsAndAlerts(limit);
expect(results.length).toEqual(limit);
});
});
describe('notifyIfUnique', () => {
const duplicateData: NotificationData = {
title: 'Docker Query Failure',
subject: 'Failed to fetch containers from Docker',
description: 'Please verify that the Docker service is running.',
importance: NotificationImportance.ALERT,
};
it('skips creating duplicate unread notifications', async ({ expect }) => {
const created = await service.notifyIfUnique(duplicateData);
expect(created).toBeDefined();
const skipped = await service.notifyIfUnique(duplicateData);
expect(skipped).toBeNull();
const notifications = await service.getNotifications({
type: NotificationType.UNREAD,
limit: 50,
offset: 0,
});
expect(
notifications.filter((notification) => notification.title === duplicateData.title)
).toHaveLength(1);
});
it('creates new notification when no duplicate exists', async ({ expect }) => {
const uniqueData: NotificationData = {
title: 'UPS Disconnected',
subject: 'UPS connection lost',
description: 'Reconnect the UPS to restore protection.',
importance: NotificationImportance.WARNING,
};
const notification = await service.notifyIfUnique(uniqueData);
expect(notification).toBeDefined();
expect(notification?.title).toEqual(uniqueData.title);
});
});
/**--------------------------------------------
* CRUD: Update Tests
*---------------------------------------------**/

View File

@@ -121,6 +121,7 @@ export class NotificationsService {
pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_ADDED, {
notificationAdded: notification,
});
void this.publishWarningsAndAlerts();
}
}
@@ -142,6 +143,20 @@ export class NotificationsService {
});
}
private async publishWarningsAndAlerts() {
try {
const warningsAndAlerts = await this.getWarningsAndAlerts();
await pubsub.publish(PUBSUB_CHANNEL.NOTIFICATION_WARNINGS_AND_ALERTS, {
notificationsWarningsAndAlerts: warningsAndAlerts,
});
} catch (error) {
this.logger.error(
'[publishWarningsAndAlerts] Failed to broadcast warnings and alerts snapshot',
error as Error
);
}
}
private increment(importance: NotificationImportance, collector: NotificationCounts) {
collector[importance.toLowerCase()] += 1;
collector['total'] += 1;
@@ -214,6 +229,8 @@ export class NotificationsService {
await writeFile(path, ini);
}
void this.publishWarningsAndAlerts();
return this.notificationFileToGqlNotification({ id, type: NotificationType.UNREAD }, fileData);
}
@@ -300,6 +317,9 @@ export class NotificationsService {
this.decrement(notification.importance, NotificationsService.overview[type.toLowerCase()]);
await this.publishOverview();
if (type === NotificationType.UNREAD) {
void this.publishWarningsAndAlerts();
}
// return both the overview & the deleted notification
// this helps us reference the deleted notification in-memory if we want
@@ -320,6 +340,10 @@ export class NotificationsService {
warning: 0,
total: 0,
};
await this.publishOverview();
if (type === NotificationType.UNREAD) {
void this.publishWarningsAndAlerts();
}
return this.getOverview();
}
@@ -433,6 +457,8 @@ export class NotificationsService {
});
await moveToArchive(notification);
void this.publishWarningsAndAlerts();
return {
...notification,
type: NotificationType.ARCHIVE,
@@ -458,6 +484,7 @@ export class NotificationsService {
});
await moveToUnread(notification);
void this.publishWarningsAndAlerts();
return {
...notification,
type: NotificationType.UNREAD,
@@ -482,6 +509,7 @@ export class NotificationsService {
});
const stats = await batchProcess(notifications, archive);
void this.publishWarningsAndAlerts();
return { ...stats, overview: overviewSnapshot };
}
@@ -504,6 +532,7 @@ export class NotificationsService {
});
const stats = await batchProcess(notifications, unArchive);
void this.publishWarningsAndAlerts();
return { ...stats, overview: overviewSnapshot };
}
@@ -567,6 +596,64 @@ export class NotificationsService {
return notifications;
}
/**
* Creates a notification only if an equivalent unread notification does not already exist.
*
* @param data The notification data to create.
* @returns The created notification, or null if a duplicate was detected.
*/
public async notifyIfUnique(data: NotificationData): Promise<Notification | null> {
const fingerprint = this.getNotificationFingerprintFromData(data);
const hasDuplicate = await this.hasUnreadNotificationWithFingerprint(fingerprint);
if (hasDuplicate) {
this.logger.verbose(
`[notifyIfUnique] Skipping notification creation for duplicate fingerprint: ${fingerprint}`
);
return null;
}
return this.createNotification(data);
}
/**
* Returns a deduplicated list of unread warning and alert notifications.
*
* Deduplication is based on the combination of importance, title, subject, description, and link.
* This ensures repeated notifications with the same user-facing content are only shown once, while
* still prioritizing the most recent occurrence of each unique notification.
*
* @param limit Maximum number of unique notifications to return. Default: 50.
*/
public async getWarningsAndAlerts(limit = 50): Promise<Notification[]> {
const notifications = await this.loadUnreadNotifications();
const deduped: Notification[] = [];
const seen = new Set<string>();
for (const notification of notifications) {
if (
notification.importance !== NotificationImportance.ALERT &&
notification.importance !== NotificationImportance.WARNING
) {
continue;
}
const key = this.getDeduplicationKey(notification);
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(notification);
if (deduped.length >= limit) {
break;
}
}
return deduped;
}
/**
* Given a path to a folder, returns the full (absolute) paths of the folder's top-level contents.
* Sorted latest-first by default.
@@ -787,8 +874,57 @@ export class NotificationsService {
* Helpers
*------------------------------------------------------------------------**/
private async loadUnreadNotifications(): Promise<Notification[]> {
const { UNREAD } = this.paths();
const files = await this.listFilesInFolder(UNREAD);
const [notifications] = await this.loadNotificationsFromPaths(files, {
type: NotificationType.UNREAD,
});
return notifications;
}
private async hasUnreadNotificationWithFingerprint(fingerprint: string): Promise<boolean> {
const notifications = await this.loadUnreadNotifications();
return notifications.some(
(notification) => this.getDeduplicationKey(notification) === fingerprint
);
}
private sortLatestFirst(a: Notification, b: Notification) {
const defaultTimestamp = 0;
return Number(b.timestamp ?? defaultTimestamp) - Number(a.timestamp ?? defaultTimestamp);
}
private getDeduplicationKey(notification: Notification): string {
return this.getNotificationFingerprint(notification);
}
private getNotificationFingerprintFromData(data: NotificationData): string {
return this.getNotificationFingerprint({
importance: data.importance,
title: data.title,
subject: data.subject,
description: data.description,
link: data.link,
});
}
private getNotificationFingerprint({
importance,
title,
subject,
description,
link,
}: Pick<Notification, 'importance' | 'title' | 'subject' | 'description'> & {
link?: string | null;
}): string {
const makePart = (value?: string | null) => (value ?? '').trim();
return [
importance,
makePart(title),
makePart(subject),
makePart(description),
makePart(link),
].join('|');
}
}

View File

@@ -15,8 +15,8 @@ import { InfoModule } from '@app/unraid-api/graph/resolvers/info/info.module.js'
import { LogsModule } from '@app/unraid-api/graph/resolvers/logs/logs.module.js';
import { MetricsModule } from '@app/unraid-api/graph/resolvers/metrics/metrics.module.js';
import { RootMutationsResolver } from '@app/unraid-api/graph/resolvers/mutation/mutation.resolver.js';
import { NotificationsModule } from '@app/unraid-api/graph/resolvers/notifications/notifications.module.js';
import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js';
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
import { OnlineResolver } from '@app/unraid-api/graph/resolvers/online/online.resolver.js';
import { OwnerResolver } from '@app/unraid-api/graph/resolvers/owner/owner.resolver.js';
import { RCloneModule } from '@app/unraid-api/graph/resolvers/rclone/rclone.module.js';
@@ -47,6 +47,7 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
FlashBackupModule,
InfoModule,
LogsModule,
NotificationsModule,
RCloneModule,
SettingsModule,
SsoModule,
@@ -58,7 +59,6 @@ import { MeResolver } from '@app/unraid-api/graph/user/user.resolver.js';
FlashResolver,
MeResolver,
NotificationsResolver,
NotificationsService,
OnlineResolver,
OwnerResolver,
RegistrationResolver,

View File

@@ -148,6 +148,16 @@ const verifyLibvirtConnection = async (hypervisor: Hypervisor) => {
}
};
// Check if qemu-img is available before running tests
const isQemuAvailable = () => {
try {
execSync('qemu-img --version', { stdio: 'ignore' });
return true;
} catch (error) {
return false;
}
};
describe('VmsService', () => {
let service: VmsService;
let hypervisor: Hypervisor;
@@ -174,6 +184,14 @@ describe('VmsService', () => {
</domain>
`;
beforeAll(() => {
if (!isQemuAvailable()) {
throw new Error(
'QEMU not available - skipping VM integration tests. Please install QEMU to run these tests.'
);
}
});
beforeAll(async () => {
// Override the LIBVIRT_URI environment variable for testing
process.env.LIBVIRT_URI = LIBVIRT_URI;

View File

@@ -222,9 +222,15 @@ export class ResolvedOrganizerView {
@IsString()
name!: string;
@Field(() => ResolvedOrganizerEntry)
@ValidateNested()
root!: ResolvedOrganizerEntryType;
@Field()
@IsString()
rootId!: string;
@Field(() => [FlatOrganizerEntry])
@IsArray()
@ValidateNested({ each: true })
@Type(() => FlatOrganizerEntry)
flatEntries!: FlatOrganizerEntry[];
@Field(() => GraphQLJSON, { nullable: true })
@IsOptional()
@@ -246,3 +252,54 @@ export class ResolvedOrganizerV1 {
@Type(() => ResolvedOrganizerView)
views!: ResolvedOrganizerView[];
}
// ============================================
// FLAT ORGANIZER ENTRY (for efficient frontend consumption)
// ============================================
@ObjectType()
export class FlatOrganizerEntry {
@Field()
@IsString()
id!: string;
@Field()
@IsString()
type!: string;
@Field()
@IsString()
name!: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
parentId?: string;
@Field()
@IsNumber()
depth!: number;
@Field()
@IsNumber()
position!: number;
@Field(() => [String])
@IsArray()
@IsString({ each: true })
path!: string[];
@Field()
hasChildren!: boolean;
@Field(() => [String])
@IsArray()
@IsString({ each: true })
childrenIds!: string[];
@Field(() => DockerContainer, { nullable: true })
@IsOptional()
@ValidateNested()
@Type(() => DockerContainer)
meta?: DockerContainer;
}

View File

@@ -4,8 +4,6 @@ import { resolveOrganizer } from '@app/unraid-api/organizer/organizer.js';
import {
OrganizerResource,
OrganizerV1,
ResolvedOrganizerEntryType,
ResolvedOrganizerFolder,
ResolvedOrganizerV1,
} from '@app/unraid-api/organizer/organizer.model.js';
@@ -72,36 +70,48 @@ describe('Organizer Resolver', () => {
const defaultView = resolved.views[0];
expect(defaultView.id).toBe('default');
expect(defaultView.name).toBe('Default View');
expect(defaultView.root.type).toBe('folder');
expect(defaultView.rootId).toBe('root-folder');
if (defaultView.root.type === 'folder') {
const rootFolder = defaultView.root as ResolvedOrganizerFolder;
expect(rootFolder.name).toBe('Root');
expect(rootFolder.children).toHaveLength(2);
// Check flatEntries structure
const flatEntries = defaultView.flatEntries;
expect(flatEntries).toHaveLength(4);
// First child should be the resolved container1
const firstChild = rootFolder.children[0];
expect(firstChild.type).toBe('container');
expect(firstChild.id).toBe('container1');
expect(firstChild.name).toBe('My Container');
// Root folder
const rootEntry = flatEntries[0];
expect(rootEntry.id).toBe('root-folder');
expect(rootEntry.type).toBe('folder');
expect(rootEntry.name).toBe('Root');
expect(rootEntry.depth).toBe(0);
expect(rootEntry.parentId).toBeUndefined();
expect(rootEntry.childrenIds).toEqual(['container1-ref', 'subfolder']);
// Second child should be the resolved subfolder
const secondChild = rootFolder.children[1];
expect(secondChild.type).toBe('folder');
if (secondChild.type === 'folder') {
const subFolder = secondChild as ResolvedOrganizerFolder;
expect(subFolder.name).toBe('Subfolder');
expect(subFolder.children).toHaveLength(1);
// First child (container1-ref resolved to container)
const container1Entry = flatEntries[1];
expect(container1Entry.id).toBe('container1-ref');
expect(container1Entry.type).toBe('container');
expect(container1Entry.name).toBe('My Container');
expect(container1Entry.depth).toBe(1);
expect(container1Entry.parentId).toBe('root-folder');
const nestedChild = subFolder.children[0];
expect(nestedChild.type).toBe('container');
expect(nestedChild.id).toBe('container2');
expect(nestedChild.name).toBe('Another Container');
}
}
// Subfolder
const subfolderEntry = flatEntries[2];
expect(subfolderEntry.id).toBe('subfolder');
expect(subfolderEntry.type).toBe('folder');
expect(subfolderEntry.name).toBe('Subfolder');
expect(subfolderEntry.depth).toBe(1);
expect(subfolderEntry.parentId).toBe('root-folder');
expect(subfolderEntry.childrenIds).toEqual(['container2-ref']);
// Nested container
const container2Entry = flatEntries[3];
expect(container2Entry.id).toBe('container2-ref');
expect(container2Entry.type).toBe('container');
expect(container2Entry.name).toBe('Another Container');
expect(container2Entry.depth).toBe(2);
expect(container2Entry.parentId).toBe('subfolder');
});
test('should throw error for missing resource', () => {
test('should handle missing resource gracefully', () => {
const organizer: OrganizerV1 = {
version: 1,
resources: {},
@@ -127,12 +137,19 @@ describe('Organizer Resolver', () => {
},
};
expect(() => resolveOrganizer(organizer)).toThrow(
"Resource with id 'nonexistent-resource' not found"
);
const resolved = resolveOrganizer(organizer);
const flatEntries = resolved.views[0].flatEntries;
// Should have 2 entries: root folder and the ref (kept as ref type since resource not found)
expect(flatEntries).toHaveLength(2);
const missingRefEntry = flatEntries[1];
expect(missingRefEntry.id).toBe('missing-ref');
expect(missingRefEntry.type).toBe('ref'); // Stays as ref when resource not found
expect(missingRefEntry.meta).toBeUndefined();
});
test('should throw error for missing entry', () => {
test('should skip missing entries gracefully', () => {
const organizer: OrganizerV1 = {
version: 1,
resources: {},
@@ -153,9 +170,12 @@ describe('Organizer Resolver', () => {
},
};
expect(() => resolveOrganizer(organizer)).toThrow(
"Entry with id 'nonexistent-entry' not found in view"
);
const resolved = resolveOrganizer(organizer);
const flatEntries = resolved.views[0].flatEntries;
// Should only have root folder, missing entry is skipped
expect(flatEntries).toHaveLength(1);
expect(flatEntries[0].id).toBe('root-folder');
});
test('should resolve empty folders correctly', () => {
@@ -207,30 +227,27 @@ describe('Organizer Resolver', () => {
const defaultView = resolved.views[0];
expect(defaultView.id).toBe('default');
expect(defaultView.name).toBe('Default View');
expect(defaultView.root.type).toBe('folder');
expect(defaultView.rootId).toBe('root');
if (defaultView.root.type === 'folder') {
const rootFolder = defaultView.root as ResolvedOrganizerFolder;
expect(rootFolder.name).toBe('Root');
expect(rootFolder.children).toHaveLength(2);
const flatEntries = defaultView.flatEntries;
expect(flatEntries).toHaveLength(3);
// First child should be the resolved container
const firstChild = rootFolder.children[0];
expect(firstChild.type).toBe('container');
expect(firstChild.id).toBe('container1');
// Root folder
expect(flatEntries[0].id).toBe('root');
expect(flatEntries[0].type).toBe('folder');
expect(flatEntries[0].name).toBe('Root');
// Second child should be the resolved empty folder
const secondChild = rootFolder.children[1];
expect(secondChild.type).toBe('folder');
expect(secondChild.id).toBe('empty-folder');
// First child - resolved container
expect(flatEntries[1].id).toBe('container1-ref');
expect(flatEntries[1].type).toBe('container');
expect(flatEntries[1].name).toBe('My Container');
if (secondChild.type === 'folder') {
const emptyFolder = secondChild as ResolvedOrganizerFolder;
expect(emptyFolder.name).toBe('Empty Folder');
expect(emptyFolder.children).toEqual([]);
expect(emptyFolder.children).toHaveLength(0);
}
}
// Second child - empty folder
expect(flatEntries[2].id).toBe('empty-folder');
expect(flatEntries[2].type).toBe('folder');
expect(flatEntries[2].name).toBe('Empty Folder');
expect(flatEntries[2].childrenIds).toEqual([]);
expect(flatEntries[2].hasChildren).toBe(false);
});
test('should handle real-world scenario with containers and empty folder', () => {
@@ -314,24 +331,19 @@ describe('Organizer Resolver', () => {
expect(resolved.views).toHaveLength(1);
const defaultView = resolved.views[0];
expect(defaultView.root.type).toBe('folder');
expect(defaultView.rootId).toBe('root');
if (defaultView.root.type === 'folder') {
const rootFolder = defaultView.root as ResolvedOrganizerFolder;
expect(rootFolder.children).toHaveLength(4);
const flatEntries = defaultView.flatEntries;
expect(flatEntries).toHaveLength(5); // root + 3 containers + empty folder
// Last child should be the empty folder (not an empty object)
const lastChild = rootFolder.children[3];
expect(lastChild).not.toEqual({}); // This should NOT be an empty object
expect(lastChild.type).toBe('folder');
expect(lastChild.id).toBe('new-folder');
if (lastChild.type === 'folder') {
const newFolder = lastChild as ResolvedOrganizerFolder;
expect(newFolder.name).toBe('new-folder');
expect(newFolder.children).toEqual([]);
}
}
// Last entry should be the empty folder (not missing or malformed)
const lastEntry = flatEntries[4];
expect(lastEntry).toBeDefined();
expect(lastEntry.type).toBe('folder');
expect(lastEntry.id).toBe('new-folder');
expect(lastEntry.name).toBe('new-folder');
expect(lastEntry.childrenIds).toEqual([]);
expect(lastEntry.hasChildren).toBe(false);
});
test('should handle nested empty folders correctly', () => {
@@ -373,31 +385,28 @@ describe('Organizer Resolver', () => {
expect(resolved.views).toHaveLength(1);
const defaultView = resolved.views[0];
expect(defaultView.root.type).toBe('folder');
expect(defaultView.rootId).toBe('root');
if (defaultView.root.type === 'folder') {
const rootFolder = defaultView.root as ResolvedOrganizerFolder;
expect(rootFolder.children).toHaveLength(1);
const flatEntries = defaultView.flatEntries;
expect(flatEntries).toHaveLength(3);
const level1Folder = rootFolder.children[0];
expect(level1Folder.type).toBe('folder');
expect(level1Folder.id).toBe('level1-folder');
// Root
expect(flatEntries[0].id).toBe('root');
expect(flatEntries[0].depth).toBe(0);
if (level1Folder.type === 'folder') {
const level1 = level1Folder as ResolvedOrganizerFolder;
expect(level1.children).toHaveLength(1);
// Level 1 folder
expect(flatEntries[1].id).toBe('level1-folder');
expect(flatEntries[1].type).toBe('folder');
expect(flatEntries[1].depth).toBe(1);
expect(flatEntries[1].parentId).toBe('root');
const level2Folder = level1.children[0];
expect(level2Folder.type).toBe('folder');
expect(level2Folder.id).toBe('level2-folder');
if (level2Folder.type === 'folder') {
const level2 = level2Folder as ResolvedOrganizerFolder;
expect(level2.children).toEqual([]);
expect(level2.children).toHaveLength(0);
}
}
}
// Level 2 folder (empty)
expect(flatEntries[2].id).toBe('level2-folder');
expect(flatEntries[2].type).toBe('folder');
expect(flatEntries[2].depth).toBe(2);
expect(flatEntries[2].parentId).toBe('level1-folder');
expect(flatEntries[2].childrenIds).toEqual([]);
expect(flatEntries[2].hasChildren).toBe(false);
});
test('should validate that all resolved objects have proper structure', () => {
@@ -443,30 +452,24 @@ describe('Organizer Resolver', () => {
const resolved: ResolvedOrganizerV1 = resolveOrganizer(organizer);
// Recursively validate that all objects have proper structure
function validateResolvedEntry(entry: ResolvedOrganizerEntryType) {
// Validate that all flat entries have proper structure
const flatEntries = resolved.views[0].flatEntries;
expect(flatEntries).toHaveLength(3); // root + container + empty folder
flatEntries.forEach((entry) => {
expect(entry).toBeDefined();
expect(entry).not.toEqual({});
expect(entry).toHaveProperty('id');
expect(entry).toHaveProperty('type');
expect(entry).toHaveProperty('name');
expect(entry).toHaveProperty('depth');
expect(entry).toHaveProperty('childrenIds');
expect(typeof entry.id).toBe('string');
expect(typeof entry.type).toBe('string');
expect(typeof entry.name).toBe('string');
if (entry.type === 'folder') {
const folder = entry as ResolvedOrganizerFolder;
expect(folder).toHaveProperty('children');
expect(Array.isArray(folder.children)).toBe(true);
// Recursively validate children
folder.children.forEach((child) => validateResolvedEntry(child));
}
}
if (resolved.views[0].root.type === 'folder') {
validateResolvedEntry(resolved.views[0].root);
}
expect(typeof entry.depth).toBe('number');
expect(Array.isArray(entry.childrenIds)).toBe(true);
});
});
test('should maintain object identity and not return empty objects', () => {
@@ -510,22 +513,19 @@ describe('Organizer Resolver', () => {
const resolved: ResolvedOrganizerV1 = resolveOrganizer(organizer);
if (resolved.views[0].root.type === 'folder') {
const rootFolder = resolved.views[0].root as ResolvedOrganizerFolder;
expect(rootFolder.children).toHaveLength(3);
const flatEntries = resolved.views[0].flatEntries;
expect(flatEntries).toHaveLength(4); // root + 3 empty folders
// Ensure none of the children are empty objects
rootFolder.children.forEach((child, index) => {
expect(child).not.toEqual({});
expect(child.type).toBe('folder');
expect(child.id).toBe(`empty${index + 1}`);
expect(child.name).toBe(`Empty ${index + 1}`);
if (child.type === 'folder') {
const folder = child as ResolvedOrganizerFolder;
expect(folder.children).toEqual([]);
}
});
}
// Ensure none of the entries are malformed
const emptyFolders = flatEntries.slice(1); // Skip root
emptyFolders.forEach((entry, index) => {
expect(entry).not.toEqual({});
expect(entry).toBeDefined();
expect(entry.type).toBe('folder');
expect(entry.id).toBe(`empty${index + 1}`);
expect(entry.name).toBe(`Empty ${index + 1}`);
expect(entry.childrenIds).toEqual([]);
expect(entry.hasChildren).toBe(false);
});
});
});

View File

@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest';
import { addMissingResourcesToView } from '@app/unraid-api/organizer/organizer.js';
import {
addMissingResourcesToView,
removeStaleRefsFromView,
} from '@app/unraid-api/organizer/organizer.js';
import {
OrganizerFolder,
OrganizerResource,
@@ -263,4 +266,268 @@ describe('addMissingResourcesToView', () => {
expect(result.entries['key-different-from-id'].id).toBe('actual-resource-id');
expect((result.entries['root1'] as OrganizerFolder).children).toContain('key-different-from-id');
});
it("does not re-add resources to root if they're already referenced in any folder", () => {
const resources: OrganizerV1['resources'] = {
resourceA: { id: 'resourceA', type: 'container', name: 'A' },
resourceB: { id: 'resourceB', type: 'container', name: 'B' },
};
const originalView: OrganizerView = {
id: 'view1',
name: 'Test View',
root: 'root1',
entries: {
root1: {
id: 'root1',
type: 'folder',
name: 'Root',
children: ['stuff'],
},
stuff: {
id: 'stuff',
type: 'folder',
name: 'Stuff',
children: ['resourceA', 'resourceB'],
},
resourceA: { id: 'resourceA', type: 'ref', target: 'resourceA' },
resourceB: { id: 'resourceB', type: 'ref', target: 'resourceB' },
},
};
const result = addMissingResourcesToView(resources, originalView);
// Root should still only contain the 'stuff' folder, not the resources
const rootChildren = (result.entries['root1'] as OrganizerFolder).children;
expect(rootChildren).toEqual(['stuff']);
});
it('should remove stale refs when resources are removed', () => {
const resources: OrganizerV1['resources'] = {
resource1: { id: 'resource1', type: 'container', name: 'Container 1' },
// resource2 has been removed
};
const originalView: OrganizerView = {
id: 'view1',
name: 'Test View',
root: 'root1',
entries: {
root1: {
id: 'root1',
type: 'folder',
name: 'Root',
children: ['resource1', 'resource2'],
},
resource1: { id: 'resource1', type: 'ref', target: 'resource1' },
resource2: { id: 'resource2', type: 'ref', target: 'resource2' }, // stale ref
},
};
const result = addMissingResourcesToView(resources, originalView);
// resource2 should be removed from entries
expect(result.entries['resource2']).toBeUndefined();
// resource2 should be removed from root children
const rootChildren = (result.entries['root1'] as OrganizerFolder).children;
expect(rootChildren).not.toContain('resource2');
expect(rootChildren).toContain('resource1');
});
it('should remove stale refs from nested folders', () => {
const resources: OrganizerV1['resources'] = {
resource1: { id: 'resource1', type: 'container', name: 'Container 1' },
// resource2 and resource3 have been removed
};
const originalView: OrganizerView = {
id: 'view1',
name: 'Test View',
root: 'root1',
entries: {
root1: {
id: 'root1',
type: 'folder',
name: 'Root',
children: ['folder1', 'resource1'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Nested Folder',
children: ['resource2', 'resource3'],
},
resource1: { id: 'resource1', type: 'ref', target: 'resource1' },
resource2: { id: 'resource2', type: 'ref', target: 'resource2' }, // stale
resource3: { id: 'resource3', type: 'ref', target: 'resource3' }, // stale
},
};
const result = addMissingResourcesToView(resources, originalView);
// stale refs should be removed
expect(result.entries['resource2']).toBeUndefined();
expect(result.entries['resource3']).toBeUndefined();
// folder1 children should be empty
const folder1Children = (result.entries['folder1'] as OrganizerFolder).children;
expect(folder1Children).toEqual([]);
// resource1 should still exist
expect(result.entries['resource1']).toBeDefined();
});
});
describe('removeStaleRefsFromView', () => {
it('should remove refs pointing to non-existent resources', () => {
const resources: OrganizerV1['resources'] = {
resource1: { id: 'resource1', type: 'container', name: 'Container 1' },
};
const originalView: OrganizerView = {
id: 'view1',
name: 'Test View',
root: 'root1',
entries: {
root1: {
id: 'root1',
type: 'folder',
name: 'Root',
children: ['resource1', 'stale-ref'],
},
resource1: { id: 'resource1', type: 'ref', target: 'resource1' },
'stale-ref': { id: 'stale-ref', type: 'ref', target: 'non-existent-resource' },
},
};
const result = removeStaleRefsFromView(resources, originalView);
expect(result.entries['resource1']).toBeDefined();
expect(result.entries['stale-ref']).toBeUndefined();
expect((result.entries['root1'] as OrganizerFolder).children).toEqual(['resource1']);
});
it('should not remove folders even if empty', () => {
const resources: OrganizerV1['resources'] = {};
const originalView: OrganizerView = {
id: 'view1',
name: 'Test View',
root: 'root1',
entries: {
root1: {
id: 'root1',
type: 'folder',
name: 'Root',
children: ['folder1'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Empty Folder',
children: [],
},
},
};
const result = removeStaleRefsFromView(resources, originalView);
expect(result.entries['root1']).toBeDefined();
expect(result.entries['folder1']).toBeDefined();
});
it('should remove multiple stale refs from multiple folders', () => {
const resources: OrganizerV1['resources'] = {
resource1: { id: 'resource1', type: 'container', name: 'Container 1' },
};
const originalView: OrganizerView = {
id: 'view1',
name: 'Test View',
root: 'root1',
entries: {
root1: {
id: 'root1',
type: 'folder',
name: 'Root',
children: ['folder1', 'stale1'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Folder',
children: ['resource1', 'stale2', 'stale3'],
},
resource1: { id: 'resource1', type: 'ref', target: 'resource1' },
stale1: { id: 'stale1', type: 'ref', target: 'gone1' },
stale2: { id: 'stale2', type: 'ref', target: 'gone2' },
stale3: { id: 'stale3', type: 'ref', target: 'gone3' },
},
};
const result = removeStaleRefsFromView(resources, originalView);
expect(result.entries['resource1']).toBeDefined();
expect(result.entries['stale1']).toBeUndefined();
expect(result.entries['stale2']).toBeUndefined();
expect(result.entries['stale3']).toBeUndefined();
expect((result.entries['root1'] as OrganizerFolder).children).toEqual(['folder1']);
expect((result.entries['folder1'] as OrganizerFolder).children).toEqual(['resource1']);
});
it('should not mutate the original view', () => {
const resources: OrganizerV1['resources'] = {};
const originalView: OrganizerView = {
id: 'view1',
name: 'Test View',
root: 'root1',
entries: {
root1: {
id: 'root1',
type: 'folder',
name: 'Root',
children: ['stale-ref'],
},
'stale-ref': { id: 'stale-ref', type: 'ref', target: 'gone' },
},
};
const originalEntriesCount = Object.keys(originalView.entries).length;
const result = removeStaleRefsFromView(resources, originalView);
expect(Object.keys(originalView.entries)).toHaveLength(originalEntriesCount);
expect(originalView.entries['stale-ref']).toBeDefined();
expect(result).not.toBe(originalView);
});
it('should handle view with no refs', () => {
const resources: OrganizerV1['resources'] = {
resource1: { id: 'resource1', type: 'container', name: 'Container 1' },
};
const originalView: OrganizerView = {
id: 'view1',
name: 'Test View',
root: 'root1',
entries: {
root1: {
id: 'root1',
type: 'folder',
name: 'Root',
children: ['folder1'],
},
folder1: {
id: 'folder1',
type: 'folder',
name: 'Sub Folder',
children: [],
},
},
};
const result = removeStaleRefsFromView(resources, originalView);
expect(Object.keys(result.entries)).toHaveLength(2);
expect(result.entries['root1']).toBeDefined();
expect(result.entries['folder1']).toBeDefined();
});
});

View File

@@ -1,5 +1,7 @@
import {
AnyOrganizerResource,
FlatOrganizerEntry,
OrganizerContainerResource,
OrganizerFolder,
OrganizerResource,
OrganizerResourceRef,
@@ -45,72 +47,164 @@ export function resourceToResourceRef(
* // updatedView will contain 'res1' as a resource reference in the root folder
* ```
*/
export function addMissingResourcesToView(
/**
* Removes refs from a view that point to resources that no longer exist.
* This ensures the view stays in sync when containers are removed.
*
* @param resources - The current set of available resources
* @param originalView - The view to clean up
* @returns A new view with stale refs removed
*/
export function removeStaleRefsFromView(
resources: OrganizerV1['resources'],
originalView: OrganizerView
): OrganizerView {
const view = structuredClone(originalView);
view.entries[view.root] ??= {
id: view.root,
name: view.name,
const resourceIds = new Set(Object.keys(resources));
const staleRefIds = new Set<string>();
// Find all refs that point to non-existent resources
Object.entries(view.entries).forEach(([id, entry]) => {
if (entry.type === 'ref') {
const ref = entry as OrganizerResourceRef;
if (!resourceIds.has(ref.target)) {
staleRefIds.add(id);
}
}
});
// Remove stale refs from all folder children arrays
Object.values(view.entries).forEach((entry) => {
if (entry.type === 'folder') {
const folder = entry as OrganizerFolder;
folder.children = folder.children.filter((childId) => !staleRefIds.has(childId));
}
});
// Delete the stale ref entries themselves
for (const refId of staleRefIds) {
delete view.entries[refId];
}
return view;
}
export function addMissingResourcesToView(
resources: OrganizerV1['resources'],
originalView: OrganizerView
): OrganizerView {
// First, remove any stale refs pointing to non-existent resources
const cleanedView = removeStaleRefsFromView(resources, originalView);
cleanedView.entries[cleanedView.root] ??= {
id: cleanedView.root,
name: cleanedView.name,
type: 'folder',
children: [],
};
const root = view.entries[view.root]! as OrganizerFolder;
const root = cleanedView.entries[cleanedView.root]! as OrganizerFolder;
const rootChildren = new Set(root.children);
// Track if a resource id is already referenced in any folder
const referencedIds = new Set<string>();
Object.values(cleanedView.entries).forEach((entry) => {
if (entry.type === 'folder') {
for (const childId of entry.children) referencedIds.add(childId);
}
});
Object.entries(resources).forEach(([id, resource]) => {
if (!view.entries[id]) {
view.entries[id] = resourceToResourceRef(resource, (resource) => resource.id);
const existsInEntries = Boolean(cleanedView.entries[id]);
const isReferencedSomewhere = referencedIds.has(id);
// Ensure a ref entry exists for the resource id
if (!existsInEntries) {
cleanedView.entries[id] = resourceToResourceRef(resource, (resource) => resource.id);
}
// Only add to root if the resource is not already referenced elsewhere
if (!isReferencedSomewhere) {
rootChildren.add(id);
}
});
root.children = Array.from(rootChildren);
return view;
return cleanedView;
}
/**
* Recursively resolves an organizer entry (folder or resource ref) into its actual objects.
* This transforms the flat ID-based structure into a nested object structure for frontend convenience.
* Directly enriches flat entries from an organizer view without building an intermediate tree.
* This is more efficient than building a tree just to flatten it again.
*
* PRECONDITION: The given view is valid (ie. does not contain any cycles or depth issues).
*
* @param entryId - The ID of the entry to resolve
* @param view - The organizer view containing the entry definitions
* @param view - The flat organizer view
* @param resources - The collection of all available resources
* @returns The resolved entry with actual objects instead of ID references
* @returns Array of enriched flat organizer entries with metadata
*/
function resolveEntry(
entryId: string,
export function enrichFlatEntries(
view: OrganizerView,
resources: OrganizerV1['resources']
): ResolvedOrganizerEntryType {
const entry = view.entries[entryId];
): FlatOrganizerEntry[] {
const entries: FlatOrganizerEntry[] = [];
const parentMap = new Map<string, string>();
if (!entry) {
throw new Error(`Entry with id '${entryId}' not found in view`);
}
if (entry.type === 'folder') {
// Recursively resolve all children
const resolvedChildren = entry.children.map((childId) => resolveEntry(childId, view, resources));
return {
id: entry.id,
type: 'folder',
name: entry.name,
children: resolvedChildren,
} as ResolvedOrganizerFolder;
} else if (entry.type === 'ref') {
// Resolve the resource reference
const resource = resources[entry.target];
if (!resource) {
throw new Error(`Resource with id '${entry.target}' not found`);
// Build parent map
for (const [id, entry] of Object.entries(view.entries)) {
if (entry.type === 'folder') {
for (const childId of entry.children) {
parentMap.set(childId, id);
}
}
return resource;
}
throw new Error(`Unknown entry type: ${(entry as any).type}`);
// Walk from root to maintain order and calculate depth/position
function walk(entryId: string, depth: number, path: string[], position: number): void {
const entry = view.entries[entryId];
if (!entry) return;
const currentPath = [...path, entryId];
const isFolder = entry.type === 'folder';
const children = isFolder ? (entry as OrganizerFolder).children : [];
// Resolve resource if ref
let meta: any = undefined;
let name = entryId;
let type: string = entry.type;
if (entry.type === 'ref') {
const resource = resources[(entry as OrganizerResourceRef).target];
if (resource) {
if (resource.type === 'container') {
meta = (resource as OrganizerContainerResource).meta;
type = 'container';
}
name = resource.name;
}
} else if (entry.type === 'folder') {
name = (entry as OrganizerFolder).name;
}
entries.push({
id: entryId,
type,
name,
parentId: parentMap.get(entryId),
depth,
path: currentPath,
position,
hasChildren: isFolder && children.length > 0,
childrenIds: children,
meta,
});
if (isFolder) {
children.forEach((childId, idx) => {
walk(childId, depth + 1, currentPath, idx);
});
}
}
walk(view.root, 0, [], 0);
return entries;
}
/**
@@ -127,12 +221,13 @@ export function resolveOrganizerView(
view: OrganizerView,
resources: OrganizerV1['resources']
): ResolvedOrganizerView {
const resolvedRoot = resolveEntry(view.root, view, resources);
const flatEntries = enrichFlatEntries(view, resources);
return {
id: view.id,
name: view.name,
root: resolvedRoot,
rootId: view.root,
flatEntries,
prefs: view.prefs,
};
}
@@ -574,3 +669,108 @@ export function moveEntriesToFolder(params: MoveEntriesToFolderParams): Organize
destinationFolder.children = Array.from(destinationChildren);
return newView;
}
export interface MoveItemsToPositionParams {
view: OrganizerView;
sourceEntryIds: Set<string>;
destinationFolderId: string;
position: number;
resources?: OrganizerV1['resources'];
}
/**
* Moves entries to a specific position within a destination folder.
* Combines moveEntriesToFolder with position-based insertion.
*/
export function moveItemsToPosition(params: MoveItemsToPositionParams): OrganizerView {
const { view, sourceEntryIds, destinationFolderId, position, resources } = params;
const movedView = moveEntriesToFolder({ view, sourceEntryIds, destinationFolderId });
const folder = movedView.entries[destinationFolderId] as OrganizerFolder;
const movedIds = Array.from(sourceEntryIds);
const otherChildren = folder.children.filter((id) => !sourceEntryIds.has(id));
const insertPos = Math.max(0, Math.min(position, otherChildren.length));
const reordered = [
...otherChildren.slice(0, insertPos),
...movedIds,
...otherChildren.slice(insertPos),
];
folder.children = reordered;
return movedView;
}
export interface RenameFolderParams {
view: OrganizerView;
folderId: string;
newName: string;
}
/**
* Renames a folder by updating its name property.
* This is simpler than the current create+delete approach.
*/
export function renameFolder(params: RenameFolderParams): OrganizerView {
const { view, folderId, newName } = params;
const newView = structuredClone(view);
const entry = newView.entries[folderId];
if (!entry) {
throw new Error(`Folder with id '${folderId}' not found`);
}
if (entry.type !== 'folder') {
throw new Error(`Entry '${folderId}' is not a folder`);
}
(entry as OrganizerFolder).name = newName;
return newView;
}
export interface CreateFolderWithItemsParams {
view: OrganizerView;
folderId: string;
folderName: string;
parentId: string;
sourceEntryIds?: string[];
position?: number;
resources?: OrganizerV1['resources'];
}
/**
* Creates a new folder and optionally moves items into it at a specific position.
* Combines createFolder + moveItems + positioning in a single atomic operation.
*/
export function createFolderWithItems(params: CreateFolderWithItemsParams): OrganizerView {
const { view, folderId, folderName, parentId, sourceEntryIds = [], position, resources } = params;
let newView = createFolderInView({
view,
folderId,
folderName,
parentId,
childrenIds: sourceEntryIds,
});
if (sourceEntryIds.length > 0) {
newView = moveEntriesToFolder({
view: newView,
sourceEntryIds: new Set(sourceEntryIds),
destinationFolderId: folderId,
});
}
if (position !== undefined) {
const parent = newView.entries[parentId] as OrganizerFolder;
const withoutNewFolder = parent.children.filter((id) => id !== folderId);
const insertPos = Math.max(0, Math.min(position, withoutNewFolder.length));
parent.children = [
...withoutNewFolder.slice(0, insertPos),
folderId,
...withoutNewFolder.slice(insertPos),
];
}
return newView;
}

View File

@@ -7,6 +7,7 @@ import { basename, dirname, join } from 'path';
import { applyPatch, createPatch, parsePatch, reversePatch } from 'diff';
import { coerce, compare, gte, lte } from 'semver';
import { compareVersions } from '@app/common/compare-semver-version.js';
import { getUnraidVersion } from '@app/common/dashboard/get-unraid-version.js';
export type ModificationEffect = 'nginx:reload';
@@ -212,9 +213,11 @@ export abstract class FileModification {
}
// Default implementation that can be overridden if needed
async shouldApply(): Promise<ShouldApplyWithReason> {
async shouldApply({
checkOsVersion = true,
}: { checkOsVersion?: boolean } = {}): Promise<ShouldApplyWithReason> {
try {
if (await this.isUnraidVersionGreaterThanOrEqualTo('7.2.0')) {
if (checkOsVersion && (await this.isUnraidVersionGreaterThanOrEqualTo('7.2.0'))) {
return {
shouldApply: false,
reason: 'Patch unnecessary for Unraid 7.2 or later because the Unraid API is integrated.',
@@ -274,25 +277,7 @@ export abstract class FileModification {
throw new Error(`Failed to compare Unraid version - missing comparison version`);
}
// Special handling for prerelease versions when base versions are equal
if (includePrerelease) {
const baseUnraid = `${unraidVersion.major}.${unraidVersion.minor}.${unraidVersion.patch}`;
const baseCompared = `${comparedVersion.major}.${comparedVersion.minor}.${comparedVersion.patch}`;
if (baseUnraid === baseCompared) {
const unraidHasPrerelease = unraidVersion.prerelease.length > 0;
const comparedHasPrerelease = comparedVersion.prerelease.length > 0;
// If one has prerelease and the other doesn't, handle specially
if (unraidHasPrerelease && !comparedHasPrerelease) {
// For gte: prerelease is considered greater than stable
// For lte: prerelease is considered less than stable
return compareFn === gte;
}
}
}
return compareFn(unraidVersion, comparedVersion);
return compareVersions(unraidVersion, comparedVersion, compareFn, { includePrerelease });
}
protected async isUnraidVersionGreaterThanOrEqualTo(

View File

@@ -0,0 +1,61 @@
import { readFile } from 'node:fs/promises';
import { ENABLE_NEXT_DOCKER_RELEASE } from '@app/environment.js';
import {
FileModification,
ShouldApplyWithReason,
} from '@app/unraid-api/unraid-file-modifier/file-modification.js';
export default class DockerContainersPageModification extends FileModification {
id: string = 'docker-containers-page';
public readonly filePath: string =
'/usr/local/emhttp/plugins/dynamix.docker.manager/DockerContainers.page';
async shouldApply(): Promise<ShouldApplyWithReason> {
const baseCheck = await super.shouldApply({ checkOsVersion: false });
if (!baseCheck.shouldApply) {
return baseCheck;
}
if (!ENABLE_NEXT_DOCKER_RELEASE) {
return {
shouldApply: false,
reason: 'ENABLE_NEXT_DOCKER_RELEASE is not enabled, so Docker overview table modification is not applied',
};
}
if (await this.isUnraidVersionGreaterThanOrEqualTo('7.3.0')) {
return {
shouldApply: true,
reason: 'Docker overview table WILL BE integrated in Unraid 7.3 or later. This modification is a temporary measure for testing.',
};
}
return {
shouldApply: false,
reason: 'Docker overview table modification is disabled for Unraid < 7.3',
};
}
protected async generatePatch(overridePath?: string): Promise<string> {
const fileContent = await readFile(this.filePath, 'utf-8');
const newContent = this.applyToSource();
return this.createPatchWithDiff(overridePath ?? this.filePath, fileContent, newContent);
}
private applyToSource(): string {
return `Menu="Docker:1"
Title="Docker Containers"
Tag="cubes"
Cond="is_file('/var/run/dockerd.pid')"
Markdown="false"
Nchan="docker_load"
Tabs="false"
---
<div class="unapi">
<unraid-docker-container-overview></unraid-docker-container-overview>
</div>
`;
}
}

View File

@@ -127,6 +127,13 @@ export class UnraidFileModificationService
this.logger.debug(
`Skipping modification: ${modification.id} - ${shouldApplyWithReason.reason}`
);
// Check if there's a leftover patch from a previous run that needs to be rolled back
try {
await modification.rollback(true);
this.logger.log(`Rolled back previously applied modification: ${modification.id}`);
} catch {
// No patch file exists or rollback failed - this is expected when the modification was never applied
}
}
} catch (error) {
if (error instanceof Error) {

View File

@@ -13,9 +13,11 @@ export enum GRAPHQL_PUBSUB_CHANNEL {
NOTIFICATION = "NOTIFICATION",
NOTIFICATION_ADDED = "NOTIFICATION_ADDED",
NOTIFICATION_OVERVIEW = "NOTIFICATION_OVERVIEW",
NOTIFICATION_WARNINGS_AND_ALERTS = "NOTIFICATION_WARNINGS_AND_ALERTS",
OWNER = "OWNER",
SERVERS = "SERVERS",
VMS = "VMS",
DOCKER_STATS = "DOCKER_STATS",
LOG_FILE = "LOG_FILE",
PARITY = "PARITY",
}

27
pnpm-lock.yaml generated
View File

@@ -187,6 +187,9 @@ importers:
exit-hook:
specifier: 4.0.0
version: 4.0.0
fast-xml-parser:
specifier: ^5.3.0
version: 5.3.0
fastify:
specifier: 5.5.0
version: 5.5.0
@@ -1064,6 +1067,9 @@ importers:
'@floating-ui/vue':
specifier: 1.1.9
version: 1.1.9(vue@3.5.20(typescript@5.9.2))
'@formkit/auto-animate':
specifier: ^0.9.0
version: 0.9.0
'@headlessui/vue':
specifier: 1.7.23
version: 1.7.23(vue@3.5.20(typescript@5.9.2))
@@ -1085,6 +1091,9 @@ importers:
'@nuxt/ui':
specifier: 4.0.0-alpha.0
version: 4.0.0-alpha.0(@babel/parser@7.28.4)(@netlify/blobs@9.1.2)(change-case@5.4.4)(db0@0.3.2)(embla-carousel@8.6.0)(focus-trap@7.6.5)(ioredis@5.7.0)(jwt-decode@4.0.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.2)(vite@7.1.3(@types/node@22.18.0)(jiti@2.5.1)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vue-router@4.5.1(vue@3.5.20(typescript@5.9.2)))(vue@3.5.20(typescript@5.9.2))(zod@3.25.76)
'@tanstack/vue-table':
specifier: ^8.21.3
version: 8.21.3(vue@3.5.20(typescript@5.9.2))
'@unraid/shared-callbacks':
specifier: 3.0.0
version: 3.0.0
@@ -2566,6 +2575,9 @@ packages:
'@floating-ui/vue@1.1.9':
resolution: {integrity: sha512-BfNqNW6KA83Nexspgb9DZuz578R7HT8MZw1CfK9I6Ah4QReNWEJsXWHN+SdmOVLNGmTPDi+fDT535Df5PzMLbQ==}
'@formkit/auto-animate@0.9.0':
resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==}
'@golevelup/nestjs-discovery@4.0.3':
resolution: {integrity: sha512-8w3CsXHN7+7Sn2i419Eal1Iw/kOjAd6Kb55M/ZqKBBwACCMn4WiEuzssC71LpBMI1090CiDxuelfPRwwIrQK+A==}
peerDependencies:
@@ -7730,6 +7742,10 @@ packages:
resolution: {integrity: sha512-y655CeyUQ+jj7KBbYMc4FG01V8ZQqjN+gDYGJ50RtfsUB8iG9AmwmwoAgeKLJdmueKKMrH1RJ7yXHTSoczdv5w==}
hasBin: true
fast-xml-parser@5.3.0:
resolution: {integrity: sha512-gkWGshjYcQCF+6qtlrqBqELqNqnt4CxruY6UVAWWnqb3DQ6qaNFEIKqzYep1XzHLM/QtrHVCxyPOtTk4LTQ7Aw==}
hasBin: true
fastify-plugin@4.5.1:
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
@@ -11375,6 +11391,9 @@ packages:
strnum@1.0.5:
resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==}
strnum@2.1.1:
resolution: {integrity: sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==}
strtok3@10.3.1:
resolution: {integrity: sha512-3JWEZM6mfix/GCJBBUrkA8p2Id2pBkyTkVCJKto55w080QBKZ+8R171fGrbiSp+yMO/u6F8/yUh7K4V9K+YCnw==}
engines: {node: '>=18'}
@@ -14139,6 +14158,8 @@ snapshots:
- '@vue/composition-api'
- vue
'@formkit/auto-animate@0.9.0': {}
'@golevelup/nestjs-discovery@4.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2))(reflect-metadata@0.1.14)(rxjs@7.8.2))':
dependencies:
'@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.1.14)(rxjs@7.8.2)
@@ -20085,6 +20106,10 @@ snapshots:
dependencies:
strnum: 1.0.5
fast-xml-parser@5.3.0:
dependencies:
strnum: 2.1.1
fastify-plugin@4.5.1: {}
fastify-plugin@5.0.1: {}
@@ -24246,6 +24271,8 @@ snapshots:
strnum@1.0.5: {}
strnum@2.1.1: {}
strtok3@10.3.1:
dependencies:
'@tokenizer/token': 0.3.0

View File

@@ -3,3 +3,5 @@ components.d.ts
composables/gql/
src/composables/gql/
dist/
.output/
.nuxt/

View File

@@ -5,12 +5,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ComponentMapping } from '~/components/Wrapper/component-registry';
import type { MockInstance } from 'vitest';
let lastUAppPortal: string | undefined;
// Mock @nuxt/ui components
vi.mock('@nuxt/ui/components/App.vue', () => ({
default: defineComponent({
name: 'UApp',
setup(_, { slots }) {
return () => h('div', { class: 'u-app' }, slots.default?.());
props: {
portal: {
type: String,
required: false,
},
},
setup(props, { slots }) {
lastUAppPortal = props.portal;
return () => h('div', { class: 'u-app', 'data-portal': props.portal ?? '' }, slots.default?.());
},
}),
}));
@@ -92,6 +101,7 @@ describe('mount-engine', () => {
// Import fresh module
vi.resetModules();
lastUAppPortal = undefined;
mockCreateI18nInstance.mockClear();
mockEnsureLocale.mockClear();
mockGetWindowLocale.mockReset();
@@ -124,6 +134,7 @@ describe('mount-engine', () => {
// Clean up DOM
document.body.innerHTML = '';
lastUAppPortal = undefined;
});
afterEach(() => {
@@ -218,6 +229,46 @@ describe('mount-engine', () => {
expect(element.getAttribute('message')).toBe('{&quot;text&quot;: &quot;Encoded&quot;}');
});
it('should configure UApp portal within scoped container', async () => {
const element = document.createElement('div');
element.id = 'portal-app';
document.body.appendChild(element);
mockComponentMappings.push({
selector: '#portal-app',
appId: 'portal-app',
component: TestComponent,
});
await mountUnifiedApp();
const portalRoot = document.getElementById('unraid-api-modals-virtual');
expect(portalRoot).toBeTruthy();
expect(portalRoot?.classList.contains('unapi')).toBe(true);
expect(lastUAppPortal).toBe('#unraid-api-modals-virtual');
});
it('should decorate the parent container when requested', async () => {
const container = document.createElement('div');
container.id = 'container';
const element = document.createElement('div');
element.id = 'test-app';
container.appendChild(element);
document.body.appendChild(container);
mockComponentMappings.push({
selector: '#test-app',
appId: 'test-app',
component: TestComponent,
decorateContainer: true,
});
await mountUnifiedApp();
expect(container.classList.contains('unapi')).toBe(true);
expect(element.classList.contains('unapi')).toBe(true);
});
it('should handle multiple selector aliases', async () => {
const element1 = document.createElement('div');
element1.id = 'app1';

32
web/components.d.ts vendored
View File

@@ -20,6 +20,8 @@ declare module 'vue' {
'ApiStatus.standalone': typeof import('./src/components/ApiStatus/ApiStatus.standalone.vue')['default']
'Auth.standalone': typeof import('./src/components/Auth.standalone.vue')['default']
Avatar: typeof import('./src/components/Brand/Avatar.vue')['default']
BaseLogViewer: typeof import('./src/components/Logs/BaseLogViewer.vue')['default']
BaseTreeTable: typeof import('./src/components/Common/BaseTreeTable.vue')['default']
Beta: typeof import('./src/components/UserProfile/Beta.vue')['default']
CallbackButton: typeof import('./src/components/UpdateOs/CallbackButton.vue')['default']
CallbackFeedback: typeof import('./src/components/UserProfile/CallbackFeedback.vue')['default']
@@ -33,9 +35,12 @@ declare module 'vue' {
ChangelogModal: typeof import('./src/components/UpdateOs/ChangelogModal.vue')['default']
CheckUpdateResponseModal: typeof import('./src/components/UpdateOs/CheckUpdateResponseModal.vue')['default']
'ColorSwitcher.standalone': typeof import('./src/components/ColorSwitcher.standalone.vue')['default']
ConfirmActionsModal: typeof import('./src/components/Common/ConfirmActionsModal.vue')['default']
ConfirmDialog: typeof import('./src/components/ConfirmDialog.vue')['default']
'ConnectSettings.standalone': typeof import('./src/components/ConnectSettings/ConnectSettings.standalone.vue')['default']
Console: typeof import('./src/components/Docker/Console.vue')['default']
ContainerOverviewCard: typeof import('./src/components/Docker/ContainerOverviewCard.vue')['default']
ContainerSizesModal: typeof import('./src/components/Docker/ContainerSizesModal.vue')['default']
'CriticalNotifications.standalone': typeof import('./src/components/Notifications/CriticalNotifications.standalone.vue')['default']
Detail: typeof import('./src/components/LayoutViews/Detail/Detail.vue')['default']
DetailContentHeader: typeof import('./src/components/LayoutViews/Detail/DetailContentHeader.vue')['default']
DetailLeftNavigation: typeof import('./src/components/LayoutViews/Detail/DetailLeftNavigation.vue')['default']
@@ -45,6 +50,19 @@ declare module 'vue' {
'DevModalTest.standalone': typeof import('./src/components/DevModalTest.standalone.vue')['default']
DevSettings: typeof import('./src/components/DevSettings.vue')['default']
'DevThemeSwitcher.standalone': typeof import('./src/components/DevThemeSwitcher.standalone.vue')['default']
DockerAutostartSettings: typeof import('./src/components/Docker/DockerAutostartSettings.vue')['default']
DockerConsoleViewer: typeof import('./src/components/Docker/DockerConsoleViewer.vue')['default']
DockerContainerManagement: typeof import('./src/components/Docker/DockerContainerManagement.vue')['default']
DockerContainerOverview: typeof import('./src/components/Docker/DockerContainerOverview.vue')['default']
'DockerContainerOverview.standalone': typeof import('./src/components/Docker/DockerContainerOverview.standalone.vue')['default']
DockerContainersTable: typeof import('./src/components/Docker/DockerContainersTable.vue')['default']
DockerContainerStatCell: typeof import('./src/components/Docker/DockerContainerStatCell.vue')['default']
DockerLogViewerModal: typeof import('./src/components/Docker/DockerLogViewerModal.vue')['default']
DockerNameCell: typeof import('./src/components/Docker/DockerNameCell.vue')['default']
DockerOrphanedAlert: typeof import('./src/components/Docker/DockerOrphanedAlert.vue')['default']
DockerPortConflictsAlert: typeof import('./src/components/Docker/DockerPortConflictsAlert.vue')['default']
DockerSidebarTree: typeof import('./src/components/Docker/DockerSidebarTree.vue')['default']
DockerTailscaleIndicator: typeof import('./src/components/Docker/DockerTailscaleIndicator.vue')['default']
Downgrade: typeof import('./src/components/UpdateOs/Downgrade.vue')['default']
'DowngradeOs.standalone': typeof import('./src/components/DowngradeOs.standalone.vue')['default']
DropdownConnectStatus: typeof import('./src/components/UserProfile/DropdownConnectStatus.vue')['default']
@@ -71,12 +89,13 @@ declare module 'vue' {
LocaleSwitcher: typeof import('./src/components/LocaleSwitcher.vue')['default']
LogFilterInput: typeof import('./src/components/Logs/LogFilterInput.vue')['default']
Logo: typeof import('./src/components/Brand/Logo.vue')['default']
Logs: typeof import('./src/components/Docker/Logs.vue')['default']
'LogViewer.standalone': typeof import('./src/components/Logs/LogViewer.standalone.vue')['default']
LogViewerToolbar: typeof import('./src/components/Logs/LogViewerToolbar.vue')['default']
Mark: typeof import('./src/components/Brand/Mark.vue')['default']
Modal: typeof import('./src/components/Modal.vue')['default']
'Modals.standalone': typeof import('./src/components/Modals.standalone.vue')['default']
MoveToFolderModal: typeof import('./src/components/Common/MoveToFolderModal.vue')['default']
MultiValueCopyBadges: typeof import('./src/components/Common/MultiValueCopyBadges.vue')['default']
OidcDebugButton: typeof import('./src/components/Logs/OidcDebugButton.vue')['default']
OidcDebugLogs: typeof import('./src/components/ConnectSettings/OidcDebugLogs.vue')['default']
Overview: typeof import('./src/components/Docker/Overview.vue')['default']
@@ -88,7 +107,9 @@ declare module 'vue' {
'Registration.standalone': typeof import('./src/components/Registration.standalone.vue')['default']
ReleaseNotesModal: typeof import('./src/components/ReleaseNotesModal.vue')['default']
RemoteItem: typeof import('./src/components/RClone/RemoteItem.vue')['default']
RemoveContainerModal: typeof import('./src/components/Docker/RemoveContainerModal.vue')['default']
ReplaceCheck: typeof import('./src/components/Registration/ReplaceCheck.vue')['default']
ResizableSlideover: typeof import('./src/components/Common/ResizableSlideover.vue')['default']
ResponsiveModal: typeof import('./src/components/ResponsiveModal.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
@@ -96,16 +117,19 @@ declare module 'vue' {
ServerStateBuy: typeof import('./src/components/UserProfile/ServerStateBuy.vue')['default']
ServerStatus: typeof import('./src/components/UserProfile/ServerStatus.vue')['default']
Sidebar: typeof import('./src/components/Notifications/Sidebar.vue')['default']
SingleDockerLogViewer: typeof import('./src/components/Docker/SingleDockerLogViewer.vue')['default']
SingleLogViewer: typeof import('./src/components/Logs/SingleLogViewer.vue')['default']
'SsoButton.standalone': typeof import('./src/components/SsoButton.standalone.vue')['default']
SsoButtons: typeof import('./src/components/sso/SsoButtons.vue')['default']
SsoProviderButton: typeof import('./src/components/sso/SsoProviderButton.vue')['default']
Status: typeof import('./src/components/UpdateOs/Status.vue')['default']
TableColumnMenu: typeof import('./src/components/Common/TableColumnMenu.vue')['default']
'TestThemeSwitcher.standalone': typeof import('./src/components/TestThemeSwitcher.standalone.vue')['default']
'TestUpdateModal.standalone': typeof import('./src/components/UpdateOs/TestUpdateModal.standalone.vue')['default']
'ThemeSwitcher.standalone': typeof import('./src/components/ThemeSwitcher.standalone.vue')['default']
ThirdPartyDrivers: typeof import('./src/components/UpdateOs/ThirdPartyDrivers.vue')['default']
Trial: typeof import('./src/components/UserProfile/Trial.vue')['default']
UAlert: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Alert.vue')['default']
UBadge: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Badge.vue')['default']
UButton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Button.vue')['default']
UCard: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Card.vue')['default']
@@ -115,6 +139,7 @@ declare module 'vue' {
UFormField: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/FormField.vue')['default']
UIcon: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/vue/components/Icon.vue')['default']
UInput: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Input.vue')['default']
UModal: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Modal.vue')['default']
UNavigationMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/NavigationMenu.vue')['default']
UnraidToaster: typeof import('./src/components/UnraidToaster.vue')['default']
Update: typeof import('./src/components/UpdateOs/Update.vue')['default']
@@ -122,10 +147,13 @@ declare module 'vue' {
UpdateExpirationAction: typeof import('./src/components/Registration/UpdateExpirationAction.vue')['default']
UpdateIneligible: typeof import('./src/components/UpdateOs/UpdateIneligible.vue')['default']
'UpdateOs.standalone': typeof import('./src/components/UpdateOs.standalone.vue')['default']
UPopover: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Popover.vue')['default']
UptimeExpire: typeof import('./src/components/UserProfile/UptimeExpire.vue')['default']
USelectMenu: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/SelectMenu.vue')['default']
'UserProfile.standalone': typeof import('./src/components/UserProfile.standalone.vue')['default']
USkeleton: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Skeleton.vue')['default']
USwitch: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Switch.vue')['default']
UTable: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Table.vue')['default']
UTabs: typeof import('./../node_modules/.pnpm/@nuxt+ui@4.0.0-alpha.0_@babel+parser@7.28.4_@netlify+blobs@9.1.2_change-case@5.4.4_db0@_655bac6707ae017754653173419b3890/node_modules/@nuxt/ui/dist/runtime/components/Tabs.vue')['default']
'WanIpCheck.standalone': typeof import('./src/components/WanIpCheck.standalone.vue')['default']
'WelcomeModal.standalone': typeof import('./src/components/Activation/WelcomeModal.standalone.vue')['default']

View File

@@ -71,7 +71,7 @@ const vueRules = {
'vue/no-unsupported-features': [
'error',
{
version: '^3.3.0',
version: '^3.5.0',
},
],
'vue/no-unused-properties': [

View File

@@ -102,6 +102,7 @@
"@floating-ui/dom": "1.7.4",
"@floating-ui/utils": "0.2.10",
"@floating-ui/vue": "1.1.9",
"@formkit/auto-animate": "^0.9.0",
"@headlessui/vue": "1.7.23",
"@heroicons/vue": "2.2.0",
"@jsonforms/core": "3.6.0",
@@ -109,6 +110,7 @@
"@jsonforms/vue-vanilla": "3.6.0",
"@jsonforms/vue-vuetify": "3.6.0",
"@nuxt/ui": "4.0.0-alpha.0",
"@tanstack/vue-table": "^8.21.3",
"@unraid/shared-callbacks": "3.0.0",
"@unraid/ui": "link:../unraid-ui",
"@vue/apollo-composable": "4.2.2",

View File

@@ -13,9 +13,24 @@ interface AtRule extends Container {
params: string;
}
type WalkAtRulesRoot = {
walkAtRules: (name: string, callback: (atRule: AtRule) => void) => void;
};
type ParentContainer = Container & {
insertBefore?: (oldNode: Container, newNode: Container) => void;
removeChild?: (node: Container) => void;
};
type RemovableAtRule = AtRule & {
nodes?: Container[];
remove?: () => void;
};
type PostcssPlugin = {
postcssPlugin: string;
Rule?(rule: Rule): void;
OnceExit?(root: WalkAtRulesRoot): void;
};
type PluginCreator<T> = {
@@ -163,6 +178,49 @@ export const scopeTailwindToUnapi: PluginCreator<ScopeOptions> = (options: Scope
rule.selector = scopedSelectors.join(', ');
}
},
OnceExit(root) {
// Remove @layer at-rules after all rules have been scoped.
// Tailwind CSS v4 uses @layer directives (e.g., @layer utilities, @layer components)
// to organize CSS. After the Rule hook scopes all selectors within these layers,
// the @layer wrappers are no longer needed in the final output.
//
// This cleanup step:
// 1. Extracts all scoped rules from inside @layer blocks
// 2. Moves them to the parent container (outside the @layer)
// 3. Removes the now-empty @layer wrapper
//
// This produces cleaner CSS output, avoids potential browser compatibility issues
// with CSS layers, and ensures the final CSS only contains the scoped rules without
// the organizational layer structure.
root.walkAtRules('layer', (atRule: AtRule) => {
const removableAtRule = atRule as RemovableAtRule;
const parent = atRule.parent as ParentContainer | undefined;
if (!parent) {
return;
}
// Extract all nodes from the @layer and move them to the parent
if (
Array.isArray(removableAtRule.nodes) &&
removableAtRule.nodes.length > 0 &&
typeof (parent as ParentContainer).insertBefore === 'function'
) {
const parentContainer = parent as ParentContainer;
while (removableAtRule.nodes.length) {
const node = removableAtRule.nodes[0]!;
parentContainer.insertBefore?.(atRule as unknown as Container, node);
}
}
// Remove the empty @layer wrapper
if (typeof removableAtRule.remove === 'function') {
removableAtRule.remove();
return;
}
(parent as ParentContainer).removeChild?.(atRule as unknown as Container);
});
},
};
};

View File

@@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>All Components - Unraid Component Test</title>
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
margin: 0;
padding: 20px;
background: #f3f4f6;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.component-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 20px;
}
.component-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.component-card h3 {
margin: 0 0 10px 0;
color: #1f2937;
font-size: 16px;
font-weight: 600;
}
.component-card .selector {
font-family: monospace;
font-size: 12px;
color: #6b7280;
margin-bottom: 15px;
background: #f3f4f6;
padding: 4px 8px;
border-radius: 4px;
display: inline-block;
}
.component-mount {
min-height: 50px;
border: 1px dashed #e5e7eb;
border-radius: 4px;
padding: 10px;
position: relative;
}
.status {
position: absolute;
top: 5px;
right: 5px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
}
.category-header {
background: #1f2937;
color: white;
padding: 10px 15px;
border-radius: 4px;
margin: 30px 0 15px 0;
font-weight: 600;
}
</style>
</head>
<body>
<div class="container">
<div class="category-header">🔔 Notifications</div>
<div class="component-card" style="grid-column: 1 / -1;">
<h3>Critical Notifications</h3>
<span class="selector">&lt;unraid-critical-notifications&gt;</span>
<div class="component-mount">
<unraid-critical-notifications></unraid-critical-notifications>
</div>
</div>
<!-- Docker -->
<div class="category-header">🐳 Docker</div>
<div class="component-grid">
<div class="component-card" style="grid-column: 1 / -1;">
<h3>Docker Container Overview</h3>
<span class="selector">&lt;unraid-docker-container-overview&gt;</span>
<div class="component-mount">
<unraid-docker-container-overview></unraid-docker-container-overview>
</div>
</div>
</div>
<!-- Authentication & User -->
<div class="category-header">👤 Authentication & User</div>
<div class="component-grid">
<div class="component-card">
<h3>Authentication</h3>
<span class="selector">&lt;unraid-auth&gt;</span>
<div class="component-mount">
<unraid-auth></unraid-auth>
</div>
</div>
<div class="component-card">
<h3>User Profile</h3>
<span class="selector">&lt;unraid-user-profile&gt;</span>
<div class="component-mount">
<unraid-user-profile></unraid-user-profile>
</div>
</div>
<div class="component-card">
<h3>SSO Button</h3>
<span class="selector">&lt;unraid-sso-button&gt;</span>
<div class="component-mount">
<unraid-sso-button></unraid-sso-button>
</div>
</div>
<div class="component-card">
<h3>Registration</h3>
<span class="selector">&lt;unraid-registration&gt;</span>
<div class="component-mount">
<unraid-registration></unraid-registration>
</div>
</div>
</div>
<!-- System & Settings -->
<div class="category-header">⚙️ System & Settings</div>
<div class="component-grid">
<div class="component-card">
<h3>Connect Settings</h3>
<span class="selector">&lt;unraid-connect-settings&gt;</span>
<div class="component-mount">
<unraid-connect-settings></unraid-connect-settings>
</div>
</div>
<div class="component-card">
<h3>Theme Switcher</h3>
<span class="selector">&lt;unraid-theme-switcher&gt;</span>
<div class="component-mount">
<unraid-theme-switcher current="white"></unraid-theme-switcher>
</div>
</div>
<div class="component-card">
<h3>Header OS Version</h3>
<span class="selector">&lt;unraid-header-os-version&gt;</span>
<div class="component-mount">
<unraid-header-os-version></unraid-header-os-version>
</div>
</div>
<div class="component-card">
<h3>WAN IP Check</h3>
<span class="selector">&lt;unraid-wan-ip-check&gt;</span>
<div class="component-mount">
<unraid-wan-ip-check php-wan-ip="192.168.1.1"></unraid-wan-ip-check>
</div>
</div>
</div>
<!-- OS Management -->
<div class="category-header">💿 OS Management</div>
<div class="component-grid">
<div class="component-card">
<h3>Update OS</h3>
<span class="selector">&lt;unraid-update-os&gt;</span>
<div class="component-mount">
<unraid-update-os></unraid-update-os>
</div>
</div>
<div class="component-card">
<h3>Downgrade OS</h3>
<span class="selector">&lt;unraid-downgrade-os&gt;</span>
<div class="component-mount">
<unraid-downgrade-os></unraid-downgrade-os>
</div>
</div>
</div>
<!-- API & Developer -->
<div class="category-header">🔧 API & Developer</div>
<div class="component-grid">
<div class="component-card">
<h3>API Key Manager</h3>
<span class="selector">&lt;unraid-api-key-manager&gt;</span>
<div class="component-mount">
<unraid-api-key-manager></unraid-api-key-manager>
</div>
</div>
<div class="component-card">
<h3>API Key Authorize</h3>
<span class="selector">&lt;unraid-api-key-authorize&gt;</span>
<div class="component-mount">
<unraid-api-key-authorize></unraid-api-key-authorize>
</div>
</div>
<div class="component-card">
<h3>Download API Logs</h3>
<span class="selector">&lt;unraid-download-api-logs&gt;</span>
<div class="component-mount">
<unraid-download-api-logs></unraid-download-api-logs>
</div>
</div>
<div class="component-card">
<h3>Log Viewer</h3>
<span class="selector">&lt;unraid-log-viewer&gt;</span>
<div class="component-mount">
<unraid-log-viewer></unraid-log-viewer>
</div>
</div>
</div>
<!-- UI Components -->
<div class="category-header">🎨 UI Components</div>
<div class="component-grid">
<div class="component-card">
<h3>Modals</h3>
<span class="selector">&lt;unraid-modals&gt;</span>
<div class="component-mount">
<unraid-modals></unraid-modals>
</div>
</div>
<div class="component-card">
<h3>Welcome Modal</h3>
<span class="selector">&lt;unraid-welcome-modal&gt;</span>
<div class="component-mount">
<unraid-welcome-modal></unraid-welcome-modal>
</div>
</div>
<div class="component-card">
<h3>Dev Modal Test</h3>
<span class="selector">&lt;unraid-dev-modal-test&gt;</span>
<div class="component-mount">
<unraid-dev-modal-test></unraid-dev-modal-test>
</div>
</div>
<div class="component-card">
<h3>Toaster</h3>
<span class="selector">&lt;unraid-toaster&gt;</span>
<div class="component-mount">
<unraid-toaster></unraid-toaster>
</div>
</div>
</div>
<!-- Test Controls -->
<div class="category-header">🎮 Test Controls</div>
<div style="background: white; padding: 20px; border-radius: 8px; margin-top: 15px;">
<h3>Language Selection</h3>
<div style="margin-bottom: 20px;">
<unraid-locale-switcher></unraid-locale-switcher>
</div>
<h3>jQuery Interaction Tests</h3>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 15px;">
<button id="test-notification" class="test-btn">Trigger Notification</button>
<button id="test-modal" class="test-btn">Open Test Modal</button>
<button id="test-theme" class="test-btn">Toggle Theme</button>
<button id="test-update-profile" class="test-btn">Update Profile Data</button>
<button id="test-settings" class="test-btn">Update Settings</button>
</div>
<div style="margin-top: 20px;">
<h4>Console Output</h4>
<div id="test-output" style="background: #1f2937; color: #10b981; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px; min-height: 100px; max-height: 200px; overflow-y: auto;">
> Ready for testing...
</div>
</div>
</div>
</div>
<style>
.test-btn {
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.test-btn:hover {
background: #2563eb;
}
</style>
<!-- Load the manifest and inject resources -->
<script src="/test-pages/load-manifest.js"></script>
<script src="/test-pages/test-server-state.js"></script>
<script src="/test-pages/shared-header.js"></script>
<!-- Test interactions -->
<script>
$(document).ready(function() {
const output = $('#test-output');
function log(message) {
// Use shared header's testLog if available, otherwise local log
if (window.testLog) {
window.testLog(message);
}
if (output.length) {
const timestamp = new Date().toLocaleTimeString();
output.append('\n> [' + timestamp + '] ' + message);
output.scrollTop(output[0].scrollHeight);
}
}
// Test notification
$('#test-notification').on('click', function() {
log('Triggering notification...');
const event = new CustomEvent('unraid:notification', {
detail: {
title: 'Test Notification',
message: 'This is a test from jQuery!',
type: 'success'
}
});
document.dispatchEvent(event);
});
// Test modal
$('#test-modal').on('click', function() {
log('Opening test modal...');
// This would trigger the modal system
window.dispatchEvent(new CustomEvent('unraid:open-modal', {
detail: { modalId: 'test-modal' }
}));
});
// Test theme toggle
$('#test-theme').on('click', function() {
log('Toggling theme...');
const currentTheme = $('body').hasClass('dark') ? 'light' : 'dark';
$('body').toggleClass('dark');
log('Theme changed to: ' + currentTheme);
});
// Test profile update
$('#test-update-profile').on('click', function() {
log('Updating profile data...');
const profileData = {
name: 'Test User ' + Math.floor(Math.random() * 100),
email: 'test' + Math.floor(Math.random() * 100) + '@example.com',
username: 'testuser'
};
$('unraid-user-profile').attr('server', JSON.stringify(profileData));
log('Profile updated: ' + JSON.stringify(profileData));
});
// Test settings update
$('#test-settings').on('click', function() {
log('Updating connect settings...');
const settings = {
enabled: Math.random() > 0.5,
url: 'https://connect.unraid.net',
lastSync: new Date().toISOString()
};
$('unraid-connect-settings').attr('initial-settings', JSON.stringify(settings));
log('Settings updated: ' + JSON.stringify(settings));
});
// Listen for component events
$(document).on('unraid:theme-changed', function(e, data) {
log('Theme changed event received: ' + JSON.stringify(data));
});
$(document).on('unraid:settings-saved', function(e, data) {
log('Settings saved event received: ' + JSON.stringify(data));
});
log('Test page initialized - all components loaded');
});
</script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
/*
/*
* Tailwind v4 configuration with Nuxt UI v3
* Using scoped selectors to prevent breaking Unraid WebGUI
*/
@@ -9,7 +9,7 @@
/* Import theme and utilities only - no global preflight */
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/utilities.css" layer(utilities);
/* @import "@nuxt/ui"; temporarily disabled */
@import "@nuxt/ui";
@import 'tw-animate-css';
@import '../../../@tailwind-shared/index.css';
@@ -17,7 +17,7 @@
@source "../**/*.{vue,ts,js,tsx,jsx}";
@source "../../../unraid-ui/src/**/*.{vue,ts,js,tsx,jsx}";
/*
/*
* Scoped base styles for .unapi elements only
* Import Tailwind's preflight into our custom layer and scope it
*/
@@ -28,122 +28,13 @@
@import "tailwindcss/preflight.css";
}
/* Override Unraid's button styles for Nuxt UI components */
.unapi button {
/* Reset Unraid's button styles */
margin: 0 !important;
padding: 0;
border: none;
background: none;
}
/* Accessible focus styles for keyboard navigation */
.unapi button:focus-visible {
outline: 2px solid #ff8c2f;
outline-offset: 2px;
}
/* Restore button functionality while removing Unraid's forced styles */
.unapi button:not([role="switch"]) {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
}
/* Ensure Nuxt UI modal/slideover close buttons work properly */
.unapi [role="dialog"] button,
.unapi [data-radix-collection-item] button {
margin: 0 !important;
background: transparent !important;
border: none !important;
}
/* Focus styles for dialog buttons */
.unapi [role="dialog"] button:focus-visible,
.unapi [data-radix-collection-item] button:focus-visible {
outline: 2px solid #ff8c2f;
outline-offset: 2px;
}
/* Reset figure element for logo */
.unapi figure {
margin: 0;
padding: 0;
}
/* Reset heading elements - only margin/padding */
.unapi h1,
.unapi h2,
.unapi h3,
.unapi h4,
.unapi h5 {
margin: 0;
padding: 0;
}
/* Reset paragraph element */
.unapi p {
margin: 0;
padding: 0;
text-align: unset;
}
/* Reset UL styles to prevent default browser styling */
.unapi ul {
padding-inline-start: 0;
list-style-type: none;
}
/* Reset toggle/switch button backgrounds */
.unapi button[role="switch"],
.unapi button[role="switch"][data-state="checked"],
.unapi button[role="switch"][data-state="unchecked"] {
background-color: transparent;
background: transparent;
border: 1px solid #ccc;
}
/* Style for checked state */
.unapi button[role="switch"][data-state="checked"] {
background-color: #ff8c2f; /* Unraid orange */
}
/* Style for unchecked state */
.unapi button[role="switch"][data-state="unchecked"] {
background-color: #e5e5e5;
}
/* Dark mode toggle styles */
.unapi.dark button[role="switch"][data-state="unchecked"],
.dark .unapi button[role="switch"][data-state="unchecked"] {
background-color: #333;
border-color: #555;
}
/* Toggle thumb/handle */
.unapi button[role="switch"] span {
background-color: white;
}
}
/* Override link styles inside .unapi */
.unapi a,
.unapi a:link,
.unapi a:visited {
color: inherit;
text-decoration: none;
}
.unapi a:hover,
.unapi a:focus {
text-decoration: underline;
color: inherit;
}
/* Note: Tailwind utilities will apply globally but should be used with .unapi prefix in HTML */
/* Ensure unraid-modals container has extremely high z-index */
unraid-modals.unapi {
position: relative;

View File

@@ -0,0 +1,617 @@
<script setup lang="ts" generic="T = unknown">
import { computed, h, ref, resolveComponent, watch } from 'vue';
import { useDragDrop } from '@/composables/useDragDrop';
import { useDropProjection } from '@/composables/useDropProjection';
import { getSelectableDescendants, useRowSelection } from '@/composables/useRowSelection';
import { useTreeExpansion } from '@/composables/useTreeExpansion';
import { useTreeFilter } from '@/composables/useTreeFilter';
import {
createDropIndicator,
createSelectColumnCell,
wrapCellWithRow,
wrapHeaderContent,
} from '@/utils/tableRenderers';
import type { DropEvent } from '@/composables/useDragDrop';
import type { TreeRow } from '@/composables/useTreeData';
import type { TableColumn } from '@nuxt/ui';
import type { HeaderContext } from '@tanstack/vue-table';
import type { Component, VNode } from 'vue';
type SearchAccessor<T> = (row: TreeRow<T>) => unknown | unknown[];
type FlatRow<T> = TreeRow<T> & { depth: number; parentId?: string };
type TableInstanceRow<T> = {
original: FlatRow<T>;
depth?: number;
getIsSelected: () => boolean;
toggleSelected: (value: boolean) => void;
getValue: (key: string) => unknown;
};
type EnhancedRow<T> = TableInstanceRow<T> & {
depth: number;
getIsExpanded: () => boolean;
toggleExpanded: () => void;
};
interface Props {
data: TreeRow<T>[];
columns: TableColumn<TreeRow<T>>[];
loading?: boolean;
compact?: boolean;
activeId?: string | null;
selectedIds?: string[];
selectableType?: string;
enableDragDrop?: boolean;
busyRowIds?: Set<string>;
searchableKeys?: string[];
searchAccessor?: SearchAccessor<T>;
includeMetaInSearch?: boolean;
canExpand?: (row: TreeRow<T>) => boolean;
canSelect?: (row: TreeRow<T>) => boolean;
canDrag?: (row: TreeRow<T>) => boolean;
canDropInside?: (row: TreeRow<T>) => boolean;
enableResizing?: boolean;
columnSizing?: Record<string, number>;
columnOrder?: string[];
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
compact: false,
activeId: null,
selectedIds: () => [],
enableDragDrop: false,
busyRowIds: () => new Set(),
includeMetaInSearch: true,
enableResizing: false,
columnSizing: () => ({}),
columnOrder: () => [],
});
const emit = defineEmits<{
(e: 'row:click', payload: { id: string; type: string; name: string; meta?: T }): void;
(
e: 'row:select',
payload: { id: string; type: string; name: string; selected: boolean; meta?: T }
): void;
(
e: 'row:contextmenu',
payload: { id: string; type: string; name: string; meta?: T; event: MouseEvent }
): void;
(e: 'row:drop', payload: DropEvent<T>): void;
(e: 'update:selectedIds', value: string[]): void;
(e: 'update:columnSizing', value: Record<string, number>): void;
(e: 'update:columnOrder', value: string[]): void;
}>();
const UButton = resolveComponent('UButton');
const UCheckbox = resolveComponent('UCheckbox');
const UIcon = resolveComponent('UIcon');
const UTable = resolveComponent('UTable') as Component;
const treeDataRef = computed(() => props.data);
const selectedIdsRef = ref(props.selectedIds);
const tableContainerRef = ref<HTMLElement | null>(null);
const columnVisibility = ref<Record<string, boolean>>({});
const columnSizing = defineModel<Record<string, number>>('columnSizing', { default: () => ({}) });
const columnOrderModel = defineModel<string[]>('columnOrder', { default: () => [] });
const columnOrderState = computed({
get: () => {
const order = columnOrderModel.value;
if (!order.length) return [];
const filtered = order.filter((id) => id !== 'select' && id !== 'drag');
const pinnedColumns = props.enableDragDrop ? ['drag', 'select'] : ['select'];
return [...pinnedColumns, ...filtered];
},
set: (value: string[]) => {
const filtered = value.filter((id) => id !== 'select' && id !== 'drag');
columnOrderModel.value = filtered;
},
});
type ColumnHeaderRenderer = TableColumn<TreeRow<T>>['header'];
watch(
() => props.selectedIds,
(val) => {
selectedIdsRef.value = val;
}
);
function canSelectRow(row: TreeRow<T>): boolean {
if (props.canSelect) return props.canSelect(row);
if (props.selectableType) return row.type === props.selectableType;
return false;
}
function canExpandRow(row: TreeRow<T>): boolean {
if (props.canExpand) return props.canExpand(row);
return !!(row.children && row.children.length);
}
function canDragRow(row: TreeRow<T>): boolean {
if (props.canDrag) return props.canDrag(row);
return props.enableDragDrop ?? false;
}
function canDropInsideRow(row: TreeRow<T>): boolean {
if (props.canDropInside) return props.canDropInside(row);
return false;
}
const { globalFilter, filteredData, setGlobalFilter } = useTreeFilter<T>({
data: treeDataRef,
searchableKeys: props.searchableKeys,
searchAccessor: props.searchAccessor,
includeMetaInSearch: props.includeMetaInSearch,
});
const { expandedRowIds, toggleExpanded, flattenedData, flatRowMap } = useTreeExpansion<T>({
data: filteredData,
});
const { rowSelection, getSelectedRowIds, flattenSelectableRows } = useRowSelection<T>({
selectedIds: selectedIdsRef,
treeData: treeDataRef,
selectableType: props.selectableType,
isSelectable: canSelectRow,
});
const {
handleDragStart,
handleDragEnd: composableDragEnd,
draggingIds,
} = useDragDrop<T>({
rowSelection,
});
const { projectionState, clearProjection, updateProjectionFromPointer } = useDropProjection<T>({
draggingIds,
flatRowMap,
tableContainerRef,
canDropInside: canDropInsideRow,
});
function handleDragEnd() {
composableDragEnd();
clearProjection();
}
watch(
rowSelection,
() => {
emit('update:selectedIds', getSelectedRowIds());
},
{ deep: true }
);
watch(
() => props.activeId,
(activeId) => {
if (activeId) {
const parentFolderIds = findParentFolderIds(activeId, filteredData.value);
if (parentFolderIds) {
for (const folderId of parentFolderIds) {
if (!expandedRowIds.value.has(folderId)) {
toggleExpanded(folderId);
}
}
}
}
},
{ immediate: true }
);
function handleContainerDragOver(event: DragEvent) {
if (!props.enableDragDrop) return;
// Always preventDefault to accept the drop - don't rely on draggingIds being set yet
// Chrome fires dragover before Vue reactivity updates from dragstart
event.preventDefault();
event.stopPropagation();
if (draggingIds.value.length) {
updateProjectionFromPointer(event.clientY);
}
}
function handleContainerDrop(event: DragEvent) {
if (!props.enableDragDrop || !draggingIds.value.length) return;
event.preventDefault();
event.stopPropagation();
const state = projectionState.value;
if (!state) return;
const targetRow = flatRowMap.value.get(state.targetId);
if (!targetRow) return;
try {
emit('row:drop', {
target: targetRow,
area: state.area,
sourceIds: [...draggingIds.value],
});
rowSelection.value = {};
} finally {
handleDragEnd();
}
}
const tableRef = ref<{ tableApi?: unknown } | null>(null);
watch(
() => props.compact,
(isCompact) => {
if (isCompact) {
const hideColumns: Record<string, boolean> = {};
props.columns.forEach((col) => {
const key = ((col as { id?: string; accessorKey?: string }).id ||
(col as { id?: string; accessorKey?: string }).accessorKey) as string;
if (key && key !== 'select' && key !== 'name') {
hideColumns[key] = false;
}
});
columnVisibility.value = hideColumns;
}
},
{ immediate: true }
);
const selectedCount = computed(() => {
return Object.values(rowSelection.value).filter(Boolean).length;
});
function createCellWrapper(
row: { original: FlatRow<T>; depth?: number },
cellContent: VNode,
columnIndex: number
) {
const isBusy = props.busyRowIds.has(row.original.id);
const isActive = props.activeId !== null && props.activeId === row.original.id;
const isDragging = props.enableDragDrop && draggingIds.value.includes(row.original.id);
const isProjectionTarget = projectionState.value?.targetId === row.original.id;
const projectionArea = projectionState.value?.area;
const draggable = props.enableDragDrop && canDragRow(row.original);
const dropIndicator =
props.enableDragDrop && isProjectionTarget && projectionArea
? createDropIndicator({ row: row.original, projectionArea, columnIndex })
: null;
return wrapCellWithRow({
row: row.original,
cellContent,
columnIndex,
isBusy,
isActive,
isDragging,
draggable,
isSelectable: canSelectRow(row.original),
dropIndicator,
enableDragDrop: props.enableDragDrop,
onRowClick: (id, type, name, meta) => {
emit('row:click', { id, type, name, meta });
},
onRowContextMenu: (id, type, name, meta, event) => {
emit('row:contextmenu', { id, type, name, meta, event });
},
onDragStart: handleDragStart,
onDragEnd: handleDragEnd,
});
}
function wrapColumnHeaderRenderer(
header: ColumnHeaderRenderer | undefined
): ColumnHeaderRenderer | undefined {
if (typeof header === 'function') {
return function wrappedHeaderRenderer(this: unknown, ...args: unknown[]) {
const result = (header as (...args: unknown[]) => unknown).apply(this, args);
return wrapHeaderContent(result, args[0] as HeaderContext<unknown, unknown>);
};
}
// Return a renderer that includes resizing logic
return (context: unknown) => {
const ctx = context as HeaderContext<unknown, unknown>;
const content = header !== undefined ? header : ctx?.column?.id;
return wrapHeaderContent(content, ctx);
};
}
function createSelectColumn(): TableColumn<TreeRow<T>> {
return {
id: 'select',
header: () => {
if (props.compact) return '';
const visibleRows = flattenedData.value;
const containers = flattenSelectableRows(visibleRows);
const totalSelectable = containers.length;
const selectedIds = Object.entries(rowSelection.value)
.filter(([, selected]) => selected)
.map(([id]) => id);
const selectedSet = new Set(selectedIds);
const selectedCount = containers.reduce(
(count, row) => (selectedSet.has(row.id) ? count + 1 : count),
0
);
const allSelected = totalSelectable > 0 && selectedCount === totalSelectable;
const someSelected = selectedCount > 0 && !allSelected;
return wrapHeaderContent(
h(UCheckbox, {
modelValue: someSelected ? 'indeterminate' : allSelected,
'onUpdate:modelValue': () => {
const target = someSelected || allSelected ? false : true;
const next = { ...rowSelection.value } as Record<string, boolean>;
for (const row of containers) {
if (target) {
next[row.id] = true;
} else {
delete next[row.id];
}
}
rowSelection.value = next;
},
'aria-label': 'Select all',
})
);
},
cell: ({ row }) => {
const enhancedRow = enhanceRowInstance(row as unknown as TableInstanceRow<T>);
return createSelectColumnCell(enhancedRow, {
UCheckbox: UCheckbox as Component,
UButton: UButton as Component,
compact: props.compact,
flatVisibleRows: flattenedData.value,
rowSelection: rowSelection.value,
canSelectRow,
canExpandRow,
flattenSelectableRows,
onSelectionChange: (selection) => {
rowSelection.value = selection;
},
onRowSelect: (id, type, name, selected, meta) => {
emit('row:select', { id, type, name, selected, meta });
},
wrapCell: createCellWrapper,
getIsExpanded: (id) => expandedRowIds.value.has(id),
toggleExpanded,
getIsSelected: (id) => !!rowSelection.value[id],
toggleSelected: (id, value) => {
if (value) {
rowSelection.value = { ...rowSelection.value, [id]: true };
} else {
const next = { ...rowSelection.value };
delete next[id];
rowSelection.value = next;
}
},
getSelectableDescendants: (row) => getSelectableDescendants(row, canSelectRow),
});
},
enableSorting: false,
enableHiding: false,
meta: { class: { th: 'w-10', td: 'w-10' } },
enableResizing: false,
};
}
function createDragColumn(): TableColumn<TreeRow<T>> {
return {
id: 'drag',
header: () => '',
cell: ({ row }) => {
const enhancedRow = enhanceRowInstance(row as unknown as TableInstanceRow<T>);
const canDrag = canDragRow(enhancedRow.original);
const isBusy = props.busyRowIds.has(enhancedRow.original.id);
const isDraggingThis = draggingIds.value.includes(enhancedRow.original.id);
if (!canDrag) {
return createCellWrapper(enhancedRow, h('span', { class: 'w-4 inline-block' }), 0);
}
return createCellWrapper(
enhancedRow,
h(
'div',
{
class: `flex items-center justify-center select-none ${isBusy ? '' : 'cursor-grab active:cursor-grabbing'}`,
'data-drag-handle': 'true',
draggable: !isBusy && !isDraggingThis,
onMousedown: (e: MouseEvent) => {
console.log('[DragHandle] mousedown', {
rowId: enhancedRow.original.id,
rowName: enhancedRow.original.name,
isBusy,
isDraggingThis,
draggable: !isBusy && !isDraggingThis,
target: e.target,
currentTarget: e.currentTarget,
});
},
onDragstart: (e: DragEvent) => {
console.log('[DragHandle] dragstart fired', {
rowId: enhancedRow.original.id,
rowName: enhancedRow.original.name,
isBusy,
dataTransfer: e.dataTransfer,
});
if (isBusy) {
console.log('[DragHandle] dragstart prevented - row is busy');
e.preventDefault();
return;
}
handleDragStart(e, enhancedRow.original);
console.log('[DragHandle] handleDragStart called, draggingIds:', draggingIds.value);
},
onDragend: (e: DragEvent) => {
console.log('[DragHandle] dragend fired', {
rowId: enhancedRow.original.id,
dropEffect: e.dataTransfer?.dropEffect,
});
handleDragEnd();
},
onDrag: (e: DragEvent) => {
// Log occasionally during drag (throttled by checking if clientX changed significantly)
if (e.clientX % 50 < 5) {
console.log('[DragHandle] dragging...', { x: e.clientX, y: e.clientY });
}
},
},
[
h(UIcon, {
name: 'i-lucide-grip-vertical',
class: 'h-4 w-4 text-muted-foreground hover:text-foreground pointer-events-none',
}),
]
),
0
);
},
enableSorting: false,
enableHiding: false,
enableResizing: false,
meta: { class: { th: 'w-8', td: 'w-8' } },
};
}
const processedColumns = computed<TableColumn<TreeRow<T>>[]>(() => {
const baseColumnIndex = props.enableDragDrop ? 2 : 1;
return [
...(props.enableDragDrop ? [createDragColumn()] : []),
createSelectColumn(),
...props.columns.map((col, colIndex) => {
const originalHeader = col.header as ColumnHeaderRenderer | undefined;
const header = wrapColumnHeaderRenderer(originalHeader) ?? originalHeader;
const cell = (col as { cell?: unknown }).cell
? ({ row }: { row: TableInstanceRow<T> }) => {
const cellFn = (col as { cell: (args: unknown) => VNode | string | number }).cell;
const enhancedRow = enhanceRowInstance(row as unknown as TableInstanceRow<T>);
const content = typeof cellFn === 'function' ? cellFn({ row: enhancedRow }) : cellFn;
return createCellWrapper(enhancedRow, content as VNode, colIndex + baseColumnIndex);
}
: undefined;
return {
...col,
header,
cell,
} as TableColumn<TreeRow<T>>;
}),
];
});
defineExpose({
tableRef,
tableApi: computed(() => tableRef.value?.tableApi),
rowSelection,
selectedCount,
globalFilter,
columnVisibility,
setGlobalFilter,
toggleExpanded,
});
function findParentFolderIds(
targetId: string,
rows: TreeRow<T>[],
path: string[] = []
): string[] | null {
for (const row of rows) {
if (row.id === targetId) {
return path;
}
if (row.children && row.children.length) {
const newPath = row.type === 'folder' ? [...path, row.id] : path;
const found = findParentFolderIds(targetId, row.children, newPath);
if (found !== null) {
return found;
}
}
}
return null;
}
function enhanceRowInstance(row: TableInstanceRow<T>): EnhancedRow<T> {
return {
...row,
depth: (row.original?.depth ?? row.depth ?? 0) as number,
getIsExpanded: () => expandedRowIds.value.has(row.original.id),
toggleExpanded: () => toggleExpanded(row.original.id),
};
}
</script>
<template>
<div
class="w-full"
ref="tableContainerRef"
@dragover.capture="handleContainerDragOver"
@drop.capture="handleContainerDrop"
>
<slot
name="toolbar"
:selected-count="selectedCount"
:global-filter="globalFilter"
:column-visibility="columnVisibility"
:column-order="columnOrderState"
:row-selection="rowSelection"
:set-global-filter="setGlobalFilter"
>
<div v-if="!compact" class="mb-3 flex items-center gap-2">
<slot name="toolbar-start" />
<slot name="toolbar-end" />
</div>
</slot>
<UTable
ref="tableRef"
v-model:row-selection="rowSelection"
v-model:column-visibility="columnVisibility"
v-model:column-sizing="columnSizing"
v-model:column-order="columnOrderState"
:data="flattenedData"
:columns="processedColumns"
:get-row-id="(row: any) => row.id"
:get-row-can-select="(row: any) => canSelectRow(row.original)"
:column-filters-options="{ filterFromLeafRows: true }"
:column-sizing-options="{ enableColumnResizing: enableResizing, columnResizeMode: 'onChange' }"
:loading="loading"
:ui="{
td: 'p-0 empty:p-0',
thead: compact ? 'hidden' : '',
th: (compact ? 'hidden ' : '') + 'p-0',
}"
sticky
class="base-tree-table flex-1 pb-2"
/>
<div v-if="!loading && filteredData.length === 0" class="py-8 text-center text-gray-500">
<slot name="empty">No items found</slot>
</div>
</div>
</template>
<style scoped>
.base-tree-table :deep(th) {
padding: 0;
overflow: visible;
position: relative;
}
.base-tree-table :deep(td) {
overflow: visible;
}
.base-tree-table :deep(tr) {
position: relative;
}
.base-tree-table :deep(tr:has([data-row-active])) {
background-color: var(--color-primary-50);
}
:root.dark .base-tree-table :deep(tr:has([data-row-active])) {
background-color: color-mix(in srgb, var(--color-primary-950) 30%, transparent);
}
</style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
export interface ConfirmActionGroup {
label: string;
items: { name: string }[];
}
interface Props {
open: boolean;
title?: string;
groups: ConfirmActionGroup[];
}
withDefaults(defineProps<Props>(), {
title: 'Confirm actions',
});
const emit = defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'confirm'): void;
}>();
function handleConfirm() {
emit('confirm');
emit('update:open', false);
}
function handleClose() {
emit('update:open', false);
}
</script>
<template>
<UModal
:open="open"
:title="title"
:ui="{ footer: 'justify-end', overlay: 'z-50', content: 'z-50' }"
@update:open="$emit('update:open', $event)"
>
<template #body>
<div class="space-y-3">
<template v-for="group in groups" :key="group.label">
<div v-if="group.items.length" class="space-y-1">
<div class="text-sm font-medium">{{ group.label }}</div>
<ul class="list-disc pl-5 text-sm text-gray-600 dark:text-gray-300">
<li v-for="item in group.items" :key="item.name" class="truncate">{{ item.name }}</li>
</ul>
</div>
</template>
</div>
</template>
<template #footer>
<UButton color="neutral" variant="outline" @click="handleClose">Cancel</UButton>
<UButton @click="handleConfirm">Confirm</UButton>
</template>
</UModal>
</template>

View File

@@ -0,0 +1,165 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import type { FlatFolderRow } from '@/composables/useFolderTree';
interface Props {
open: boolean;
loading?: boolean;
folders: FlatFolderRow[];
expandedFolders: Set<string>;
selectedFolderId: string;
rootFolderId: string;
renamingFolderId?: string;
renameValue?: string;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
renamingFolderId: '',
renameValue: '',
});
const emit = defineEmits<{
(e: 'update:open', value: boolean): void;
(e: 'update:selectedFolderId', value: string): void;
(e: 'update:renameValue', value: string): void;
(e: 'toggle-expand', id: string): void;
(e: 'create-folder', name: string): void;
(e: 'delete-folder'): void;
(e: 'start-rename', id: string, name: string): void;
(e: 'commit-rename', id: string): void;
(e: 'cancel-rename'): void;
(e: 'confirm'): void;
}>();
const newFolderName = ref('');
const canCreateFolder = computed(() => newFolderName.value.trim().length > 0);
const canDeleteFolder = computed(
() => props.selectedFolderId && props.selectedFolderId !== props.rootFolderId
);
function handleCreateFolder() {
const name = newFolderName.value.trim();
if (!name) return;
emit('create-folder', name);
newFolderName.value = '';
}
function handleConfirm() {
emit('confirm');
emit('update:open', false);
}
function handleClose() {
emit('update:open', false);
}
</script>
<template>
<UModal
:open="open"
title="Move to folder"
:ui="{ footer: 'justify-end', overlay: 'z-50', content: 'z-50' }"
@update:open="$emit('update:open', $event)"
>
<template #body>
<div class="space-y-3">
<div class="flex items-center gap-2">
<UInput v-model="newFolderName" placeholder="New folder name" class="flex-1" />
<UButton
size="sm"
color="neutral"
variant="outline"
:disabled="!canCreateFolder"
@click="handleCreateFolder"
>
Create
</UButton>
<UButton
size="sm"
color="neutral"
variant="outline"
:disabled="!canDeleteFolder"
@click="$emit('delete-folder')"
>
Delete
</UButton>
</div>
<div class="border-default rounded border">
<div
v-for="row in folders"
:key="row.id"
:data-id="row.id"
class="flex items-center gap-2 px-2 py-1 hover:bg-gray-50 dark:hover:bg-gray-800"
>
<UButton
v-if="row.hasChildren"
color="neutral"
size="xs"
variant="ghost"
icon="i-lucide-chevron-right"
:class="expandedFolders.has(row.id) ? 'rotate-90' : ''"
square
@click="$emit('toggle-expand', row.id)"
/>
<span v-else class="inline-block w-5" />
<input
type="radio"
:value="row.id"
:checked="selectedFolderId === row.id"
class="accent-primary"
@change="$emit('update:selectedFolderId', row.id)"
/>
<div
:style="{ paddingLeft: `calc(${row.depth} * 0.75rem)` }"
class="flex min-w-0 flex-1 items-center gap-2"
>
<span class="i-lucide-folder text-gray-500" />
<template v-if="renamingFolderId === row.id">
<input
:value="renameValue"
class="border-default bg-default flex-1 rounded border px-2 py-1"
@input="$emit('update:renameValue', ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="$emit('commit-rename', row.id)"
@keydown.esc.prevent="$emit('cancel-rename')"
@blur="$emit('commit-rename', row.id)"
autofocus
/>
</template>
<template v-else>
<span class="truncate">{{ row.name }}</span>
</template>
</div>
<UDropdownMenu
:items="[
[
{
label: 'Rename',
icon: 'i-lucide-pencil',
as: 'button',
onSelect: () => $emit('start-rename', row.id, row.name),
},
],
]"
:ui="{ content: 'z-50' }"
>
<UButton color="neutral" variant="ghost" icon="i-lucide-more-vertical" square />
</UDropdownMenu>
</div>
</div>
</div>
</template>
<template #footer>
<UButton color="neutral" variant="outline" @click="handleClose">Cancel</UButton>
<UButton :loading="loading" :disabled="!selectedFolderId" @click="handleConfirm">
Confirm
</UButton>
</template>
</UModal>
</template>

View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, resolveComponent, watch } from 'vue';
import { useClipboardWithToast } from '@/composables/useClipboardWithToast';
type Primitive = string | number | boolean | null | undefined;
interface Props {
/**
* Values to display. Accepts either an array or a single primitive.
*/
values?: Primitive[] | Primitive;
/**
* Maximum number of values to show inline before using the overflow popover.
*/
inlineLimit?: number;
/**
* Short label describing the values. Used for accessibility and toast copy messaging.
*/
label?: string;
/**
* Optional prefix to create stable element keys across renders (e.g., a row id).
*/
idPrefix?: string;
/**
* Text shown when there are no values.
*/
emptyText?: string;
/**
* Optional custom copy success message or builder function.
*/
copyMessage?: string | ((value: string) => string);
/**
* Badge size token passed to UBadge.
*/
size?: 'sm' | 'md';
/**
* Duration (ms) to keep the copied state highlighted.
*/
feedbackDuration?: number;
}
const props = withDefaults(defineProps<Props>(), {
values: () => [],
inlineLimit: 3,
label: 'Value',
idPrefix: '',
emptyText: '—',
size: 'sm',
feedbackDuration: 2000,
});
type ValueEntry = { value: string; index: number; key: string };
const UBadge = resolveComponent('UBadge');
const UPopover = resolveComponent('UPopover');
const UIcon = resolveComponent('UIcon');
const { copyWithNotification } = useClipboardWithToast();
function normalizeValues(input: Props['values']): string[] {
if (Array.isArray(input)) {
return input
.map((value) => (value === null || value === undefined ? '' : String(value).trim()))
.filter((value) => value.length > 0);
}
if (input === null || input === undefined) {
return [];
}
const trimmed = String(input).trim();
return trimmed ? [trimmed] : [];
}
function makeValueKey(prefix: string, value: string, index: number): string {
const safePrefix = prefix?.length ? prefix : 'value';
return `${safePrefix}-${index}-${value}`;
}
const normalizedValues = computed(() => normalizeValues(props.values));
const valueEntries = computed<ValueEntry[]>(() =>
normalizedValues.value.map((value, index) => ({
value,
index,
key: makeValueKey(props.idPrefix, value, index),
}))
);
const inlineEntries = computed(() => valueEntries.value.slice(0, props.inlineLimit));
const overflowEntries = computed(() => valueEntries.value.slice(props.inlineLimit));
const hasValues = computed(() => valueEntries.value.length > 0);
const copiedBadgeKeys = ref<Set<string>>(new Set());
const badgeCopyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
function clearBadgeTimeout(key: string) {
const timeout = badgeCopyTimeouts.get(key);
if (timeout) {
clearTimeout(timeout);
badgeCopyTimeouts.delete(key);
}
}
function markBadgeCopied(key: string) {
const next = new Set(copiedBadgeKeys.value);
next.add(key);
copiedBadgeKeys.value = next;
clearBadgeTimeout(key);
const timeoutId = setTimeout(() => {
const updated = new Set(copiedBadgeKeys.value);
updated.delete(key);
copiedBadgeKeys.value = updated;
badgeCopyTimeouts.delete(key);
}, props.feedbackDuration);
badgeCopyTimeouts.set(key, timeoutId);
}
function pruneObsoleteBadges() {
const allowed = new Set(valueEntries.value.map((entry) => entry.key));
const next = new Set<string>();
for (const key of copiedBadgeKeys.value) {
if (allowed.has(key)) {
next.add(key);
continue;
}
clearBadgeTimeout(key);
}
copiedBadgeKeys.value = next;
}
watch(valueEntries, pruneObsoleteBadges, { immediate: true });
function isBadgeCopied(key: string): boolean {
return copiedBadgeKeys.value.has(key);
}
function getCopyMessage(value: string): string {
if (typeof props.copyMessage === 'function') {
return props.copyMessage(value);
}
if (typeof props.copyMessage === 'string' && props.copyMessage.length) {
return props.copyMessage;
}
return `Copied ${props.label} to clipboard`;
}
async function handleCopy(entry: ValueEntry) {
if (!entry.value) return;
const success = await copyWithNotification(entry.value, getCopyMessage(entry.value));
if (success) {
markBadgeCopied(entry.key);
}
}
function getBadgeTitle(key: string): string {
return isBadgeCopied(key) ? 'Copied!' : 'Click to copy';
}
function getBadgeAriaLabel(key: string): string {
return isBadgeCopied(key) ? `${props.label} copied to clipboard` : `Copy ${props.label} value`;
}
function getBadgeColor(key: string) {
return isBadgeCopied(key) ? 'success' : 'neutral';
}
function getBadgeVariant(key: string) {
return isBadgeCopied(key) ? 'solid' : 'subtle';
}
function handleBadgeKeydown(event: KeyboardEvent, entry: ValueEntry) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
void handleCopy(entry);
}
}
onBeforeUnmount(() => {
badgeCopyTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
badgeCopyTimeouts.clear();
});
</script>
<template>
<div v-if="hasValues" class="flex flex-wrap items-center gap-1">
<component
:is="UBadge"
v-for="entry in inlineEntries"
:key="entry.key"
:color="getBadgeColor(entry.key)"
:variant="getBadgeVariant(entry.key)"
:size="size"
:title="getBadgeTitle(entry.key)"
:aria-label="getBadgeAriaLabel(entry.key)"
data-stop-row-click="true"
role="button"
tabindex="0"
class="max-w-[20ch] cursor-pointer items-center gap-1 truncate select-none"
@click.stop="handleCopy(entry)"
@keydown="handleBadgeKeydown($event, entry)"
>
<span class="flex min-w-0 items-center gap-1">
<component
:is="UIcon"
v-if="isBadgeCopied(entry.key)"
name="i-lucide-check"
class="h-4 w-4 flex-shrink-0 text-white/90"
/>
<span class="truncate">{{ entry.value }}</span>
</span>
</component>
<component :is="UPopover" v-if="overflowEntries.length">
<template #default>
<span data-stop-row-click="true" @click.stop>
<component
:is="UBadge"
color="neutral"
variant="outline"
:size="size"
class="cursor-pointer select-none"
>
+{{ overflowEntries.length }} more
</component>
</span>
</template>
<template #content>
<div class="max-h-64 max-w-xs space-y-1 overflow-y-auto p-1">
<component
:is="UBadge"
v-for="entry in overflowEntries"
:key="entry.key"
:color="getBadgeColor(entry.key)"
:variant="getBadgeVariant(entry.key)"
:size="size"
:title="getBadgeTitle(entry.key)"
:aria-label="getBadgeAriaLabel(entry.key)"
data-stop-row-click="true"
role="button"
tabindex="0"
class="w-full cursor-pointer items-center justify-start gap-1 truncate select-none"
@click.stop="handleCopy(entry)"
@keydown="handleBadgeKeydown($event, entry)"
>
<span class="flex min-w-0 items-center gap-1">
<component
:is="UIcon"
v-if="isBadgeCopied(entry.key)"
name="i-lucide-check"
class="h-4 w-4 flex-shrink-0 text-white/90"
/>
<span class="truncate">{{ entry.value }}</span>
</span>
</component>
</div>
</template>
</component>
</div>
<span v-else class="text-gray-500 dark:text-gray-400">{{ emptyText }}</span>
</template>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useEventListener, useSupported, useWindowSize } from '@vueuse/core';
import type { CSSProperties } from 'vue';
const props = withDefaults(
defineProps<{
title: string;
description?: string;
iframeUrl?: string;
buttonLabel?: string;
wrapperClass?: string;
minWidth?: number;
maxWidth?: number;
defaultWidth?: number;
}>(),
{
description: '',
iframeUrl: undefined,
buttonLabel: '',
wrapperClass: '',
minWidth: 400,
maxWidth: 1200,
defaultWidth: 800,
}
);
const open = defineModel<boolean>('open', { default: false });
const { width: windowWidth } = useWindowSize();
const sidebarWidth = ref(props.defaultWidth);
const isResizing = ref(false);
const clampSidebarWidth = (desired: number, viewport: number | null) => {
const safeViewport = viewport || props.defaultWidth;
const maxAllowed = Math.min(props.maxWidth, safeViewport);
const minAllowed = Math.min(props.minWidth, safeViewport);
return Math.min(Math.max(desired, minAllowed), maxAllowed);
};
const startResize = (e: MouseEvent) => {
isResizing.value = true;
if (import.meta.client) {
document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
}
e.preventDefault();
e.stopPropagation();
};
const handleResize = (e: MouseEvent) => {
if (!isResizing.value) return;
const viewportWidth = window.innerWidth;
const newWidth = viewportWidth - e.clientX;
sidebarWidth.value = clampSidebarWidth(newWidth, viewportWidth);
};
const stopResize = () => {
if (!isResizing.value) return;
isResizing.value = false;
if (import.meta.client) {
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
};
useEventListener('mousemove', handleResize);
useEventListener('mouseup', stopResize);
watch(
windowWidth,
(viewport) => {
if (!viewport) return;
const clamped = clampSidebarWidth(sidebarWidth.value, viewport);
if (clamped !== sidebarWidth.value) {
sidebarWidth.value = clamped;
}
},
{ immediate: true }
);
const slideoverUi = computed(() => ({
content: 'flex flex-col overflow-hidden',
body: 'relative flex flex-1 flex-col overflow-hidden p-0 sm:p-0',
}));
const slideoverContent = computed<Record<string, unknown>>(() => ({
class: 'max-w-none overflow-hidden',
style: {
width: `${sidebarWidth.value}px`,
maxWidth: `${Math.min(props.maxWidth, windowWidth.value || props.maxWidth)}px`,
minWidth: `${Math.min(props.minWidth, windowWidth.value || props.minWidth)}px`,
} as CSSProperties,
}));
const supportsCredentiallessIframe = useSupported(
() => typeof HTMLIFrameElement !== 'undefined' && 'credentialless' in HTMLIFrameElement.prototype
);
</script>
<template>
<div :class="wrapperClass">
<USlideover
v-model:open="open"
:title="title"
:description="description"
:ui="slideoverUi"
:content="slideoverContent"
side="right"
>
<template #body>
<div class="relative flex h-full min-h-0 w-full min-w-0 flex-1 flex-col">
<!-- Resize handle -->
<div
class="hover:bg-primary/20 absolute top-0 left-0 z-10 h-full w-2 cursor-ew-resize bg-transparent transition-colors"
@mousedown="startResize"
>
<div
class="bg-primary/40 hover:bg-primary/70 absolute top-1/2 left-1/2 h-16 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors"
/>
</div>
<!-- Pointer overlay while resizing -->
<div v-if="isResizing" class="absolute inset-0 z-20 cursor-ew-resize bg-transparent" />
<!-- Content: iframe or slot -->
<iframe
v-if="iframeUrl"
:src="iframeUrl"
class="h-full w-full min-w-0 flex-1 border-0"
:title="title"
referrerpolicy="no-referrer"
:credentialless="supportsCredentiallessIframe ? true : undefined"
/>
<div v-else class="h-full w-full min-w-0 flex-1 overflow-auto">
<slot />
</div>
</div>
</template>
</USlideover>
<UButton
v-if="buttonLabel"
variant="soft"
color="neutral"
size="md"
leading-icon="i-heroicons-question-mark-circle"
:label="buttonLabel"
@click="open = true"
/>
</div>
</template>

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useColumnDragDrop } from '@/composables/useColumnDragDrop';
import type { ColumnVisibilityTableInstance } from '@/composables/usePersistentColumnVisibility';
interface Props {
table: ColumnVisibilityTableInstance | null;
columnOrder?: string[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'change'): void;
(e: 'update:columnOrder', value: string[]): void;
}>();
const isOpen = ref(false);
const dropdownRef = ref<HTMLElement | null>(null);
onClickOutside(dropdownRef, () => {
isOpen.value = false;
});
const columnOrderState = ref<string[]>([]);
const orderedColumns = computed(() => {
if (!props.table?.tableApi) return [];
const availableColumns = props.table.tableApi.getAllColumns().filter((column) => column.getCanHide());
const columnIds = availableColumns.map((col) => col.id);
const order = props.columnOrder && props.columnOrder.length > 0 ? props.columnOrder : columnIds;
columnOrderState.value = order;
const columnMap = new Map(availableColumns.map((col) => [col.id, col]));
const ordered = order
.map((id) => columnMap.get(id))
.filter((col): col is NonNullable<typeof col> => col !== undefined);
const missing = availableColumns.filter((col) => !order.includes(col.id));
return [...ordered, ...missing];
});
const {
draggingColumnId,
dragOverColumnId,
handleDragStart,
handleDragEnd,
handleDragOver,
handleDrop,
} = useColumnDragDrop({
columnOrder: columnOrderState,
onReorder: (newOrder) => {
emit('update:columnOrder', newOrder);
emit('change');
},
});
function toggleColumnVisibility(columnId: string, checked: boolean | 'indeterminate') {
if (checked === 'indeterminate') return;
props.table?.tableApi?.getColumn?.(columnId)?.toggleVisibility(checked);
emit('change');
}
function toggleDropdown() {
isOpen.value = !isOpen.value;
}
</script>
<template>
<div ref="dropdownRef" class="relative">
<UButton
color="neutral"
variant="outline"
size="md"
trailing-icon="i-lucide-chevron-down"
@click="toggleDropdown"
>
Columns
</UButton>
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="isOpen"
class="ring-opacity-5 absolute left-0 z-10 mt-2 min-w-[220px] origin-top-right overflow-y-auto rounded-md bg-white shadow-lg ring-1 ring-black focus:outline-none dark:bg-gray-800"
>
<div class="py-1">
<div
v-for="column in orderedColumns"
:key="column.id"
:draggable="true"
:class="[
'flex cursor-move items-center gap-2 px-3 py-2 transition-colors select-none hover:bg-gray-50 dark:hover:bg-gray-700',
draggingColumnId === column.id && 'opacity-50',
dragOverColumnId === column.id && 'bg-primary-50 dark:bg-primary-900/20',
]"
@dragstart="(e: DragEvent) => handleDragStart(e, column.id)"
@dragend="handleDragEnd"
@dragover="(e: DragEvent) => handleDragOver(e, column.id)"
@drop="(e: DragEvent) => handleDrop(e, column.id)"
>
<UIcon name="i-lucide-grip-vertical" class="h-4 w-4 text-gray-400" />
<UCheckbox
:model-value="column.getIsVisible()"
@update:model-value="
(checked: boolean | 'indeterminate') => toggleColumnVisibility(column.id, checked)
"
@click.stop
/>
<span class="flex-1 text-sm">{{ column.id }}</span>
</div>
</div>
</div>
</Transition>
</div>
</template>

View File

@@ -1,59 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
interface Props {
item: {
id: string;
label: string;
icon?: string;
badge?: string | number;
};
}
const props = defineProps<Props>();
const command = ref('');
const output = ref<string[]>([
`root@${props.item.id}:/# echo "Welcome to ${props.item.label}"`,
`Welcome to ${props.item.label}`,
`root@${props.item.id}:/#`,
]);
const executeCommand = () => {
if (command.value.trim()) {
output.value.push(`root@${props.item.id}:/# ${command.value}`);
output.value.push(`${command.value}: command executed`);
output.value.push(`root@${props.item.id}:/#`);
command.value = '';
}
};
</script>
<template>
<div class="space-y-4">
<div class="flex items-center justify-between sm:mx-4">
<h3 class="text-lg font-medium">Terminal</h3>
<div class="flex gap-2">
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-maximize-2">
<span class="hidden sm:inline">Fullscreen</span>
</UButton>
<UButton size="sm" color="primary" variant="outline" icon="i-lucide-refresh-cw">
<span class="hidden sm:inline">Restart</span>
</UButton>
</div>
</div>
<div class="h-96 overflow-y-auto rounded-lg bg-black p-4 font-mono text-sm text-green-400 sm:mx-4">
<div v-for="(line, index) in output" :key="index">
{{ line }}
</div>
<div class="flex items-center">
<span>root@{{ item.id }}:/# </span>
<input
v-model="command"
class="ml-1 flex-1 bg-transparent outline-none"
type="text"
@keyup.enter="executeCommand"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,587 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useLazyQuery } from '@vue/apollo-composable';
import { GET_CONTAINER_TAILSCALE_STATUS } from '@/components/Docker/docker-tailscale-status.query';
import {
formatContainerIp,
formatExternalPorts,
formatImage,
formatInternalPorts,
formatNetwork,
formatUptime,
formatVolumes,
getFirstLanIp,
openLanIpInNewTab,
stripLeadingSlash,
} from '@/utils/docker';
import type { DockerContainer, TailscaleStatus } from '@/composables/gql/graphql';
interface Props {
container: DockerContainer | null | undefined;
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
const containerName = computed(() => stripLeadingSlash(props.container?.names?.[0]) || 'Unknown');
const stateColor = computed(() => {
const state = props.container?.state;
if (state === 'RUNNING') return 'success';
if (state === 'PAUSED') return 'warning';
if (state === 'EXITED') return 'error';
return 'neutral';
});
const stateLabel = computed(() => {
const state = props.container?.state;
if (!state) return 'Unknown';
return state.charAt(0).toUpperCase() + state.slice(1);
});
const imageVersion = computed(() => formatImage(props.container));
const networkMode = computed(() => formatNetwork(props.container));
const containerIps = computed(() => formatContainerIp(props.container));
const internalPorts = computed(() => formatInternalPorts(props.container));
const externalPorts = computed(() => formatExternalPorts(props.container));
const volumeMounts = computed(() => formatVolumes(props.container));
const uptime = computed(() => formatUptime(props.container));
const createdDate = computed(() => {
if (!props.container?.created) return null;
return new Date(props.container.created * 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
});
const shortId = computed(() => {
if (!props.container?.id) return null;
const id = props.container.id;
const colonIndex = id.indexOf(':');
const rawId = colonIndex > -1 ? id.slice(colonIndex + 1) : id;
return rawId.slice(0, 12);
});
const hasUpdateAvailable = computed(() => Boolean(props.container?.isUpdateAvailable));
const hasRebuildReady = computed(() => Boolean(props.container?.isRebuildReady));
const projectUrl = computed(() => props.container?.projectUrl || null);
const registryUrl = computed(() => props.container?.registryUrl || null);
const supportUrl = computed(() => props.container?.supportUrl || null);
const lanIpAddress = computed(() => getFirstLanIp(props.container));
const isTailscaleEnabled = computed(() => Boolean(props.container?.tailscaleEnabled));
const isContainerRunning = computed(() => props.container?.state === 'RUNNING');
const {
load: loadTailscaleStatus,
result: tailscaleResult,
loading: tailscaleLoading,
refetch: refetchTailscale,
} = useLazyQuery(GET_CONTAINER_TAILSCALE_STATUS, () => ({
id: props.container?.id,
}));
const tailscaleStatus = computed<TailscaleStatus | null | undefined>(
() => tailscaleResult.value?.docker?.container?.tailscaleStatus
);
const tailscaleFetched = ref(false);
const tailscaleRefreshing = ref(false);
watch(
() => props.container?.id,
(newId, oldId) => {
if (newId && newId !== oldId && isTailscaleEnabled.value && isContainerRunning.value) {
tailscaleFetched.value = false;
loadTailscaleStatus();
tailscaleFetched.value = true;
}
},
{ immediate: true }
);
async function handleRefreshTailscale() {
if (tailscaleRefreshing.value || tailscaleLoading.value) return;
tailscaleRefreshing.value = true;
try {
await refetchTailscale({ id: props.container?.id });
} finally {
tailscaleRefreshing.value = false;
}
}
function formatTailscaleDate(dateStr: string | Date | null | undefined): string {
if (!dateStr) return '—';
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
return date.toLocaleDateString();
}
function handleOpenWebUI() {
if (lanIpAddress.value) {
openLanIpInNewTab(lanIpAddress.value);
}
}
function handleOpenTailscaleWebUI() {
if (tailscaleStatus.value?.webUiUrl) {
window.open(tailscaleStatus.value.webUiUrl, '_blank');
}
}
function handleOpenTailscaleAuth() {
if (tailscaleStatus.value?.authUrl) {
window.open(tailscaleStatus.value.authUrl, '_blank');
}
}
</script>
<template>
<div v-if="loading" class="space-y-4">
<UCard variant="subtle">
<div class="flex items-start gap-4">
<USkeleton class="h-16 w-16 shrink-0 rounded-lg" />
<div class="min-w-0 flex-1 space-y-2">
<USkeleton class="h-6 w-48" />
<USkeleton class="h-4 w-32" />
<USkeleton class="h-4 w-24" />
</div>
</div>
</UCard>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<UCard v-for="i in 3" :key="i">
<template #header>
<USkeleton class="h-4 w-24" />
</template>
<div class="space-y-2">
<USkeleton class="h-4 w-full" />
<USkeleton class="h-4 w-3/4" />
</div>
</UCard>
</div>
</div>
<div v-else class="space-y-4">
<!-- Header Card -->
<UCard variant="subtle">
<div class="flex items-start gap-4">
<div
v-if="container?.iconUrl"
class="bg-muted flex h-16 w-16 shrink-0 items-center justify-center overflow-hidden rounded-lg"
>
<img :src="container.iconUrl" :alt="containerName" class="h-12 w-12 object-contain" />
</div>
<div v-else class="bg-muted flex h-16 w-16 shrink-0 items-center justify-center rounded-lg">
<UIcon name="i-lucide-box" class="h-8 w-8 text-gray-400" />
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="truncate text-lg font-semibold">{{ containerName }}</h3>
<UBadge :color="stateColor" variant="subtle" size="sm">
{{ stateLabel }}
</UBadge>
<UBadge v-if="hasUpdateAvailable" color="info" variant="soft" size="sm">
Update Available
</UBadge>
<UBadge v-if="hasRebuildReady" color="warning" variant="soft" size="sm">
Rebuild Ready
</UBadge>
</div>
<p v-if="imageVersion" class="text-muted-foreground mt-1 text-sm">
Version: {{ imageVersion }}
</p>
<p v-if="uptime" class="text-muted-foreground text-sm">Uptime: {{ uptime }}</p>
</div>
<div class="flex shrink-0 items-center gap-2">
<UButton
v-if="lanIpAddress"
size="sm"
variant="soft"
color="primary"
icon="i-lucide-external-link"
@click="handleOpenWebUI"
>
Web UI
</UButton>
<UButton
v-if="tailscaleStatus?.webUiUrl"
size="sm"
variant="soft"
color="primary"
icon="i-lucide-external-link"
@click="handleOpenTailscaleWebUI"
>
Web UI (Tailscale)
</UButton>
</div>
</div>
</UCard>
<!-- Stats Grid -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Network Card -->
<UCard>
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-network" class="h-4 w-4 text-gray-500" />
<span class="text-sm font-medium">Network</span>
</div>
</template>
<div class="space-y-2">
<div>
<p class="text-muted-foreground text-xs">Mode</p>
<p class="font-mono text-sm">{{ networkMode || '—' }}</p>
</div>
<div v-if="containerIps.length">
<p class="text-muted-foreground text-xs">Container IP</p>
<p v-for="ip in containerIps" :key="ip" class="font-mono text-sm">{{ ip }}</p>
</div>
</div>
</UCard>
<!-- Ports Card -->
<UCard>
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-plug" class="h-4 w-4 text-gray-500" />
<span class="text-sm font-medium">Ports</span>
</div>
</template>
<div class="space-y-2">
<div v-if="externalPorts.length">
<p class="text-muted-foreground text-xs">External</p>
<div class="flex flex-wrap gap-1">
<UBadge
v-for="port in externalPorts.slice(0, 5)"
:key="port"
:label="port"
color="neutral"
variant="subtle"
size="xs"
/>
<UBadge
v-if="externalPorts.length > 5"
:label="`+${externalPorts.length - 5}`"
color="neutral"
variant="soft"
size="xs"
/>
</div>
</div>
<div v-if="internalPorts.length">
<p class="text-muted-foreground text-xs">Internal</p>
<div class="flex flex-wrap gap-1">
<UBadge
v-for="port in internalPorts.slice(0, 5)"
:key="port"
:label="port"
color="neutral"
variant="subtle"
size="xs"
/>
<UBadge
v-if="internalPorts.length > 5"
:label="`+${internalPorts.length - 5}`"
color="neutral"
variant="soft"
size="xs"
/>
</div>
</div>
<p v-if="!externalPorts.length && !internalPorts.length" class="text-muted-foreground text-sm">
No ports exposed
</p>
</div>
</UCard>
<!-- Container Info Card -->
<UCard>
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-info" class="h-4 w-4 text-gray-500" />
<span class="text-sm font-medium">Container Info</span>
</div>
</template>
<div class="space-y-2">
<div v-if="shortId">
<p class="text-muted-foreground text-xs">ID</p>
<p class="font-mono text-sm">{{ shortId }}</p>
</div>
<div v-if="createdDate">
<p class="text-muted-foreground text-xs">Created</p>
<p class="text-sm">{{ createdDate }}</p>
</div>
<div>
<p class="text-muted-foreground text-xs">Auto Start</p>
<UBadge
:label="container?.autoStart ? 'Enabled' : 'Disabled'"
:color="container?.autoStart ? 'success' : 'neutral'"
variant="subtle"
size="xs"
/>
</div>
</div>
</UCard>
</div>
<!-- Volumes Card (if any) -->
<UCard v-if="volumeMounts.length">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-hard-drive" class="h-4 w-4 text-gray-500" />
<span class="text-sm font-medium">Volume Mounts</span>
<UBadge :label="String(volumeMounts.length)" color="neutral" variant="subtle" size="xs" />
</div>
</template>
<div class="max-h-48 space-y-2 overflow-y-auto">
<div v-for="(mount, index) in volumeMounts" :key="index" class="bg-muted/50 rounded-md p-2">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-folder" class="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-400" />
<div class="min-w-0 flex-1 space-y-1">
<p class="text-foreground truncate font-mono text-xs" :title="mount.split(' → ')[0]">
{{ mount.split(' → ')[0] }}
</p>
<div class="flex items-center gap-1">
<UIcon name="i-lucide-arrow-right" class="h-3 w-3 shrink-0 text-gray-400" />
<p
class="text-muted-foreground truncate font-mono text-xs"
:title="mount.split(' → ')[1]"
>
{{ mount.split(' → ')[1] }}
</p>
</div>
</div>
</div>
</div>
</div>
</UCard>
<!-- Tailscale Card -->
<UCard v-if="isTailscaleEnabled">
<template #header>
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-gray-500" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="6" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="12" r="3" />
<circle cx="12" cy="18" r="3" />
</svg>
<span class="flex-1 text-sm font-medium">Tailscale</span>
<button
v-if="tailscaleFetched"
class="hover:bg-muted rounded p-1"
:disabled="tailscaleLoading || tailscaleRefreshing"
title="Refresh Tailscale status"
@click="handleRefreshTailscale"
>
<UIcon
name="i-lucide-refresh-cw"
:class="['h-3.5 w-3.5', { 'animate-spin': tailscaleLoading || tailscaleRefreshing }]"
/>
</button>
</div>
</template>
<!-- Not running state -->
<div v-if="!isContainerRunning" class="text-sm text-gray-500">Container is not running</div>
<!-- Loading state -->
<div v-else-if="tailscaleLoading && !tailscaleStatus" class="space-y-2">
<USkeleton class="h-4 w-full" />
<USkeleton class="h-4 w-3/4" />
<USkeleton class="h-4 w-1/2" />
</div>
<!-- Tailscale status -->
<div v-else-if="tailscaleStatus" class="space-y-3">
<!-- Needs Login Warning -->
<div v-if="tailscaleStatus.backendState === 'NeedsLogin'" class="bg-warning/10 rounded p-2">
<div class="text-warning flex items-center gap-1 text-sm font-medium">
<UIcon name="i-lucide-alert-triangle" class="h-4 w-4" />
Authentication Required
</div>
<p class="text-warning mt-1 text-xs">Tailscale needs to be authenticated in this container.</p>
<UButton
v-if="tailscaleStatus.authUrl"
size="xs"
variant="soft"
color="warning"
class="mt-2"
@click="handleOpenTailscaleAuth"
>
Authenticate
<UIcon name="i-lucide-external-link" class="ml-1 h-3 w-3" />
</UButton>
</div>
<!-- Status info -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">Status</span>
<UBadge
:label="tailscaleStatus.online ? 'Online' : 'Offline'"
:color="tailscaleStatus.online ? 'success' : 'error'"
variant="subtle"
size="xs"
/>
</div>
<div v-if="tailscaleStatus.version" class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">Version</span>
<span class="flex items-center gap-1 text-sm">
v{{ tailscaleStatus.version }}
<UBadge
v-if="tailscaleStatus.updateAvailable"
label="Update"
color="warning"
variant="soft"
size="xs"
/>
</span>
</div>
<div v-if="tailscaleStatus.hostname" class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">Hostname</span>
<span class="max-w-[140px] truncate text-sm" :title="tailscaleStatus.hostname">
{{ tailscaleStatus.hostname }}
</span>
</div>
<div v-if="tailscaleStatus.dnsName" class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">DNS Name</span>
<span class="max-w-[140px] truncate text-sm" :title="tailscaleStatus.dnsName">
{{ tailscaleStatus.dnsName }}
</span>
</div>
<div
v-if="tailscaleStatus.relayName || tailscaleStatus.relay"
class="flex items-center justify-between"
>
<span class="text-muted-foreground text-xs">DERP Relay</span>
<span class="text-sm">{{ tailscaleStatus.relayName || tailscaleStatus.relay }}</span>
</div>
<div v-if="tailscaleStatus.tailscaleIps?.length" class="flex items-start justify-between">
<span class="text-muted-foreground text-xs">IP Addresses</span>
<div class="text-right">
<p v-for="ip in tailscaleStatus.tailscaleIps" :key="ip" class="font-mono text-xs">
{{ ip }}
</p>
</div>
</div>
<div v-if="tailscaleStatus.primaryRoutes?.length" class="flex items-start justify-between">
<span class="text-muted-foreground text-xs">Routes</span>
<div class="text-right">
<p v-for="route in tailscaleStatus.primaryRoutes" :key="route" class="font-mono text-xs">
{{ route }}
</p>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">Exit Node</span>
<span v-if="tailscaleStatus.isExitNode" class="text-sm text-green-500"
>This is an exit node</span
>
<span v-else-if="tailscaleStatus.exitNodeStatus" class="text-sm">
{{ tailscaleStatus.exitNodeStatus.online ? 'Connected' : 'Offline' }}
</span>
<span v-else class="text-sm text-gray-400">Not configured</span>
</div>
<div v-if="tailscaleStatus.keyExpiry" class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">Key Expiry</span>
<span :class="['text-sm', tailscaleStatus.keyExpired ? 'text-red-500' : '']">
{{ formatTailscaleDate(tailscaleStatus.keyExpiry) }}
<span v-if="tailscaleStatus.keyExpired" class="text-red-500">(Expired)</span>
<span
v-else-if="
tailscaleStatus.keyExpiryDays !== null && tailscaleStatus.keyExpiryDays !== undefined
"
class="text-gray-400"
>
({{ tailscaleStatus.keyExpiryDays }}d)
</span>
</span>
</div>
</div>
<!-- WebUI Button -->
<UButton
v-if="tailscaleStatus.webUiUrl"
size="xs"
variant="soft"
color="primary"
class="w-full"
@click="handleOpenTailscaleWebUI"
>
<UIcon name="i-lucide-external-link" class="mr-1 h-3 w-3" />
Open Tailscale WebUI
</UButton>
</div>
<!-- No data -->
<div v-else class="text-sm text-gray-500">No Tailscale data available</div>
</UCard>
<!-- Quick Links -->
<UCard v-if="projectUrl || registryUrl || supportUrl">
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-link" class="h-4 w-4 text-gray-500" />
<span class="text-sm font-medium">Links</span>
</div>
</template>
<div class="flex flex-wrap gap-2">
<UButton
v-if="projectUrl"
:to="projectUrl"
target="_blank"
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-home"
external
>
Project
</UButton>
<UButton
v-if="registryUrl"
:to="registryUrl"
target="_blank"
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-package"
external
>
Registry
</UButton>
<UButton
v-if="supportUrl"
:to="supportUrl"
target="_blank"
size="xs"
variant="ghost"
color="neutral"
icon="i-lucide-life-buoy"
external
>
Support
</UButton>
</div>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,175 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useQuery } from '@vue/apollo-composable';
import { GET_DOCKER_CONTAINER_SIZES } from '@/components/Docker/docker-container-sizes.query';
import { stripLeadingSlash } from '@/utils/docker';
import type { GetDockerContainerSizesQuery } from '@/composables/gql/graphql';
export interface Props {
open?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
open: false,
});
const emit = defineEmits<{
'update:open': [value: boolean];
}>();
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
});
const { result, loading, refetch } = useQuery<GetDockerContainerSizesQuery>(
GET_DOCKER_CONTAINER_SIZES,
undefined,
{
fetchPolicy: 'network-only',
enabled: computed(() => isOpen.value),
}
);
const containers = computed(() => result.value?.docker?.containers ?? []);
const tableRows = computed(() => {
return containers.value
.map((container) => {
const primaryName = stripLeadingSlash(container.names?.[0]) || 'Unknown';
const totalBytes = container.sizeRootFs ?? 0;
const writableBytes = container.sizeRw ?? 0;
const logBytes = container.sizeLog ?? 0;
return {
id: container.id,
name: primaryName,
totalBytes,
writableBytes,
logBytes,
};
})
.sort((a, b) => b.totalBytes - a.totalBytes)
.map((entry) => ({
id: entry.id,
name: entry.name,
total: formatBytes(entry.totalBytes),
writable: formatBytes(entry.writableBytes),
log: formatBytes(entry.logBytes),
}));
});
const totals = computed(() => {
const aggregate = containers.value.reduce(
(acc, container) => {
acc.total += container.sizeRootFs ?? 0;
acc.writable += container.sizeRw ?? 0;
acc.log += container.sizeLog ?? 0;
return acc;
},
{ total: 0, writable: 0, log: 0 }
);
return {
total: formatBytes(aggregate.total),
writable: formatBytes(aggregate.writable),
log: formatBytes(aggregate.log),
};
});
const columns = computed(() => [
{
accessorKey: 'name',
header: 'Container',
footer: 'Totals',
},
{
accessorKey: 'total',
header: 'Total',
footer: totals.value.total,
meta: { class: { td: 'text-right font-mono text-sm', th: 'text-right' } },
},
{
accessorKey: 'writable',
header: 'Writable',
footer: totals.value.writable,
meta: { class: { td: 'text-right font-mono text-sm', th: 'text-right' } },
},
{
accessorKey: 'log',
header: 'Log',
footer: totals.value.log,
meta: { class: { td: 'text-right font-mono text-sm', th: 'text-right' } },
},
]);
// Format byte counts into a short human-readable string (e.g. "1.2 GB").
function formatBytes(value?: number | null): string {
if (!Number.isFinite(value ?? NaN) || value === null || value === undefined) {
return '—';
}
if (value === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const formatter = new Intl.NumberFormat(undefined, {
maximumFractionDigits: size < 10 ? 2 : 1,
minimumFractionDigits: size < 10 && unitIndex > 0 ? 1 : 0,
});
return `${formatter.format(size)} ${units[unitIndex]}`;
}
async function handleRefresh() {
await refetch();
}
</script>
<template>
<UModal
v-model:open="isOpen"
title="Container Sizes"
:ui="{ footer: 'justify-end', content: 'sm:max-w-4xl' }"
>
<template #body>
<div class="space-y-4">
<div class="flex items-center justify-between gap-3">
<p class="text-muted-foreground text-sm">
Includes total filesystem, writable layer, and log file sizes per container.
</p>
<UButton
size="sm"
variant="outline"
icon="i-lucide-refresh-cw"
:loading="loading"
@click="handleRefresh"
>
Refresh
</UButton>
</div>
<UTable
:data="tableRows"
:columns="columns"
:loading="loading"
sticky="header"
:ui="{ td: 'py-2 px-3', th: 'py-2 px-3 text-left', tfoot: 'bg-muted' }"
>
<template #empty>
<div class="text-muted-foreground py-6 text-center text-sm">No containers found.</div>
</template>
</UTable>
</div>
</template>
</UModal>
</template>

View File

@@ -0,0 +1,409 @@
<script setup lang="ts">
import { computed, h, ref, resolveComponent, watch } from 'vue';
import { useMutation } from '@vue/apollo-composable';
import BaseTreeTable from '@/components/Common/BaseTreeTable.vue';
import { UPDATE_DOCKER_AUTOSTART_CONFIGURATION } from '@/components/Docker/docker-update-autostart-configuration.mutation';
import { useTreeData } from '@/composables/useTreeData';
import { stripLeadingSlash } from '@/utils/docker';
import type { DockerContainer } from '@/composables/gql/graphql';
import type { DropEvent } from '@/composables/useDragDrop';
import type { TreeRow } from '@/composables/useTreeData';
import type { TableColumn } from '@nuxt/ui';
import type { Component } from 'vue';
interface Props {
containers: DockerContainer[];
loading?: boolean;
refresh?: () => Promise<unknown>;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
refresh: undefined,
});
const emit = defineEmits<{
(e: 'close'): void;
}>();
interface AutostartEntry {
id: string;
container: DockerContainer;
autoStart: boolean;
wait: number;
}
function sanitizeWait(value: unknown): number {
const parsed = Number.parseInt(String(value ?? ''), 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
return 0;
}
function sortContainers(containers: DockerContainer[]): DockerContainer[] {
return [...containers].sort((a, b) => {
const aEnabled = Boolean(a.autoStart);
const bEnabled = Boolean(b.autoStart);
if (aEnabled && bEnabled) {
return (
(a.autoStartOrder ?? Number.MAX_SAFE_INTEGER) - (b.autoStartOrder ?? Number.MAX_SAFE_INTEGER)
);
}
if (aEnabled) return -1;
if (bEnabled) return 1;
const aName = stripLeadingSlash(a.names?.[0]);
const bName = stripLeadingSlash(b.names?.[0]);
return aName.localeCompare(bName);
});
}
function containersToEntries(containers: DockerContainer[]): AutostartEntry[] {
return sortContainers(containers).map((container) => ({
id: container.id,
container,
autoStart: Boolean(container.autoStart),
wait: sanitizeWait(container.autoStartWait),
}));
}
const entries = ref<AutostartEntry[]>([]);
const selectedIds = ref<string[]>([]);
function hasOrderChanged(previous?: AutostartEntry[]) {
if (!previous) return false;
if (previous.length !== entries.value.length) return true;
return previous.some((entry, index) => entry.id !== entries.value[index]?.id);
}
watch(
() => props.containers,
(containers) => {
const normalized = containersToEntries(containers);
const current = entries.value;
const isDifferent =
normalized.length !== current.length ||
normalized.some((entry, index) => {
const existing = current[index];
if (!existing) return true;
return (
entry.id !== existing.id ||
entry.autoStart !== existing.autoStart ||
entry.wait !== existing.wait
);
});
if (isDifferent) {
entries.value = normalized;
}
},
{ immediate: true, deep: true }
);
const { treeData } = useTreeData<AutostartEntry>({
flatData: entries,
buildFlatRow(entry) {
const name = stripLeadingSlash(entry.container.names?.[0]) || 'Unknown';
return {
id: entry.id,
type: 'container',
name,
state: entry.container.state ?? '',
meta: entry,
};
},
});
function getRowIndex(id: string) {
return entries.value.findIndex((entry) => entry.id === id);
}
function canMoveUp(id: string): boolean {
return getRowIndex(id) > 0;
}
function canMoveDown(id: string): boolean {
const index = getRowIndex(id);
return index >= 0 && index < entries.value.length - 1;
}
async function handleMoveUp(id: string) {
if (mutationLoading.value) return;
const index = getRowIndex(id);
if (index <= 0) return;
const snapshot = entries.value.map((entry) => ({ ...entry }));
const [removed] = entries.value.splice(index, 1);
entries.value.splice(index - 1, 0, removed);
await persistConfiguration(snapshot);
}
async function handleMoveDown(id: string) {
if (mutationLoading.value) return;
const index = getRowIndex(id);
if (index < 0 || index >= entries.value.length - 1) return;
const snapshot = entries.value.map((entry) => ({ ...entry }));
const [removed] = entries.value.splice(index, 1);
entries.value.splice(index + 1, 0, removed);
await persistConfiguration(snapshot);
}
const { mutate, loading: mutationLoading } = useMutation(UPDATE_DOCKER_AUTOSTART_CONFIGURATION);
const errorMessage = ref<string | null>(null);
async function persistConfiguration(previousSnapshot?: AutostartEntry[]) {
try {
errorMessage.value = null;
const persistUserPreferences = hasOrderChanged(previousSnapshot);
await mutate({
entries: entries.value.map((entry) => ({
id: entry.id,
autoStart: entry.autoStart,
wait: entry.autoStart ? entry.wait : 0,
})),
persistUserPreferences,
});
if (props.refresh) {
await props.refresh().catch((refreshError: unknown) => {
if (refreshError instanceof Error) {
errorMessage.value = refreshError.message;
} else {
errorMessage.value = 'Auto-start updated but failed to refresh data.';
}
});
}
} catch (error) {
if (previousSnapshot) {
entries.value = previousSnapshot.map((entry) => ({ ...entry }));
}
if (error instanceof Error) {
errorMessage.value = error.message;
} else {
errorMessage.value = 'Failed to update auto-start configuration.';
}
}
}
async function handleToggle(entry: AutostartEntry, value: boolean) {
if (mutationLoading.value || entry.autoStart === value) return;
const snapshot = entries.value.map((item) => ({ ...item }));
entry.autoStart = value;
if (!value) {
entry.wait = 0;
}
await persistConfiguration(snapshot);
}
async function handleWaitChange(entry: AutostartEntry, value: string | number) {
if (mutationLoading.value) return;
const normalized = sanitizeWait(value);
if (normalized === entry.wait) return;
const snapshot = entries.value.map((item) => ({ ...item }));
entry.wait = normalized;
await persistConfiguration(snapshot);
}
async function handleBulkToggle() {
if (saving.value || !selectedIds.value.length) return;
const snapshot = entries.value.map((item) => ({ ...item }));
const selected = new Set(selectedIds.value);
entries.value.forEach((entry) => {
if (!selected.has(entry.id)) return;
entry.autoStart = !entry.autoStart;
if (!entry.autoStart) {
entry.wait = 0;
}
});
await persistConfiguration(snapshot);
}
async function handleDrop(event: DropEvent<AutostartEntry>) {
if (mutationLoading.value) return;
const { target, area, sourceIds } = event;
const targetIndex = getRowIndex(target.id);
if (targetIndex === -1) return;
const snapshot = entries.value.map((entry) => ({ ...entry }));
const movingEntries = entries.value.filter((entry) => sourceIds.includes(entry.id));
let remainingEntries = entries.value.filter((entry) => !sourceIds.includes(entry.id));
let insertionIndex = targetIndex;
if (area === 'after' || area === 'inside') {
insertionIndex += 1;
}
remainingEntries.splice(insertionIndex, 0, ...movingEntries);
entries.value = remainingEntries;
await persistConfiguration(snapshot);
}
function handleClose() {
if (mutationLoading.value) return;
emit('close');
}
const busyRowIds = computed(() => {
if (!mutationLoading.value) return new Set<string>();
return new Set(entries.value.map((entry) => entry.id));
});
const saving = computed(() => props.loading || mutationLoading.value);
const hasSelection = computed(() => selectedIds.value.length > 0);
const UBadge = resolveComponent('UBadge') as Component;
const USwitch = resolveComponent('USwitch') as Component;
const UInput = resolveComponent('UInput') as Component;
const UButton = resolveComponent('UButton') as Component;
const columns = computed<TableColumn<TreeRow<AutostartEntry>>[]>(() => {
const cols: TableColumn<TreeRow<AutostartEntry>>[] = [
{
id: 'order',
header: '#',
cell: ({ row }) =>
h(
'span',
{ class: 'text-xs font-medium text-muted-foreground' },
String(getRowIndex(row.original.id) + 1)
),
meta: { class: { td: 'w-10', th: 'w-10' } },
},
{
id: 'name',
header: 'Container',
cell: ({ row }) => {
const entry = row.original.meta;
if (!entry) return row.original.name;
const badge = entry.container.state
? h(UBadge, {
label: entry.container.state,
variant: 'subtle',
size: 'sm',
})
: null;
return h('div', { class: 'flex items-center justify-between gap-3 pr-2' }, [
h('span', { class: 'font-medium' }, row.original.name),
badge,
]);
},
},
{
id: 'autoStart',
header: 'Auto Start',
cell: ({ row }) => {
const entry = row.original.meta;
if (!entry) return '';
return h(USwitch, {
modelValue: entry.autoStart,
'onUpdate:modelValue': (value: boolean) => handleToggle(entry, value),
disabled: saving.value,
});
},
meta: { class: { td: 'w-32', th: 'w-32' } },
},
{
id: 'wait',
header: 'Wait After Start (s)',
cell: ({ row }) => {
const entry = row.original.meta;
if (!entry) return '';
return h(UInput, {
type: 'number',
min: 0,
disabled: saving.value || !entry.autoStart,
modelValue: entry.wait,
class: 'w-24',
'onUpdate:modelValue': (value: string | number) => handleWaitChange(entry, value),
});
},
meta: { class: { td: 'w-48', th: 'w-48' } },
},
{
id: 'actions',
header: 'Order',
cell: ({ row }) => {
const id = row.original.id;
return h('div', { class: 'flex items-center gap-1' }, [
h(UButton, {
size: 'xs',
variant: 'ghost',
icon: 'i-lucide-arrow-up',
'aria-label': 'Move up',
disabled: saving.value || !canMoveUp(id),
onClick: () => handleMoveUp(id),
}),
h(UButton, {
size: 'xs',
variant: 'ghost',
icon: 'i-lucide-arrow-down',
'aria-label': 'Move down',
disabled: saving.value || !canMoveDown(id),
onClick: () => handleMoveDown(id),
}),
]);
},
meta: { class: { td: 'w-24', th: 'w-24' } },
},
];
return cols;
});
</script>
<template>
<div class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-lg font-semibold">Docker Auto-Start Order</h2>
<p class="text-muted-foreground text-sm">
Drag containers or use the arrow buttons to adjust the auto-start sequence. Changes are saved
automatically.
</p>
</div>
<div class="flex items-center gap-2">
<UButton
size="sm"
variant="soft"
icon="i-lucide-toggle-right"
:disabled="!hasSelection || saving"
@click="handleBulkToggle"
>
Toggle Auto Start
</UButton>
<UButton
size="sm"
variant="ghost"
icon="i-lucide-arrow-left"
:disabled="saving"
@click="handleClose"
>
Back to Overview
</UButton>
</div>
</div>
<div
v-if="errorMessage"
class="border-destructive/30 bg-destructive/10 text-destructive rounded-md border px-4 py-2 text-sm"
>
{{ errorMessage }}
</div>
<BaseTreeTable
:data="treeData"
:columns="columns"
:loading="saving"
:enable-drag-drop="true"
:busy-row-ids="busyRowIds"
:can-expand="() => false"
:can-select="(row: any) => row.type === 'container'"
:can-drag="() => true"
:can-drop-inside="() => false"
v-model:selected-ids="selectedIds"
@row:drop="handleDrop"
/>
</div>
</template>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useDockerConsoleSessions } from '@/composables/useDockerConsoleSessions';
interface Props {
containerName: string;
shell?: string;
}
const props = withDefaults(defineProps<Props>(), {
shell: 'sh',
});
const { getSession, createSession, showSession, hideSession, destroySession, markPoppedOut } =
useDockerConsoleSessions();
const isConnecting = ref(false);
const hasError = ref(false);
const isPoppedOut = ref(false);
const placeholderRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;
const socketPath = computed(() => {
const encodedName = encodeURIComponent(props.containerName.replace(/ /g, '_'));
return `/logterminal/${encodedName}/`;
});
const showPlaceholder = computed(() => !isConnecting.value && !hasError.value && !isPoppedOut.value);
function updatePosition() {
if (placeholderRef.value && showPlaceholder.value) {
const rect = placeholderRef.value.getBoundingClientRect();
showSession(props.containerName, rect);
}
}
async function initTerminal() {
const existingSession = getSession(props.containerName);
if (existingSession && !existingSession.isPoppedOut) {
isPoppedOut.value = false;
hasError.value = false;
isConnecting.value = false;
requestAnimationFrame(updatePosition);
return;
}
isConnecting.value = true;
hasError.value = false;
isPoppedOut.value = false;
try {
await createSession(props.containerName, props.shell);
isConnecting.value = false;
requestAnimationFrame(updatePosition);
} catch {
hasError.value = true;
isConnecting.value = false;
}
}
async function reconnect() {
destroySession(props.containerName);
await initTerminal();
}
async function openFullscreen() {
isPoppedOut.value = true;
markPoppedOut(props.containerName);
await new Promise((resolve) => setTimeout(resolve, 100));
const params = new URLSearchParams({
tag: 'docker',
name: props.containerName,
more: props.shell,
});
await fetch(`/webGui/include/OpenTerminal.php?${params.toString()}`);
await new Promise((resolve) => setTimeout(resolve, 300));
window.open(socketPath.value, '_blank', 'width=1200,height=800');
}
watch(
() => props.containerName,
(_newName, oldName) => {
if (oldName) {
hideSession(oldName);
}
initTerminal();
}
);
watch(showPlaceholder, (show) => {
if (show) {
requestAnimationFrame(updatePosition);
} else {
hideSession(props.containerName);
}
});
onMounted(() => {
initTerminal();
if (placeholderRef.value) {
resizeObserver = new ResizeObserver(() => {
if (showPlaceholder.value) {
updatePosition();
}
});
resizeObserver.observe(placeholderRef.value);
}
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
});
onBeforeUnmount(() => {
hideSession(props.containerName);
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
});
</script>
<template>
<div class="flex h-full flex-col">
<div class="mb-2 flex items-center justify-between">
<span class="text-muted-foreground text-sm"> {{ containerName }}: /bin/{{ shell }} </span>
<div class="flex gap-2">
<UButton
size="xs"
variant="ghost"
icon="i-lucide-maximize-2"
:disabled="isConnecting || hasError || isPoppedOut"
@click="openFullscreen"
/>
<UButton
size="xs"
variant="ghost"
icon="i-lucide-refresh-cw"
:loading="isConnecting"
@click="reconnect"
/>
</div>
</div>
<div v-if="isConnecting" class="flex flex-1 items-center justify-center rounded-lg bg-black">
<div class="text-center">
<UIcon name="i-lucide-loader-2" class="h-8 w-8 animate-spin text-green-400" />
<p class="mt-2 text-sm text-green-400">Connecting to container...</p>
</div>
</div>
<div v-else-if="hasError" class="flex flex-1 items-center justify-center rounded-lg bg-black">
<div class="text-center">
<UIcon name="i-lucide-alert-circle" class="h-8 w-8 text-red-400" />
<p class="mt-2 text-sm text-red-400">Failed to connect to container</p>
<UButton size="xs" variant="outline" color="error" class="mt-4" @click="reconnect">
Retry
</UButton>
</div>
</div>
<div v-else-if="isPoppedOut" class="flex flex-1 items-center justify-center rounded-lg bg-black">
<div class="text-center">
<UIcon name="i-lucide-external-link" class="h-8 w-8 text-neutral-400" />
<p class="mt-2 text-sm text-neutral-400">Console opened in separate window</p>
<UButton size="xs" variant="outline" color="neutral" class="mt-4" @click="reconnect">
Reconnect here
</UButton>
</div>
</div>
<!-- Placeholder that the fixed-position iframe will overlay -->
<div
v-show="showPlaceholder"
ref="placeholderRef"
class="h-full w-full flex-1 rounded-lg bg-black"
/>
</div>
</template>

View File

@@ -0,0 +1,695 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useMutation, useQuery } from '@vue/apollo-composable';
import ContainerOverviewCard from '@/components/Docker/ContainerOverviewCard.vue';
import ContainerSizesModal from '@/components/Docker/ContainerSizesModal.vue';
import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.query';
import { RESET_DOCKER_TEMPLATE_MAPPINGS } from '@/components/Docker/docker-reset-template-mappings.mutation';
import DockerAutostartSettings from '@/components/Docker/DockerAutostartSettings.vue';
import DockerConsoleViewer from '@/components/Docker/DockerConsoleViewer.vue';
import DockerContainersTable from '@/components/Docker/DockerContainersTable.vue';
import DockerOrphanedAlert from '@/components/Docker/DockerOrphanedAlert.vue';
import DockerPortConflictsAlert from '@/components/Docker/DockerPortConflictsAlert.vue';
import DockerSidebarTree from '@/components/Docker/DockerSidebarTree.vue';
import DockerEdit from '@/components/Docker/Edit.vue';
import DockerOverview from '@/components/Docker/Overview.vue';
import DockerPreview from '@/components/Docker/Preview.vue';
import SingleDockerLogViewer from '@/components/Docker/SingleDockerLogViewer.vue';
import LogViewerToolbar from '@/components/Logs/LogViewerToolbar.vue';
import { useDockerConsoleSessions } from '@/composables/useDockerConsoleSessions';
import { useDockerEditNavigation } from '@/composables/useDockerEditNavigation';
import { stripLeadingSlash } from '@/utils/docker';
import { useAutoAnimate } from '@formkit/auto-animate/vue';
import type {
DockerPortConflictsResult,
LanPortConflict,
PortConflictContainer,
} from '@/components/Docker/docker-port-conflicts.types';
import type { DockerContainer, FlatOrganizerEntry } from '@/composables/gql/graphql';
import type { LocationQueryRaw } from 'vue-router';
interface Props {
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
});
function tryUseRoute(): ReturnType<typeof useRoute> | null {
try {
const maybeRoute = useRoute();
return maybeRoute ?? null;
} catch (_err) {
return null;
}
}
function tryUseRouter(): ReturnType<typeof useRouter> | null {
try {
const maybeRouter = useRouter();
return maybeRouter ?? null;
} catch (_err) {
return null;
}
}
const route = tryUseRoute();
const router = tryUseRouter();
const hasRouter = Boolean(route && router);
const selectedIds = ref<string[]>([]);
const activeId = ref<string | null>(null);
const isSwitching = ref(false);
const viewMode = ref<'overview' | 'autostart'>('overview');
const showSizesModal = ref(false);
const ROUTE_QUERY_KEY = 'container';
const SWITCH_DELAY_MS = 150;
let switchTimeout: ReturnType<typeof setTimeout> | null = null;
let syncingFromRoute = false;
let removePopstateListener: (() => void) | null = null;
function normalizeContainerQuery(value: unknown): string | null {
if (Array.isArray(value))
return value.find((entry) => typeof entry === 'string' && entry.length) || null;
return typeof value === 'string' && value.length ? value : null;
}
function setActiveContainer(id: string | null) {
if (activeId.value === id) return;
if (switchTimeout) {
clearTimeout(switchTimeout);
switchTimeout = null;
}
if (id) {
isSwitching.value = true;
switchTimeout = setTimeout(() => {
isSwitching.value = false;
switchTimeout = null;
}, SWITCH_DELAY_MS);
} else {
isSwitching.value = false;
}
activeId.value = id;
}
if (hasRouter) {
watch(
() => normalizeContainerQuery(route!.query[ROUTE_QUERY_KEY]),
(routeId) => {
if (routeId === activeId.value) return;
syncingFromRoute = true;
setActiveContainer(routeId);
syncingFromRoute = false;
},
{ immediate: true }
);
watch(activeId, (nextId) => {
if (syncingFromRoute) return;
const currentRouteId = normalizeContainerQuery(route!.query[ROUTE_QUERY_KEY]);
if (nextId === currentRouteId) return;
const nextQuery: LocationQueryRaw = { ...route!.query };
if (nextId) nextQuery[ROUTE_QUERY_KEY] = nextId;
else delete nextQuery[ROUTE_QUERY_KEY];
router!.push({ path: route!.path, query: nextQuery, hash: route!.hash }).catch(() => {
/* ignore redundant navigation */
});
});
} else if (typeof window !== 'undefined') {
const readLocationQuery = () => {
const params = new URLSearchParams(window.location.search);
return normalizeContainerQuery(params.get(ROUTE_QUERY_KEY));
};
const initialId = readLocationQuery();
if (initialId) {
syncingFromRoute = true;
setActiveContainer(initialId);
syncingFromRoute = false;
}
const handlePopstate = () => {
const idFromLocation = readLocationQuery();
if (idFromLocation === activeId.value) return;
syncingFromRoute = true;
setActiveContainer(idFromLocation);
syncingFromRoute = false;
};
window.addEventListener('popstate', handlePopstate);
removePopstateListener = () => window.removeEventListener('popstate', handlePopstate);
watch(activeId, (nextId) => {
if (syncingFromRoute) return;
const current = readLocationQuery();
if (nextId === current) return;
const url = new URL(window.location.href);
if (nextId) url.searchParams.set(ROUTE_QUERY_KEY, nextId);
else url.searchParams.delete(ROUTE_QUERY_KEY);
window.history.pushState({}, '', `${url.pathname}${url.search}${url.hash}`);
});
}
onBeforeUnmount(() => {
if (switchTimeout) {
clearTimeout(switchTimeout);
switchTimeout = null;
}
if (removePopstateListener) {
removePopstateListener();
removePopstateListener = null;
}
});
const { result, loading, error, refetch } = useQuery<{
docker: {
id: string;
organizer: {
views: Array<{
id: string;
name: string;
rootId: string;
prefs?: Record<string, unknown> | null;
flatEntries: FlatOrganizerEntry[];
}>;
};
containers: DockerContainer[];
portConflicts: DockerPortConflictsResult;
};
}>(GET_DOCKER_CONTAINERS, {
fetchPolicy: 'cache-and-network',
variables: { skipCache: true },
});
const flatEntries = computed<FlatOrganizerEntry[]>(
() => result.value?.docker?.organizer?.views?.[0]?.flatEntries || []
);
const rootFolderId = computed(() => result.value?.docker?.organizer?.views?.[0]?.rootId || 'root');
const viewPrefs = computed(() => result.value?.docker?.organizer?.views?.[0]?.prefs || null);
const containers = computed<DockerContainer[]>(() => result.value?.docker?.containers || []);
const orphanedContainers = computed<DockerContainer[]>(() =>
containers.value.filter((c) => c.isOrphaned)
);
const portConflicts = computed<DockerPortConflictsResult | null>(() => {
const dockerData = result.value?.docker;
return dockerData?.portConflicts ?? null;
});
const lanPortConflicts = computed<LanPortConflict[]>(() => portConflicts.value?.lanPorts ?? []);
const { getLegacyEditUrl, shouldUseLegacyEditPage } = useDockerEditNavigation();
const { hasActiveSession } = useDockerConsoleSessions();
function getOrganizerEntryIdByContainerId(containerId: string): string | null {
const entry = flatEntries.value.find(
(candidate) =>
candidate.type === 'container' &&
(candidate.meta as DockerContainer | undefined)?.id === containerId
);
return entry?.id ?? null;
}
function focusContainerFromConflict(containerId: string) {
const entryId = getOrganizerEntryIdByContainerId(containerId);
if (!entryId) return;
setActiveContainer(entryId);
}
function handleConflictContainerAction(conflictContainer: PortConflictContainer) {
focusContainerFromConflict(conflictContainer.id);
}
watch(activeId, (id) => {
if (id && viewMode.value === 'autostart') {
viewMode.value = 'overview';
}
});
function openAutostartSettings() {
if (props.disabled) return;
viewMode.value = 'autostart';
}
function closeAutostartSettings() {
viewMode.value = 'overview';
}
function handleAddContainerClick() {
if (props.disabled) return;
if (typeof window === 'undefined') return;
const basePathFromRoute = hasRouter && route ? route.path : null;
const rawPath =
basePathFromRoute && basePathFromRoute !== '/' ? basePathFromRoute : window.location.pathname;
const sanitizedPath = rawPath.replace(/\?.*$/, '').replace(/\/+$/, '');
const withoutAdd = sanitizedPath.replace(/\/AddContainer$/i, '');
const targetPath = withoutAdd ? `${withoutAdd}/AddContainer` : '/AddContainer';
window.location.assign(targetPath);
}
async function refreshContainers() {
await refetch({ skipCache: true });
}
const { mutate: resetTemplateMappings, loading: resettingMappings } = useMutation(
RESET_DOCKER_TEMPLATE_MAPPINGS
);
async function handleResetAndRetry() {
try {
await resetTemplateMappings();
await refetch({ skipCache: true });
} catch (e) {
console.error('Failed to reset Docker template mappings:', e);
}
}
function handleTableRowClick(payload: {
id: string;
type: 'container' | 'folder';
name: string;
containerId?: string;
tab?: 'overview' | 'settings' | 'logs' | 'console';
}) {
if (payload.type !== 'container') return;
if (payload.tab) {
if (activeId.value === payload.id) {
legacyPaneTab.value = payload.tab;
} else {
pendingTab.value = payload.tab;
}
}
setActiveContainer(payload.id);
}
function handleUpdateSelectedIds(ids: string[]) {
selectedIds.value = ids;
}
function goBackToOverview() {
setActiveContainer(null);
}
function handleSidebarClick(item: { id: string }) {
setActiveContainer(item.id);
}
function handleSidebarSelect(item: { id: string; selected: boolean }) {
const set = new Set(selectedIds.value);
if (item.selected) set.add(item.id);
else set.delete(item.id);
selectedIds.value = Array.from(set);
}
const activeContainer = computed<DockerContainer | undefined>(() => {
if (!activeId.value) return undefined;
const entry = flatEntries.value.find((e) => e.id === activeId.value && e.type === 'container');
return entry?.meta as DockerContainer | undefined;
});
const legacyEditUrl = computed(() => getLegacyEditUrl(activeContainer.value));
// Details data (mix of real and placeholder until specific queries exist)
const detailsItem = computed(() => {
const name = stripLeadingSlash(activeContainer.value?.names?.[0]) || 'Unknown';
return {
id: activeContainer.value?.id || 'unknown',
label: name,
icon: 'i-lucide-box',
};
});
const details = computed(() => {
const c = activeContainer.value;
if (!c) return undefined;
const network = c.hostConfig?.networkMode || 'bridge';
const lanIpPort = Array.isArray(c.lanIpPorts) && c.lanIpPorts.length ? c.lanIpPorts.join(', ') : '—';
const ports = c.ports?.length ? c.ports : c.templatePorts;
return {
network,
lanIpPort,
containerIp: c.networkSettings?.IPAddress || '—',
uptime: '—',
containerPort: ports?.[0]?.privatePort?.toString?.() || '—',
creationDate: new Date((c.created || 0) * 1000).toISOString(),
containerId: c.id,
maintainer: c.labels?.maintainer || '—',
};
});
const isDetailsLoading = computed(() => loading.value || isSwitching.value);
const isDetailsDisabled = computed(() => props.disabled || isSwitching.value);
const legacyPaneTab = ref<'overview' | 'settings' | 'logs' | 'console'>('overview');
const pendingTab = ref<'overview' | 'settings' | 'logs' | 'console' | null>(null);
const logFilterText = ref('');
const logAutoScroll = ref(true);
const logViewerRef = ref<InstanceType<typeof SingleDockerLogViewer> | null>(null);
watch(activeId, (newId, oldId) => {
if (pendingTab.value) {
legacyPaneTab.value = pendingTab.value;
pendingTab.value = null;
} else if (!oldId && newId) {
// Only reset to 'overview' when opening details from overview (no previous container)
legacyPaneTab.value = 'overview';
}
// Otherwise keep the current tab when switching between containers
logFilterText.value = '';
});
const activeContainerName = computed(() => {
return stripLeadingSlash(activeContainer.value?.names?.[0]);
});
const hasActiveConsoleSession = computed(() => {
const name = activeContainerName.value;
return name ? hasActiveSession(name) : false;
});
const legacyPaneTabs = computed(() => [
{ label: 'Overview', value: 'overview' as const },
{ label: 'Settings', value: 'settings' as const },
{ label: 'Logs', value: 'logs' as const },
{
label: 'Console',
value: 'console' as const,
badge: hasActiveConsoleSession.value
? { color: 'success' as const, variant: 'solid' as const, class: 'w-2 h-2 p-0 min-w-0' }
: undefined,
},
]);
function handleLogRefresh() {
logViewerRef.value?.refreshLogContent();
}
const [transitionContainerRef] = useAutoAnimate({
duration: 200,
easing: 'ease-in-out',
});
</script>
<template>
<div ref="transitionContainerRef">
<div v-if="!activeId">
<template v-if="viewMode === 'overview'">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div class="text-base font-medium">Docker Containers</div>
<div class="flex items-center gap-2">
<UButton
size="xs"
variant="ghost"
icon="i-lucide-refresh-cw"
:loading="loading"
@click="refreshContainers"
/>
<UButton
size="xs"
color="primary"
variant="solid"
icon="i-lucide-plus"
:disabled="props.disabled"
@click="handleAddContainerClick"
>
Add Container
</UButton>
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-list-checks"
:disabled="loading"
@click="openAutostartSettings"
>
Customize Start Order
</UButton>
<UButton
size="xs"
color="neutral"
variant="outline"
icon="i-lucide-hard-drive"
:disabled="props.disabled"
@click="showSizesModal = true"
>
Container Size
</UButton>
</div>
</div>
<div v-if="orphanedContainers.length" class="mb-4">
<DockerOrphanedAlert :orphaned-containers="orphanedContainers" @refresh="refreshContainers" />
</div>
<div v-if="lanPortConflicts.length" class="mb-4">
<DockerPortConflictsAlert
:lan-conflicts="lanPortConflicts"
@container:select="handleConflictContainerAction"
/>
</div>
<UAlert
v-if="error"
color="error"
title="Failed to load Docker containers"
:description="error.message"
icon="i-lucide-alert-circle"
class="mb-4"
>
<template #actions>
<div class="flex gap-2">
<UButton size="xs" variant="soft" :loading="loading" @click="refetch({ skipCache: true })"
>Retry</UButton
>
<UButton
size="xs"
variant="outline"
:loading="resettingMappings"
@click="handleResetAndRetry"
>Reset &amp; Retry</UButton
>
</div>
</template>
</UAlert>
<div>
<DockerContainersTable
:containers="containers"
:flat-entries="flatEntries"
:root-folder-id="rootFolderId"
:view-prefs="viewPrefs"
:loading="loading"
:active-id="activeId"
:selected-ids="selectedIds"
@created-folder="refreshContainers"
@row:click="handleTableRowClick"
@update:selectedIds="handleUpdateSelectedIds"
/>
</div>
</template>
<DockerAutostartSettings
v-else
:containers="containers"
:loading="loading"
:refresh="refreshContainers"
@close="closeAutostartSettings"
/>
</div>
<div v-else class="grid gap-6 md:grid-cols-[280px_1fr]">
<UCard :ui="{ body: 'p-4 sm:px-0 sm:py-4' }">
<template #header>
<div class="flex items-center justify-between">
<div class="font-medium">Containers</div>
<UButton
size="xs"
variant="ghost"
icon="i-lucide-refresh-cw"
:loading="loading"
@click="refreshContainers"
/>
</div>
</template>
<USkeleton v-if="loading" class="h-6 w-full" :ui="{ rounded: 'rounded' }" />
<DockerSidebarTree
v-else
:containers="containers"
:flat-entries="flatEntries"
:root-folder-id="rootFolderId"
:selected-ids="selectedIds"
:active-id="activeId"
:disabled="props.disabled || loading"
@item:click="handleSidebarClick"
@item:select="handleSidebarSelect"
/>
</UCard>
<div v-if="shouldUseLegacyEditPage">
<UCard class="flex min-h-[60vh] flex-col">
<template #header>
<div class="flex flex-col gap-3">
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<UButton
size="xs"
variant="ghost"
icon="i-lucide-arrow-left"
@click="goBackToOverview"
/>
<div class="font-medium">
{{ stripLeadingSlash(activeContainer?.names?.[0]) || 'Container' }}
</div>
</div>
<UBadge
v-if="activeContainer?.state"
:label="activeContainer.state"
color="primary"
variant="subtle"
/>
</div>
<UTabs
v-model="legacyPaneTab"
:items="legacyPaneTabs"
variant="link"
color="primary"
size="md"
:ui="{ list: 'gap-1' }"
/>
</div>
</template>
<div
v-show="legacyPaneTab === 'overview'"
:class="['relative', { 'pointer-events-none opacity-50': isDetailsDisabled }]"
>
<ContainerOverviewCard :container="activeContainer" :loading="isDetailsLoading" />
</div>
<div
v-show="legacyPaneTab === 'settings'"
:class="['relative min-h-[60vh]', { 'pointer-events-none opacity-50': isDetailsDisabled }]"
>
<iframe
v-if="legacyEditUrl"
:key="legacyEditUrl"
:src="legacyEditUrl"
class="h-[70vh] w-full border-0"
loading="lazy"
/>
<div v-else class="flex h-[70vh] items-center justify-center text-sm text-neutral-500">
Unable to load container settings for this entry.
</div>
<div v-if="isDetailsLoading" class="bg-card/60 absolute inset-0 grid place-items-center">
<USkeleton class="h-6 w-6" />
</div>
</div>
<div
v-show="legacyPaneTab === 'logs'"
:class="['flex h-[70vh] flex-col', { 'pointer-events-none opacity-50': isDetailsDisabled }]"
>
<LogViewerToolbar
v-model:filter-text="logFilterText"
:show-refresh="false"
@refresh="handleLogRefresh"
/>
<SingleDockerLogViewer
v-if="activeContainer"
ref="logViewerRef"
:container-name="stripLeadingSlash(activeContainer.names?.[0])"
:auto-scroll="logAutoScroll"
:client-filter="logFilterText"
class="h-full flex-1"
/>
</div>
<div
v-show="legacyPaneTab === 'console'"
:class="['h-[70vh]', { 'pointer-events-none opacity-50': isDetailsDisabled }]"
>
<DockerConsoleViewer
v-if="activeContainer"
:container-name="activeContainerName"
:shell="activeContainer.shell ?? 'sh'"
class="h-full"
/>
</div>
</UCard>
</div>
<div v-else>
<UCard class="mb-4">
<template #header>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<UButton
size="xs"
variant="ghost"
icon="i-lucide-arrow-left"
@click="goBackToOverview"
/>
<div class="font-medium">Overview</div>
</div>
<UBadge
v-if="activeContainer?.state"
:label="activeContainer.state"
color="primary"
variant="subtle"
/>
</div>
</template>
<div class="relative">
<div v-if="isDetailsLoading" class="pointer-events-none opacity-50">
<DockerOverview :item="detailsItem" :details="details" />
</div>
<DockerOverview v-else :item="detailsItem" :details="details" />
<div v-if="isDetailsLoading" class="absolute inset-0 grid place-items-center">
<USkeleton class="h-6 w-6" />
</div>
</div>
</UCard>
<div class="3xl:grid-cols-2 grid gap-4">
<UCard>
<template #header>
<div class="font-medium">Preview</div>
</template>
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
<DockerPreview :item="detailsItem" :port="details?.containerPort || undefined" />
</div>
</UCard>
<UCard>
<template #header>
<div class="font-medium">Edit</div>
</template>
<div :class="{ 'pointer-events-none opacity-50': isDetailsDisabled }">
<DockerEdit :item="detailsItem" />
</div>
</UCard>
</div>
<UCard class="mt-4">
<template #header>
<div class="font-medium">Logs</div>
</template>
<div :class="['h-96', { 'pointer-events-none opacity-50': isDetailsDisabled }]">
<SingleDockerLogViewer
v-if="activeContainer"
:container-name="stripLeadingSlash(activeContainer.names?.[0])"
:auto-scroll="true"
class="h-full"
/>
</div>
</UCard>
</div>
</div>
<ContainerSizesModal v-model:open="showSizesModal" />
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
import DockerContainerManagement from '@/components/Docker/DockerContainerManagement.vue';
</script>
<template>
<div class="grid gap-8">
<DockerContainerManagement />
<!-- <DockerContainerOverview /> -->
<!-- <DockerOverview :item="item" :details="details" />
<DockerPreview :item="item" />
<DockerEdit :item="item" />
<DockerLogs :item="item" /> -->
</div>
</template>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useQuery } from '@vue/apollo-composable';
import { Button } from '@unraid/ui';
import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.query';
import DockerContainersTable from '@/components/Docker/DockerContainersTable.vue';
import { RefreshCwIcon } from 'lucide-vue-next';
import type { DockerContainer, FlatOrganizerEntry } from '@/composables/gql/graphql';
const { result, loading, error, refetch } = useQuery<{
docker: {
id: string;
organizer: {
views: Array<{
id: string;
name: string;
rootId: string;
prefs?: Record<string, unknown> | null;
flatEntries: FlatOrganizerEntry[];
}>;
};
};
}>(GET_DOCKER_CONTAINERS, {
fetchPolicy: 'cache-and-network',
});
const containers = computed<DockerContainer[]>(() => []);
const flatEntries = computed(() => result.value?.docker?.organizer?.views?.[0]?.flatEntries || []);
const rootFolderId = computed(() => result.value?.docker?.organizer?.views?.[0]?.rootId || 'root');
const viewPrefs = computed(() => result.value?.docker?.organizer?.views?.[0]?.prefs || null);
const handleRefresh = async () => {
await refetch({ skipCache: true });
};
</script>
<template>
<div class="docker-container-overview bg-card rounded-lg border p-6 shadow-sm">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-xl font-semibold">Docker Containers</h2>
<Button variant="outline" size="sm" @click="handleRefresh" :disabled="loading">
<RefreshCwIcon class="mr-2 h-4 w-4" :class="{ 'animate-spin': loading }" />
Refresh
</Button>
</div>
<div v-if="error" class="text-red-500">Error loading container data: {{ error.message }}</div>
<DockerContainersTable
:containers="containers"
:flat-entries="flatEntries"
:root-folder-id="rootFolderId"
:view-prefs="viewPrefs"
:loading="loading"
@created-folder="handleRefresh"
/>
</div>
</template>
<style scoped>
.docker-container-overview {
min-height: 400px;
}
</style>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { DockerContainerStats } from '@/composables/gql/graphql';
interface Props {
containerId?: string | null;
statsMap: Map<string, DockerContainerStats>;
type: 'cpu' | 'memory';
}
const props = defineProps<Props>();
const value = computed(() => {
if (!props.containerId) return '—';
const stats = props.statsMap.get(props.containerId);
if (!stats) return '—';
if (props.type === 'cpu') {
return `${stats.cpuPercent.toFixed(2)}%`;
}
return stats.memUsage;
});
</script>
<template>
<span class="tabular-nums">{{ value }}</span>
</template>

View File

@@ -0,0 +1,778 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { useApolloClient, useMutation } from '@vue/apollo-composable';
import { useStorage } from '@vueuse/core';
import BaseTreeTable from '@/components/Common/BaseTreeTable.vue';
import ConfirmActionsModal from '@/components/Common/ConfirmActionsModal.vue';
import MoveToFolderModal from '@/components/Common/MoveToFolderModal.vue';
import TableColumnMenu from '@/components/Common/TableColumnMenu.vue';
import { GET_DOCKER_CONTAINERS } from '@/components/Docker/docker-containers.query';
import { CREATE_DOCKER_FOLDER_WITH_ITEMS } from '@/components/Docker/docker-create-folder-with-items.mutation';
import { CREATE_DOCKER_FOLDER } from '@/components/Docker/docker-create-folder.mutation';
import { DELETE_DOCKER_ENTRIES } from '@/components/Docker/docker-delete-entries.mutation';
import { MOVE_DOCKER_ENTRIES_TO_FOLDER } from '@/components/Docker/docker-move-entries.mutation';
import { MOVE_DOCKER_ITEMS_TO_POSITION } from '@/components/Docker/docker-move-items-to-position.mutation';
import { PAUSE_DOCKER_CONTAINER } from '@/components/Docker/docker-pause-container.mutation';
import { REMOVE_DOCKER_CONTAINER } from '@/components/Docker/docker-remove-container.mutation';
import { SET_DOCKER_FOLDER_CHILDREN } from '@/components/Docker/docker-set-folder-children.mutation';
import { START_DOCKER_CONTAINER } from '@/components/Docker/docker-start-container.mutation';
import { STOP_DOCKER_CONTAINER } from '@/components/Docker/docker-stop-container.mutation';
import { GET_CONTAINER_TAILSCALE_STATUS } from '@/components/Docker/docker-tailscale-status.query';
import { UNPAUSE_DOCKER_CONTAINER } from '@/components/Docker/docker-unpause-container.mutation';
import DockerLogViewerModal from '@/components/Docker/DockerLogViewerModal.vue';
import RemoveContainerModal from '@/components/Docker/RemoveContainerModal.vue';
import { useContainerActions } from '@/composables/useContainerActions';
import { useContextMenu } from '@/composables/useContextMenu';
import { useDockerBulkActions } from '@/composables/useDockerBulkActions';
import { useDockerViewPreferences } from '@/composables/useDockerColumnVisibility';
import { useDockerConsoleSessions } from '@/composables/useDockerConsoleSessions';
import { useDockerContainerStats } from '@/composables/useDockerContainerStats';
import { useDockerLogSessions } from '@/composables/useDockerLogSessions';
import { useDockerRowActions } from '@/composables/useDockerRowActions';
import {
DOCKER_SEARCHABLE_KEYS,
getDefaultColumnVisibility,
useDockerTableColumns,
} from '@/composables/useDockerTableColumns';
import { useDockerUpdateActions } from '@/composables/useDockerUpdateActions';
import { useEntryReordering } from '@/composables/useEntryReordering';
import { useFolderOperations } from '@/composables/useFolderOperations';
import { useFolderTree } from '@/composables/useFolderTree';
import { usePersistentColumnVisibility } from '@/composables/usePersistentColumnVisibility';
import { getSelectableDescendants } from '@/composables/useRowSelection';
import { useTreeData } from '@/composables/useTreeData';
import { getRowDisplayLabel, stripLeadingSlash, toContainerTreeRow } from '@/utils/docker';
import type {
DockerContainer,
FlatOrganizerEntry,
GetContainerTailscaleStatusQuery,
} from '@/composables/gql/graphql';
import type { DropArea, DropEvent } from '@/composables/useDragDrop';
import type { ColumnVisibilityTableInstance } from '@/composables/usePersistentColumnVisibility';
import type { TreeRow } from '@/composables/useTreeData';
interface Props {
containers: DockerContainer[];
flatEntries?: FlatOrganizerEntry[];
rootFolderId?: string;
loading?: boolean;
compact?: boolean;
activeId?: string | null;
selectedIds?: string[];
viewPrefs?: Record<string, unknown> | null;
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
compact: false,
activeId: null,
selectedIds: () => [],
rootFolderId: 'root',
});
const emit = defineEmits<{
(e: 'created-folder'): void;
(
e: 'row:click',
payload: {
id: string;
type: 'container' | 'folder';
name: string;
containerId?: string;
tab?: 'overview' | 'settings' | 'logs' | 'console';
}
): void;
(
e: 'row:select',
payload: {
id: string;
type: 'container' | 'folder';
name: string;
containerId?: string;
selected: boolean;
}
): void;
(e: 'update:selectedIds', value: string[]): void;
}>();
// Refs
const flatEntriesRef = computed(() => props.flatEntries);
const containersRef = computed(() => props.containers);
const rootFolderId = computed<string>(() => props.rootFolderId || 'root');
const compactRef = computed(() => props.compact);
const selectedIdsRef = computed(() => props.selectedIds);
const hasFlatEntries = computed(() => !!props.flatEntries);
const baseTableRef = ref<
(ColumnVisibilityTableInstance & { toggleExpanded?: (id: string) => void }) | null
>(null);
const busyRowIds = ref<Set<string>>(new Set());
const columnSizing = useStorage<Record<string, number>>('docker-table-column-sizing', {});
const columnOrder = useStorage<string[]>('docker-table-column-order', []);
// Remove container modal state
const removeContainerModalOpen = ref(false);
const containerToRemove = ref<TreeRow<DockerContainer> | null>(null);
// Composables
const { containerStats } = useDockerContainerStats();
const logs = useDockerLogSessions();
const consoleSessions = useDockerConsoleSessions();
const contextMenu = useContextMenu<DockerContainer>();
const { client: apolloClient } = useApolloClient();
const { mergeServerPreferences, saveColumnVisibility, columnVisibilityRef } = useDockerViewPreferences();
const {
treeData,
entryParentById,
folderChildrenIds,
parentById,
positionById,
getRowById,
flattenRows,
} = useTreeData<DockerContainer>({
flatEntries: flatEntriesRef,
flatData: containersRef,
buildFlatRow: toContainerTreeRow,
});
const { visibleFolders, expandedFolders, toggleExpandFolder, setExpandedFolders } = useFolderTree({
flatEntries: flatEntriesRef,
});
// Mutations
const { mutate: createFolderMutation, loading: creating } = useMutation(CREATE_DOCKER_FOLDER);
const { mutate: createFolderWithItemsMutation } = useMutation(CREATE_DOCKER_FOLDER_WITH_ITEMS);
const { mutate: moveEntriesMutation, loading: moving } = useMutation(MOVE_DOCKER_ENTRIES_TO_FOLDER);
const { mutate: moveItemsToPositionMutation } = useMutation(MOVE_DOCKER_ITEMS_TO_POSITION);
const { mutate: deleteEntriesMutation, loading: deleting } = useMutation(DELETE_DOCKER_ENTRIES);
const { mutate: setFolderChildrenMutation } = useMutation(SET_DOCKER_FOLDER_CHILDREN);
const { mutate: startContainerMutation } = useMutation(START_DOCKER_CONTAINER);
const { mutate: stopContainerMutation } = useMutation(STOP_DOCKER_CONTAINER);
const { mutate: pauseContainerMutation } = useMutation(PAUSE_DOCKER_CONTAINER);
const { mutate: unpauseContainerMutation } = useMutation(UNPAUSE_DOCKER_CONTAINER);
const { mutate: removeContainerMutation, loading: removingContainer } =
useMutation(REMOVE_DOCKER_CONTAINER);
// Helpers
function showToast(message: string) {
window.toast?.success(message);
}
function showError(message: string, options?: { description?: string }) {
window.toast?.error?.(message, options);
}
function setRowsBusy(ids: string[], busy: boolean) {
const next = new Set(busyRowIds.value);
for (const id of ids) {
if (busy) next.add(id);
else next.delete(id);
}
busyRowIds.value = next;
}
function getContainerRows(ids: string[]): TreeRow<DockerContainer>[] {
const rows: TreeRow<DockerContainer>[] = [];
for (const id of ids) {
const row = getRowById(id, treeData.value);
if (row && row.type === 'container') {
rows.push(row as TreeRow<DockerContainer>);
}
}
return rows;
}
// Update actions
const {
updatingRowIds,
isUpdatingContainers,
activeUpdateSummary,
checkingForUpdates,
updatingAllContainers,
handleCheckForUpdates,
handleUpdateContainer,
handleBulkUpdateContainers,
handleUpdateAllContainers,
} = useDockerUpdateActions({
setRowsBusy,
showToast,
showError,
getRowById: (id) => getRowById(id, treeData.value),
});
// Container actions
const containerActions = useContainerActions({
getRowById,
treeData,
setRowsBusy,
startMutation: startContainerMutation,
stopMutation: stopContainerMutation,
pauseMutation: pauseContainerMutation,
unpauseMutation: unpauseContainerMutation,
refetchQuery: { query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } },
onSuccess: showToast,
onWillStartContainers: handleContainersWillStart,
});
// Folder operations
const refetchQuery = { query: GET_DOCKER_CONTAINERS, variables: { skipCache: true } };
const folderOps = reactive(
useFolderOperations({
rootFolderId,
folderChildrenIds,
parentById,
visibleFolders,
expandedFolders,
setExpandedFolders,
createFolderMutation,
deleteFolderMutation: deleteEntriesMutation,
setFolderChildrenMutation,
moveEntriesMutation,
refetchQuery,
onSuccess: showToast,
})
);
// Entry reordering
const entryReordering = useEntryReordering({
rootFolderId,
entryParentById,
folderChildrenIds,
treeData,
getRowById,
onMove: ({ rowId, parentId, position }) =>
moveItemsToPositionMutation(
{ sourceEntryIds: [rowId], destinationFolderId: parentId, position },
{ refetchQueries: [refetchQuery], awaitRefetchQueries: true }
),
});
// Derived data
const allContainerRows = computed<TreeRow<DockerContainer>[]>(() => {
return flattenRows(treeData.value, 'container') as TreeRow<DockerContainer>[];
});
const updateCandidateRows = computed<TreeRow<DockerContainer>[]>(() =>
allContainerRows.value.filter((row) => Boolean(row.meta?.isUpdateAvailable))
);
// Row actions
async function handleVisitTailscale(containerId: string) {
try {
const { data } = await apolloClient.query<GetContainerTailscaleStatusQuery>({
query: GET_CONTAINER_TAILSCALE_STATUS,
variables: { id: containerId },
fetchPolicy: 'network-only',
});
const webUiUrl = data?.docker?.container?.tailscaleStatus?.webUiUrl;
if (webUiUrl) {
window.open(webUiUrl, '_blank');
} else {
showError('Tailscale WebUI not available', {
description: 'The container may need to authenticate with Tailscale first.',
});
}
} catch {
showError('Failed to fetch Tailscale status');
}
}
function handleSelectAllChildren(row: TreeRow<DockerContainer>) {
const canSelect = (r: TreeRow<DockerContainer>) => r.type === 'container';
const descendants = getSelectableDescendants(row, canSelect);
if (descendants.length === 0) return;
const descendantIds = descendants.map((d) => d.id);
const currentSelected = new Set(props.selectedIds);
for (const id of descendantIds) {
currentSelected.add(id);
}
emit('update:selectedIds', Array.from(currentSelected));
}
function handleViewLogs(row: TreeRow<DockerContainer>) {
const containerName = row.name;
if (!containerName) return;
logs.openLogsForContainers([{ containerName, label: getRowDisplayLabel(row, row.name) }]);
}
function handleOpenConsole(row: TreeRow<DockerContainer>) {
const container = row.meta as DockerContainer | undefined;
emit('row:click', {
id: row.id,
type: 'container',
name: row.name,
containerId: container?.id,
tab: 'console',
});
}
function handleManageSettings(row: TreeRow<DockerContainer>) {
const container = row.meta as DockerContainer | undefined;
emit('row:click', {
id: row.id,
type: 'container',
name: row.name,
containerId: container?.id,
});
}
function openRemoveContainerModal(row: TreeRow<DockerContainer>) {
containerToRemove.value = row;
removeContainerModalOpen.value = true;
}
async function handleConfirmRemoveContainer(withImage: boolean) {
const row = containerToRemove.value;
if (!row || !row.containerId) return;
const containerName = stripLeadingSlash(row.meta?.names?.[0]) || row.name || '';
setRowsBusy([row.id], true);
try {
await removeContainerMutation(
{ id: row.containerId, withImage },
{ refetchQueries: [refetchQuery], awaitRefetchQueries: true }
);
const imageMsg = withImage ? ' and image' : '';
showToast(`Removed container${imageMsg}: ${containerName}`);
} catch (error) {
showError(`Failed to remove container: ${containerName}`, {
description: error instanceof Error ? error.message : undefined,
});
} finally {
setRowsBusy([row.id], false);
containerToRemove.value = null;
removeContainerModalOpen.value = false;
}
}
const { getRowActionItems } = useDockerRowActions({
updatingRowIds,
hasFlatEntries,
hasActiveConsoleSession: (name) => consoleSessions.hasActiveSession(name),
canMoveUp: (id) => entryReordering.canMoveUp(id),
canMoveDown: (id) => entryReordering.canMoveDown(id),
onMoveUp: (id) => entryReordering.moveUp(id),
onMoveDown: (id) => entryReordering.moveDown(id),
onSelectAllChildren: handleSelectAllChildren,
onRenameFolder: (id, name) => folderOps.renameFolderInteractive(id, name),
onDeleteFolder: (id) => folderOps.deleteFolderById(id),
onMoveToFolder: (ids) => folderOps.openMoveModal(ids),
onStartStop: (row) => containerActions.handleRowStartStop(row),
onPauseResume: (row) => containerActions.handleRowPauseResume(row),
onViewLogs: handleViewLogs,
onOpenConsole: handleOpenConsole,
onManageSettings: handleManageSettings,
onUpdateContainer: handleUpdateContainer,
onRemoveContainer: openRemoveContainerModal,
onVisitTailscale: handleVisitTailscale,
});
// Columns
const { columns } = useDockerTableColumns({
compact: compactRef,
busyRowIds,
updatingRowIds,
containerStats,
onUpdateContainer: handleUpdateContainer,
getRowActionItems,
});
// Bulk actions
const { bulkItems } = useDockerBulkActions({
selectedIds: selectedIdsRef,
allContainerRows,
updateCandidateRows,
checkingForUpdates,
updatingAllContainers,
getContainerRows,
onCheckForUpdates: (rows) => void handleCheckForUpdates(rows),
onUpdateAllContainers: (rows) => void handleUpdateAllContainers(rows),
onMoveToFolder: (ids) => folderOps.openMoveModal(ids),
onBulkUpdate: (rows) => void handleBulkUpdateContainers(rows),
onStartStop: (ids) => containerActions.openStartStop(ids),
onPauseResume: (ids) => containerActions.openPauseResume(ids),
});
// Column visibility
const defaultColumnVisibility = computed(() => getDefaultColumnVisibility(props.compact));
const resolvedColumnVisibility = computed<Record<string, boolean>>(() => ({
...defaultColumnVisibility.value,
...(columnVisibilityRef.value ?? {}),
}));
const { persistCurrentColumnVisibility } = usePersistentColumnVisibility({
tableRef: baseTableRef,
resolvedVisibility: resolvedColumnVisibility,
fallbackVisibility: defaultColumnVisibility,
onPersist: (visibility) => saveColumnVisibility({ ...visibility }),
isPersistenceEnabled: () => !props.compact,
});
watch(
() => props.viewPrefs,
(prefs) => {
if (prefs) {
mergeServerPreferences(prefs);
}
},
{ immediate: true }
);
// Search
const dockerFilterHelpText = `Filter by ${DOCKER_SEARCHABLE_KEYS.join(', ')}`;
const dockerSearchAccessor = (row: TreeRow<DockerContainer>): unknown[] => {
const meta = row.meta;
if (!meta) return [];
const names = Array.isArray(meta.names)
? meta.names.map((name) => (typeof name === 'string' ? stripLeadingSlash(name) : name))
: [];
const image = meta.image ? [meta.image] : [];
const status = meta.status ? [meta.status] : [];
const networkMode = meta.hostConfig?.networkMode ? [meta.hostConfig.networkMode] : [];
const labels =
meta.labels && typeof meta.labels === 'object'
? Object.entries(meta.labels).flatMap(([key, value]) => [key, String(value)])
: [];
return [...names, ...image, ...status, ...networkMode, ...labels];
};
// Logs on container start
function handleContainersWillStart(entries: { id: string; containerId: string; name: string }[]) {
if (!entries.length) return;
const targets = entries
.map((entry) => {
const rawRow = getRowById(entry.id, treeData.value);
const row = rawRow && rawRow.type === 'container' ? (rawRow as TreeRow<DockerContainer>) : null;
const label = getRowDisplayLabel(row, entry.name);
return { containerName: entry.name, label };
})
.filter((entry): entry is { containerName: string; label: string } => Boolean(entry.containerName));
if (!targets.length) return;
logs.openLogsForContainers(targets);
}
// Drag and drop
function getSiblingIds(parentId: string): string[] {
const folderChildren = folderChildrenIds.value[parentId];
if (folderChildren && folderChildren.length) {
return [...folderChildren];
}
if (parentId === rootFolderId.value && treeData.value.length) {
return treeData.value.map((row) => row.id);
}
const parentRow = getRowById(parentId, treeData.value);
if (parentRow?.children?.length) {
return parentRow.children.map((child) => child.id);
}
return [];
}
function computeInsertionIndex(
siblings: string[],
movingIds: string[],
targetId: string,
area: DropArea
): number {
if (!siblings.length) return 0;
const filtered = siblings.filter((id) => !movingIds.includes(id));
let insertIndex = filtered.findIndex((id) => id === targetId);
if (insertIndex === -1) {
insertIndex = filtered.length;
} else if (area === 'after') {
insertIndex += 1;
}
return Math.max(0, Math.min(insertIndex, filtered.length));
}
async function moveIntoFolder(destinationFolderId: string, movingIds: string[]) {
await moveEntriesMutation(
{ destinationFolderId, sourceEntryIds: movingIds },
{ refetchQueries: [refetchQuery], awaitRefetchQueries: true }
);
}
async function createFolderFromDrop(containerEntryId: string, movingIds: string[]) {
const parentId = entryParentById.value[containerEntryId] || rootFolderId.value;
const targetPosition = positionById.value[containerEntryId] ?? 0;
const name = window.prompt('New folder name?')?.trim();
if (!name) return;
const toMove = [containerEntryId, ...movingIds.filter((id) => id !== containerEntryId)];
await createFolderWithItemsMutation(
{ name, parentId, sourceEntryIds: toMove, position: targetPosition },
{ refetchQueries: [refetchQuery], awaitRefetchQueries: true }
);
showToast('Folder created');
}
async function handleDropOnRow(event: DropEvent<DockerContainer>) {
if (!props.flatEntries) return;
const { target, area, sourceIds: movingIds } = event;
if (!movingIds.length) return;
if (movingIds.includes(target.id)) return;
if (target.type === 'folder' && area === 'inside') {
await moveIntoFolder(target.id, movingIds);
return;
}
if (target.type === 'container' && area === 'inside') {
await createFolderFromDrop(target.id, movingIds);
return;
}
const parentId = entryParentById.value[target.id] || rootFolderId.value;
const siblings = getSiblingIds(parentId);
const position = computeInsertionIndex(siblings, movingIds, target.id, area);
await moveItemsToPositionMutation(
{ sourceEntryIds: movingIds, destinationFolderId: parentId, position },
{ refetchQueries: [refetchQuery], awaitRefetchQueries: true }
);
}
// Event handlers
function handleRowClick(payload: { id: string; type: string; name: string; meta?: DockerContainer }) {
if (payload.type === 'folder') {
baseTableRef.value?.toggleExpanded?.(payload.id);
}
emit('row:click', {
id: payload.id,
type: payload.type as 'container' | 'folder',
name: payload.name,
containerId: payload.meta?.id,
});
}
async function handleRowContextMenu(payload: {
id: string;
type: string;
name: string;
meta?: DockerContainer;
event: MouseEvent;
}) {
payload.event.preventDefault();
payload.event.stopPropagation();
const row = getRowById(payload.id, treeData.value);
if (!row) return;
if (busyRowIds.value.has(row.id)) return;
const items = getRowActionItems(row as TreeRow<DockerContainer>);
if (!items.length) return;
await contextMenu.openContextMenu({
x: payload.event.clientX,
y: payload.event.clientY,
items,
rowId: row.id,
});
}
function handleRowSelect(payload: {
id: string;
type: string;
name: string;
meta?: DockerContainer;
selected: boolean;
}) {
emit('row:select', {
id: payload.id,
type: payload.type as 'container' | 'folder',
name: payload.name,
containerId: payload.meta?.id,
selected: payload.selected,
});
}
function handleUpdateSelectedIds(ids: string[]) {
emit('update:selectedIds', ids);
}
async function handleCreateFolderInMoveModal() {
await folderOps.handleCreateFolderInTree();
emit('created-folder');
}
// Confirm modal data
const confirmStartStopGroups = computed(() => [
{ label: 'Will stop', items: containerActions.confirmToStop.value || [] },
{ label: 'Will start', items: containerActions.confirmToStart.value || [] },
]);
const confirmPauseResumeGroups = computed(() => [
{ label: 'Will pause', items: containerActions.confirmToPause.value || [] },
{ label: 'Will resume', items: containerActions.confirmToResume.value || [] },
]);
const containerToRemoveName = computed(() =>
containerToRemove.value ? stripLeadingSlash(containerToRemove.value?.name) : ''
);
const rowActionDropdownUi = {
content: 'overflow-x-hidden z-50',
item: 'bg-transparent hover:bg-transparent focus:bg-transparent border-0 ring-0 outline-none shadow-none data-[state=checked]:bg-transparent',
};
</script>
<template>
<div class="w-full">
<BaseTreeTable
ref="baseTableRef"
:data="treeData"
:columns="columns"
:loading="loading"
:compact="compact"
:active-id="activeId"
:selected-ids="selectedIds"
:busy-row-ids="busyRowIds"
:enable-drag-drop="!!flatEntries"
enable-resizing
v-model:column-sizing="columnSizing"
v-model:column-order="columnOrder"
:searchable-keys="DOCKER_SEARCHABLE_KEYS"
:search-accessor="dockerSearchAccessor"
:can-expand="(row: TreeRow<DockerContainer>) => row.type === 'folder'"
:can-select="(row: TreeRow<DockerContainer>) => row.type === 'container'"
:can-drag="(row: TreeRow<DockerContainer>) => row.type === 'container' || row.type === 'folder'"
:can-drop-inside="
(row: TreeRow<DockerContainer>) => row.type === 'container' || row.type === 'folder'
"
@row:click="handleRowClick"
@row:contextmenu="handleRowContextMenu"
@row:select="handleRowSelect"
@row:drop="handleDropOnRow"
@update:selected-ids="handleUpdateSelectedIds"
>
<template
#toolbar="{
selectedCount: count,
globalFilter: filterText,
setGlobalFilter,
columnOrder: tableColumnOrder,
}"
>
<div :class="['mb-4 flex flex-wrap items-center gap-2', compact ? 'sm:px-0.5' : '']">
<UInput
:model-value="filterText"
:size="compact ? 'sm' : 'md'"
:class="['max-w-sm flex-1', compact ? 'min-w-[8ch]' : 'min-w-[12ch]']"
:placeholder="dockerFilterHelpText"
:title="dockerFilterHelpText"
@update:model-value="setGlobalFilter"
/>
<TableColumnMenu
v-if="!compact"
:table="baseTableRef"
:column-order="tableColumnOrder"
@change="persistCurrentColumnVisibility"
@update:column-order="(order) => (columnOrder = order)"
/>
<UDropdownMenu
:items="bulkItems"
size="md"
:ui="{
content: 'overflow-x-hidden z-40',
item: 'bg-transparent hover:bg-transparent focus:bg-transparent border-0 ring-0 outline-none shadow-none data-[state=checked]:bg-transparent',
}"
>
<UButton
color="neutral"
variant="outline"
:size="compact ? 'sm' : 'md'"
trailing-icon="i-lucide-chevron-down"
>
Actions{{ count > 0 ? ` (${count})` : '' }}
</UButton>
</UDropdownMenu>
</div>
<div
v-if="isUpdatingContainers && activeUpdateSummary"
class="border-primary/30 bg-primary/5 text-primary my-2 flex items-center gap-2 rounded border px-3 py-2 text-sm"
>
<span class="i-lucide-loader-2 text-primary animate-spin" />
<span>Updating {{ activeUpdateSummary }}...</span>
</div>
</template>
</BaseTreeTable>
<!-- Context Menu -->
<UDropdownMenu
v-model:open="contextMenu.isOpen.value"
:items="contextMenu.items.value"
size="md"
:popper="contextMenu.popperOptions"
:ui="rowActionDropdownUi"
>
<div
class="fixed h-px w-px"
:style="{ top: `${contextMenu.position.value.y}px`, left: `${contextMenu.position.value.x}px` }"
/>
</UDropdownMenu>
<!-- Logs Modal -->
<DockerLogViewerModal
v-model:open="logs.logsModalOpen.value"
v-model:active-session-id="logs.activeLogSessionId.value"
:sessions="logs.logSessions.value"
:active-session="logs.activeLogSession.value"
@remove-session="logs.removeLogSession"
@toggle-follow="logs.toggleActiveLogFollow"
/>
<!-- Move to Folder Modal -->
<MoveToFolderModal
:open="folderOps.moveOpen"
:loading="moving || creating || deleting"
:folders="visibleFolders"
:expanded-folders="expandedFolders"
:selected-folder-id="folderOps.selectedFolderId"
:root-folder-id="rootFolderId"
:renaming-folder-id="folderOps.renamingFolderId"
:rename-value="folderOps.renameValue"
@update:open="folderOps.moveOpen = $event"
@update:selected-folder-id="folderOps.selectedFolderId = $event"
@update:rename-value="folderOps.renameValue = $event"
@toggle-expand="toggleExpandFolder"
@create-folder="handleCreateFolderInMoveModal"
@delete-folder="folderOps.handleDeleteFolder"
@start-rename="folderOps.startRenameFolder"
@commit-rename="folderOps.commitRenameFolder"
@cancel-rename="folderOps.cancelRename"
@confirm="folderOps.confirmMove(() => (folderOps.moveOpen = false))"
/>
<!-- Start/Stop Confirm Modal -->
<ConfirmActionsModal
:open="containerActions.confirmStartStopOpen.value"
:groups="confirmStartStopGroups"
@update:open="containerActions.confirmStartStopOpen.value = $event"
@confirm="containerActions.confirmStartStop(() => {})"
/>
<!-- Pause/Resume Confirm Modal -->
<ConfirmActionsModal
:open="containerActions.confirmPauseResumeOpen.value"
:groups="confirmPauseResumeGroups"
@update:open="containerActions.confirmPauseResumeOpen.value = $event"
@confirm="containerActions.confirmPauseResume(() => {})"
/>
<!-- Remove Container Modal -->
<RemoveContainerModal
:open="removeContainerModalOpen"
:container-name="containerToRemoveName"
:loading="removingContainer"
@update:open="removeContainerModalOpen = $event"
@confirm="handleConfirmRemoveContainer"
/>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More