mirror of
https://github.com/unraid/api.git
synced 2025-12-21 08:39:38 -06:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
6
api/.gitignore
vendored
@@ -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
|
||||
@@ -5,3 +5,4 @@ src/unraid-api/unraid-file-modifier/modifications/__fixtures__/downloaded/*
|
||||
|
||||
# Generated Types
|
||||
src/graphql/generated/client/*.ts
|
||||
dist/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.27.2",
|
||||
"version": "4.28.2",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
555
api/docs/developer/docker.md
Normal file
555
api/docs/developer/docker.md
Normal 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
|
||||
```
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,7 @@ exports[`Returns paths 1`] = `
|
||||
"unraid-api-base",
|
||||
"unraid-data",
|
||||
"docker-autostart",
|
||||
"docker-userprefs",
|
||||
"docker-socket",
|
||||
"rclone-socket",
|
||||
"parity-checks",
|
||||
|
||||
@@ -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',
|
||||
|
||||
234
api/src/common/compare-semver-version.spec.ts
Normal file
234
api/src/common/compare-semver-version.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
44
api/src/common/compare-semver-version.ts
Normal file
44
api/src/common/compare-semver-version.ts
Normal 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);
|
||||
};
|
||||
60
api/src/common/get-unraid-version-sync.ts
Normal file
60
api/src/common/get-unraid-version-sync.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
19
api/src/core/utils/network.ts
Normal file
19
api/src/core/utils/network.ts
Normal 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 '';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
149
api/src/unraid-api/graph/resolvers/docker/docker-log.service.ts
Normal file
149
api/src/unraid-api/graph/resolvers/docker/docker-log.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
178
api/src/unraid-api/graph/resolvers/docker/docker-port.service.ts
Normal file
178
api/src/unraid-api/graph/resolvers/docker/docker-port.service.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*---------------------------------------------**/
|
||||
|
||||
@@ -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('|');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
27
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -3,3 +3,5 @@ components.d.ts
|
||||
composables/gql/
|
||||
src/composables/gql/
|
||||
dist/
|
||||
.output/
|
||||
.nuxt/
|
||||
|
||||
@@ -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('{"text": "Encoded"}');
|
||||
});
|
||||
|
||||
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
32
web/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -71,7 +71,7 @@ const vueRules = {
|
||||
'vue/no-unsupported-features': [
|
||||
'error',
|
||||
{
|
||||
version: '^3.3.0',
|
||||
version: '^3.5.0',
|
||||
},
|
||||
],
|
||||
'vue/no-unused-properties': [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
390
web/public/test-pages/all-components.html
Normal file
390
web/public/test-pages/all-components.html
Normal 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"><unraid-critical-notifications></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"><unraid-docker-container-overview></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"><unraid-auth></span>
|
||||
<div class="component-mount">
|
||||
<unraid-auth></unraid-auth>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>User Profile</h3>
|
||||
<span class="selector"><unraid-user-profile></span>
|
||||
<div class="component-mount">
|
||||
<unraid-user-profile></unraid-user-profile>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>SSO Button</h3>
|
||||
<span class="selector"><unraid-sso-button></span>
|
||||
<div class="component-mount">
|
||||
<unraid-sso-button></unraid-sso-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Registration</h3>
|
||||
<span class="selector"><unraid-registration></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"><unraid-connect-settings></span>
|
||||
<div class="component-mount">
|
||||
<unraid-connect-settings></unraid-connect-settings>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Theme Switcher</h3>
|
||||
<span class="selector"><unraid-theme-switcher></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"><unraid-header-os-version></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"><unraid-wan-ip-check></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"><unraid-update-os></span>
|
||||
<div class="component-mount">
|
||||
<unraid-update-os></unraid-update-os>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Downgrade OS</h3>
|
||||
<span class="selector"><unraid-downgrade-os></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"><unraid-api-key-manager></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"><unraid-api-key-authorize></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"><unraid-download-api-logs></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"><unraid-log-viewer></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"><unraid-modals></span>
|
||||
<div class="component-mount">
|
||||
<unraid-modals></unraid-modals>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="component-card">
|
||||
<h3>Welcome Modal</h3>
|
||||
<span class="selector"><unraid-welcome-modal></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"><unraid-dev-modal-test></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"><unraid-toaster></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>
|
||||
@@ -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;
|
||||
|
||||
617
web/src/components/Common/BaseTreeTable.vue
Normal file
617
web/src/components/Common/BaseTreeTable.vue
Normal 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>
|
||||
56
web/src/components/Common/ConfirmActionsModal.vue
Normal file
56
web/src/components/Common/ConfirmActionsModal.vue
Normal 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>
|
||||
165
web/src/components/Common/MoveToFolderModal.vue
Normal file
165
web/src/components/Common/MoveToFolderModal.vue
Normal 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>
|
||||
260
web/src/components/Common/MultiValueCopyBadges.vue
Normal file
260
web/src/components/Common/MultiValueCopyBadges.vue
Normal 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>
|
||||
153
web/src/components/Common/ResizableSlideover.vue
Normal file
153
web/src/components/Common/ResizableSlideover.vue
Normal 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>
|
||||
129
web/src/components/Common/TableColumnMenu.vue
Normal file
129
web/src/components/Common/TableColumnMenu.vue
Normal 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>
|
||||
@@ -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>
|
||||
587
web/src/components/Docker/ContainerOverviewCard.vue
Normal file
587
web/src/components/Docker/ContainerOverviewCard.vue
Normal 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>
|
||||
175
web/src/components/Docker/ContainerSizesModal.vue
Normal file
175
web/src/components/Docker/ContainerSizesModal.vue
Normal 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>
|
||||
409
web/src/components/Docker/DockerAutostartSettings.vue
Normal file
409
web/src/components/Docker/DockerAutostartSettings.vue
Normal 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>
|
||||
189
web/src/components/Docker/DockerConsoleViewer.vue
Normal file
189
web/src/components/Docker/DockerConsoleViewer.vue
Normal 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>
|
||||
695
web/src/components/Docker/DockerContainerManagement.vue
Normal file
695
web/src/components/Docker/DockerContainerManagement.vue
Normal 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 & 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>
|
||||
@@ -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>
|
||||
66
web/src/components/Docker/DockerContainerOverview.vue
Normal file
66
web/src/components/Docker/DockerContainerOverview.vue
Normal 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>
|
||||
28
web/src/components/Docker/DockerContainerStatCell.vue
Normal file
28
web/src/components/Docker/DockerContainerStatCell.vue
Normal 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>
|
||||
778
web/src/components/Docker/DockerContainersTable.vue
Normal file
778
web/src/components/Docker/DockerContainersTable.vue
Normal 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
Reference in New Issue
Block a user