mirror of
https://github.com/unraid/api.git
synced 2026-01-02 14:40:01 -06:00
Compare commits
9 Commits
v4.22.2
...
4.22.2-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d9ce0aa3d | ||
|
|
9714b21c5c | ||
|
|
44b4d77d80 | ||
|
|
3f5039c342 | ||
|
|
1d2c6701ce | ||
|
|
0ee09aefbb | ||
|
|
c60a51dc1b | ||
|
|
c4fbf698b4 | ||
|
|
00faa8f9d9 |
34
.github/workflows/release-production.yml
vendored
34
.github/workflows/release-production.yml
vendored
@@ -28,14 +28,9 @@ jobs:
|
||||
with:
|
||||
latest: true
|
||||
prerelease: false
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: 'pnpm'
|
||||
node-version: 22.19.0
|
||||
- run: |
|
||||
cat << 'EOF' > release-notes.txt
|
||||
${{ steps.release-info.outputs.body }}
|
||||
@@ -130,15 +125,22 @@ jobs:
|
||||
--content-encoding none \
|
||||
--acl public-read
|
||||
|
||||
- name: Actions for Discord
|
||||
uses: Ilshidur/action-discord@0.4.0
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.PUBLIC_DISCORD_RELEASE_ENDPOINT }}
|
||||
- name: Discord Webhook Notification
|
||||
uses: tsickert/discord-webhook@v7.0.0
|
||||
with:
|
||||
args: |
|
||||
🚀 **Unraid API Release ${{ inputs.version }}**
|
||||
|
||||
View Release: https://github.com/${{ github.repository }}/releases/tag/${{ inputs.version }}
|
||||
|
||||
**Changelog:**
|
||||
webhook-url: ${{ secrets.PUBLIC_DISCORD_RELEASE_ENDPOINT }}
|
||||
username: "Unraid API Bot"
|
||||
avatar-url: "https://craftassets.unraid.net/uploads/logos/un-mark-gradient.png"
|
||||
embed-title: "🚀 Unraid API ${{ inputs.version }} Released!"
|
||||
embed-url: "https://github.com/${{ github.repository }}/releases/tag/${{ inputs.version }}"
|
||||
embed-description: |
|
||||
A new version of Unraid API has been released!
|
||||
|
||||
**Version:** `${{ inputs.version }}`
|
||||
**Release Page:** [View on GitHub](https://github.com/${{ github.repository }}/releases/tag/${{ inputs.version }})
|
||||
|
||||
**📋 Changelog:**
|
||||
${{ steps.release-info.outputs.body }}
|
||||
embed-color: 16734296
|
||||
embed-footer-text: "Unraid API • Automated Release"
|
||||
embed-timestamp: true
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
--header-background-color: #1c1b1b;
|
||||
--header-gradient-start: rgba(28, 27, 27, 0);
|
||||
--header-gradient-end: rgba(28, 27, 27, 0.7);
|
||||
--ui-border-muted: hsl(240 5% 20%);
|
||||
--color-border: #383735;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #1c1b1b;
|
||||
@@ -28,7 +27,6 @@
|
||||
--header-background-color: #f2f2f2;
|
||||
--header-gradient-start: rgba(242, 242, 242, 0);
|
||||
--header-gradient-end: rgba(242, 242, 242, 0.7);
|
||||
--ui-border-muted: hsl(240 5.9% 90%);
|
||||
--color-border: #e0e0e0;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #f2f2f2;
|
||||
@@ -43,7 +41,6 @@
|
||||
--header-background-color: #1c1b1b;
|
||||
--header-gradient-start: rgba(28, 27, 27, 0);
|
||||
--header-gradient-end: rgba(28, 27, 27, 0.7);
|
||||
--ui-border-muted: hsl(240 5% 25%);
|
||||
--color-border: #383735;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #383735;
|
||||
@@ -58,7 +55,6 @@
|
||||
--header-background-color: #f2f2f2;
|
||||
--header-gradient-start: rgba(242, 242, 242, 0);
|
||||
--header-gradient-end: rgba(242, 242, 242, 0.7);
|
||||
--ui-border-muted: hsl(210 40% 80%);
|
||||
--color-border: #5a8bb8;
|
||||
--color-alpha: #ff8c2f;
|
||||
--color-beta: #e7f2f8;
|
||||
@@ -68,7 +64,6 @@
|
||||
|
||||
/* Dark Mode Overrides */
|
||||
.dark {
|
||||
--ui-border-muted: hsl(240 5% 20%);
|
||||
--color-border: #383735;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const config: CodegenConfig = {
|
||||
URL: 'URL',
|
||||
Port: 'number',
|
||||
UUID: 'string',
|
||||
BigInt: 'number',
|
||||
},
|
||||
scalarSchemas: {
|
||||
URL: 'z.instanceof(URL)',
|
||||
@@ -24,6 +25,7 @@ const config: CodegenConfig = {
|
||||
JSON: 'z.record(z.string(), z.any())',
|
||||
Port: 'z.number()',
|
||||
UUID: 'z.string()',
|
||||
BigInt: 'z.number()',
|
||||
},
|
||||
},
|
||||
generates: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "4.22.1",
|
||||
"version": "4.22.2",
|
||||
"extraOrigins": [],
|
||||
"sandbox": true,
|
||||
"ssoSubIds": [],
|
||||
|
||||
@@ -1093,8 +1093,8 @@ type DockerContainer implements Node {
|
||||
created: Int!
|
||||
ports: [ContainerPort!]!
|
||||
|
||||
"""Total size of all the files in the container"""
|
||||
sizeRootFs: Int
|
||||
"""Total size of all files in the container (in bytes)"""
|
||||
sizeRootFs: BigInt
|
||||
labels: JSON
|
||||
state: ContainerState!
|
||||
status: String!
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pino from 'pino';
|
||||
import pretty from 'pino-pretty';
|
||||
|
||||
import { API_VERSION, LOG_LEVEL, LOG_TYPE, PATHS_LOGS_FILE, SUPPRESS_LOGS } from '@app/environment.js';
|
||||
import { API_VERSION, LOG_LEVEL, LOG_TYPE, SUPPRESS_LOGS } from '@app/environment.js';
|
||||
|
||||
export const levels = ['trace', 'debug', 'info', 'warn', 'error', 'fatal'] as const;
|
||||
|
||||
@@ -17,30 +17,27 @@ const nullDestination = pino.destination({
|
||||
|
||||
export const logDestination =
|
||||
process.env.SUPPRESS_LOGS === 'true' ? nullDestination : pino.destination();
|
||||
const localFileDestination = pino.destination({
|
||||
dest: PATHS_LOGS_FILE,
|
||||
sync: true,
|
||||
});
|
||||
|
||||
// Since PM2 captures stdout and writes to the log file, we should not colorize stdout
|
||||
// to avoid ANSI escape codes in the log file
|
||||
const stream = SUPPRESS_LOGS
|
||||
? nullDestination
|
||||
: LOG_TYPE === 'pretty'
|
||||
? pretty({
|
||||
singleLine: true,
|
||||
hideObject: false,
|
||||
colorize: true,
|
||||
colorizeObjects: true,
|
||||
colorize: false, // No colors since PM2 writes stdout to file
|
||||
colorizeObjects: false,
|
||||
levelFirst: false,
|
||||
ignore: 'hostname,pid',
|
||||
destination: logDestination,
|
||||
translateTime: 'HH:mm:ss',
|
||||
customPrettifiers: {
|
||||
time: (timestamp: string | object) => `[${timestamp}`,
|
||||
level: (logLevel: string | object, key: string, log: any, extras: any) => {
|
||||
// Use labelColorized which preserves the colors
|
||||
const { labelColorized } = extras;
|
||||
level: (_logLevel: string | object, _key: string, log: any, extras: any) => {
|
||||
// Use label instead of labelColorized for non-colored output
|
||||
const { label } = extras;
|
||||
const context = log.context || log.logger || 'app';
|
||||
return `${labelColorized} ${context}]`;
|
||||
return `${label} ${context}]`;
|
||||
},
|
||||
},
|
||||
messageFormat: (log: any, messageKey: string) => {
|
||||
@@ -98,7 +95,7 @@ export const keyServerLogger = logger.child({ logger: 'key-server' });
|
||||
export const remoteAccessLogger = logger.child({ logger: 'remote-access' });
|
||||
export const remoteQueryLogger = logger.child({ logger: 'remote-query' });
|
||||
export const apiLogger = logger.child({ logger: 'api' });
|
||||
export const pluginLogger = logger.child({ logger: 'plugin', stream: localFileDestination });
|
||||
export const pluginLogger = logger.child({ logger: 'plugin' });
|
||||
|
||||
export const loggers = [
|
||||
internalLogger,
|
||||
|
||||
@@ -15,7 +15,7 @@ export type Scalars = {
|
||||
Int: { input: number; output: number; }
|
||||
Float: { input: number; output: number; }
|
||||
/** The `BigInt` scalar type represents non-fractional signed whole numeric values. */
|
||||
BigInt: { input: any; output: any; }
|
||||
BigInt: { input: number; output: number; }
|
||||
/** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */
|
||||
DateTime: { input: string; output: string; }
|
||||
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
|
||||
@@ -711,8 +711,8 @@ export type DockerContainer = Node & {
|
||||
names: Array<Scalars['String']['output']>;
|
||||
networkSettings?: Maybe<Scalars['JSON']['output']>;
|
||||
ports: Array<ContainerPort>;
|
||||
/** Total size of all the files in the container */
|
||||
sizeRootFs?: Maybe<Scalars['Int']['output']>;
|
||||
/** Total size of all files in the container (in bytes) */
|
||||
sizeRootFs?: Maybe<Scalars['BigInt']['output']>;
|
||||
state: ContainerState;
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
|
||||
@@ -35,7 +35,8 @@ export class RestartCommand extends CommandRunner {
|
||||
{ tag: 'PM2 Restart', raw: true, extendEnv: true, env },
|
||||
'restart',
|
||||
ECOSYSTEM_PATH,
|
||||
'--update-env'
|
||||
'--update-env',
|
||||
'--mini-list'
|
||||
);
|
||||
|
||||
if (stderr) {
|
||||
|
||||
@@ -33,7 +33,8 @@ export class StartCommand extends CommandRunner {
|
||||
{ tag: 'PM2 Start', raw: true, extendEnv: true, env },
|
||||
'start',
|
||||
ECOSYSTEM_PATH,
|
||||
'--update-env'
|
||||
'--update-env',
|
||||
'--mini-list'
|
||||
);
|
||||
if (stdout) {
|
||||
this.logger.log(stdout.toString());
|
||||
|
||||
@@ -8,6 +8,11 @@ export class StatusCommand extends CommandRunner {
|
||||
super();
|
||||
}
|
||||
async run(): Promise<void> {
|
||||
await this.pm2.run({ tag: 'PM2 Status', stdio: 'inherit', raw: true }, 'status', 'unraid-api');
|
||||
await this.pm2.run(
|
||||
{ tag: 'PM2 Status', stdio: 'inherit', raw: true },
|
||||
'status',
|
||||
'unraid-api',
|
||||
'--mini-list'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,8 @@ export class StopCommand extends CommandRunner {
|
||||
{ tag: 'PM2 Delete', stdio: 'inherit' },
|
||||
'delete',
|
||||
ECOSYSTEM_PATH,
|
||||
'--no-autorestart'
|
||||
'--no-autorestart',
|
||||
'--mini-list'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Field, ID, Int, ObjectType, registerEnumType } from '@nestjs/graphql';
|
||||
|
||||
import { Node } from '@unraid/shared/graphql.model.js';
|
||||
import { GraphQLJSON, GraphQLPort } from 'graphql-scalars';
|
||||
import { GraphQLBigInt, GraphQLJSON, GraphQLPort } from 'graphql-scalars';
|
||||
|
||||
export enum ContainerPortType {
|
||||
TCP = 'TCP',
|
||||
@@ -89,7 +89,10 @@ export class DockerContainer extends Node {
|
||||
@Field(() => [ContainerPort])
|
||||
ports!: ContainerPort[];
|
||||
|
||||
@Field(() => Int, { nullable: true, description: 'Total size of all the files in the container' })
|
||||
@Field(() => GraphQLBigInt, {
|
||||
nullable: true,
|
||||
description: 'Total size of all files in the container (in bytes)',
|
||||
})
|
||||
sizeRootFs?: number;
|
||||
|
||||
@Field(() => GraphQLJSON, { nullable: true })
|
||||
|
||||
@@ -8,6 +8,13 @@ import { ContainerState, DockerContainer } from '@app/unraid-api/graph/resolvers
|
||||
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 { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.js';
|
||||
|
||||
vi.mock('@app/unraid-api/utils/graphql-field-helper.js', () => ({
|
||||
GraphQLFieldHelper: {
|
||||
isFieldRequested: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('DockerResolver', () => {
|
||||
let resolver: DockerResolver;
|
||||
@@ -41,6 +48,9 @@ describe('DockerResolver', () => {
|
||||
|
||||
resolver = module.get<DockerResolver>(DockerResolver);
|
||||
dockerService = module.get<DockerService>(DockerService);
|
||||
|
||||
// Reset mocks before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -80,9 +90,75 @@ describe('DockerResolver', () => {
|
||||
},
|
||||
];
|
||||
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
|
||||
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(false);
|
||||
|
||||
const result = await resolver.containers(false);
|
||||
const mockInfo = {} as any;
|
||||
|
||||
const result = await resolver.containers(false, mockInfo);
|
||||
expect(result).toEqual(mockContainers);
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false });
|
||||
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs');
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: false });
|
||||
});
|
||||
|
||||
it('should request size when sizeRootFs 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: [],
|
||||
sizeRootFs: 1024000,
|
||||
state: ContainerState.EXITED,
|
||||
status: 'Exited',
|
||||
},
|
||||
];
|
||||
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
|
||||
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(true);
|
||||
|
||||
const mockInfo = {} as any;
|
||||
|
||||
const result = await resolver.containers(false, mockInfo);
|
||||
expect(result).toEqual(mockContainers);
|
||||
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs');
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: true });
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const mockInfo = {} as any;
|
||||
|
||||
await resolver.containers(false, mockInfo);
|
||||
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs');
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: true });
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
const mockInfo = {} as any;
|
||||
|
||||
await resolver.containers(false, mockInfo);
|
||||
expect(GraphQLFieldHelper.isFieldRequested).toHaveBeenCalledWith(mockInfo, 'sizeRootFs');
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: false, size: false });
|
||||
});
|
||||
|
||||
it('should handle skipCache parameter', async () => {
|
||||
const mockContainers: DockerContainer[] = [];
|
||||
vi.mocked(dockerService.getContainers).mockResolvedValue(mockContainers);
|
||||
vi.mocked(GraphQLFieldHelper.isFieldRequested).mockReturnValue(false);
|
||||
|
||||
const mockInfo = {} as any;
|
||||
|
||||
await resolver.containers(true, mockInfo);
|
||||
expect(dockerService.getContainers).toHaveBeenCalledWith({ skipCache: true, size: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Args, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
import { Args, Info, Mutation, Query, ResolveField, Resolver } from '@nestjs/graphql';
|
||||
|
||||
import type { GraphQLResolveInfo } from 'graphql';
|
||||
import { AuthAction, Resource } from '@unraid/shared/graphql.model.js';
|
||||
import { UsePermissions } from '@unraid/shared/use-permissions.directive.js';
|
||||
|
||||
@@ -15,6 +16,7 @@ import { DockerService } from '@app/unraid-api/graph/resolvers/docker/docker.ser
|
||||
import { DockerOrganizerService } from '@app/unraid-api/graph/resolvers/docker/organizer/docker-organizer.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';
|
||||
|
||||
@Resolver(() => Docker)
|
||||
export class DockerResolver {
|
||||
@@ -41,9 +43,11 @@ export class DockerResolver {
|
||||
})
|
||||
@ResolveField(() => [DockerContainer])
|
||||
public async containers(
|
||||
@Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean
|
||||
@Args('skipCache', { defaultValue: false, type: () => Boolean }) skipCache: boolean,
|
||||
@Info() info: GraphQLResolveInfo
|
||||
) {
|
||||
return this.dockerService.getContainers({ skipCache });
|
||||
const requestsSize = GraphQLFieldHelper.isFieldRequested(info, 'sizeRootFs');
|
||||
return this.dockerService.getContainers({ skipCache, size: requestsSize });
|
||||
}
|
||||
|
||||
@UsePermissions({
|
||||
|
||||
@@ -109,6 +109,65 @@ 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 = [
|
||||
{
|
||||
@@ -159,7 +218,7 @@ describe('DockerService', () => {
|
||||
|
||||
expect(mockListContainers).toHaveBeenCalledWith({
|
||||
all: true,
|
||||
size: true,
|
||||
size: false,
|
||||
});
|
||||
expect(mockCacheManager.set).toHaveBeenCalled(); // Ensure cache is set
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ export class DockerService {
|
||||
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
|
||||
|
||||
@@ -71,6 +72,8 @@ export class DockerService {
|
||||
}
|
||||
|
||||
public transformContainer(container: Docker.ContainerInfo): DockerContainer {
|
||||
const sizeValue = (container as Docker.ContainerInfo & { SizeRootFs?: number }).SizeRootFs;
|
||||
|
||||
const transformed: DockerContainer = {
|
||||
id: container.Id,
|
||||
names: container.Names,
|
||||
@@ -86,7 +89,7 @@ export class DockerService {
|
||||
ContainerPortType[port.Type.toUpperCase() as keyof typeof ContainerPortType] ||
|
||||
ContainerPortType.TCP,
|
||||
})),
|
||||
sizeRootFs: undefined,
|
||||
sizeRootFs: sizeValue,
|
||||
labels: container.Labels ?? {},
|
||||
state:
|
||||
typeof container.State === 'string'
|
||||
@@ -109,21 +112,23 @@ export class DockerService {
|
||||
{
|
||||
skipCache = false,
|
||||
all = true,
|
||||
size = true,
|
||||
size = false,
|
||||
...listOptions
|
||||
}: Partial<ContainerListingOptions> = { skipCache: false }
|
||||
): Promise<DockerContainer[]> {
|
||||
const cacheKey = size
|
||||
? DockerService.CONTAINER_WITH_SIZE_CACHE_KEY
|
||||
: DockerService.CONTAINER_CACHE_KEY;
|
||||
|
||||
if (!skipCache) {
|
||||
const cachedContainers = await this.cacheManager.get<DockerContainer[]>(
|
||||
DockerService.CONTAINER_CACHE_KEY
|
||||
);
|
||||
const cachedContainers = await this.cacheManager.get<DockerContainer[]>(cacheKey);
|
||||
if (cachedContainers) {
|
||||
this.logger.debug('Using docker container cache');
|
||||
this.logger.debug(`Using docker container cache (${size ? 'with' : 'without'} size)`);
|
||||
return cachedContainers;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.debug('Updating docker container cache');
|
||||
this.logger.debug(`Updating docker container cache (${size ? 'with' : 'without'} size)`);
|
||||
const rawContainers =
|
||||
(await this.client
|
||||
.listContainers({
|
||||
@@ -136,11 +141,7 @@ export class DockerService {
|
||||
this.autoStarts = await this.getAutoStarts();
|
||||
const containers = rawContainers.map((container) => this.transformContainer(container));
|
||||
|
||||
await this.cacheManager.set(
|
||||
DockerService.CONTAINER_CACHE_KEY,
|
||||
containers,
|
||||
DockerService.CACHE_TTL_SECONDS * 1000
|
||||
);
|
||||
await this.cacheManager.set(cacheKey, containers, DockerService.CACHE_TTL_SECONDS * 1000);
|
||||
return containers;
|
||||
}
|
||||
|
||||
@@ -191,15 +192,18 @@ export class DockerService {
|
||||
}
|
||||
|
||||
public async clearContainerCache(): Promise<void> {
|
||||
await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY);
|
||||
this.logger.debug('Invalidated container cache due to external event.');
|
||||
await Promise.all([
|
||||
this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY),
|
||||
this.cacheManager.del(DockerService.CONTAINER_WITH_SIZE_CACHE_KEY),
|
||||
]);
|
||||
this.logger.debug('Invalidated container caches due to external event.');
|
||||
}
|
||||
|
||||
public async start(id: string): Promise<DockerContainer> {
|
||||
const container = this.client.getContainer(id);
|
||||
await container.start();
|
||||
await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY);
|
||||
this.logger.debug(`Invalidated container cache after starting ${id}`);
|
||||
await this.clearContainerCache();
|
||||
this.logger.debug(`Invalidated container caches after starting ${id}`);
|
||||
const containers = await this.getContainers({ skipCache: true });
|
||||
const updatedContainer = containers.find((c) => c.id === id);
|
||||
if (!updatedContainer) {
|
||||
@@ -213,8 +217,8 @@ export class DockerService {
|
||||
public async stop(id: string): Promise<DockerContainer> {
|
||||
const container = this.client.getContainer(id);
|
||||
await container.stop({ t: 10 });
|
||||
await this.cacheManager.del(DockerService.CONTAINER_CACHE_KEY);
|
||||
this.logger.debug(`Invalidated container cache after stopping ${id}`);
|
||||
await this.clearContainerCache();
|
||||
this.logger.debug(`Invalidated container caches after stopping ${id}`);
|
||||
|
||||
let containers = await this.getContainers({ skipCache: true });
|
||||
let updatedContainer: DockerContainer | undefined;
|
||||
|
||||
332
api/src/unraid-api/utils/graphql-field-helper.spec.ts
Normal file
332
api/src/unraid-api/utils/graphql-field-helper.spec.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
import { buildSchema, FieldNode, GraphQLResolveInfo, parse } from 'graphql';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { GraphQLFieldHelper } from '@app/unraid-api/utils/graphql-field-helper.js';
|
||||
|
||||
describe('GraphQLFieldHelper', () => {
|
||||
const schema = buildSchema(`
|
||||
type User {
|
||||
id: String
|
||||
name: String
|
||||
email: String
|
||||
profile: Profile
|
||||
posts: [Post]
|
||||
settings: Settings
|
||||
}
|
||||
|
||||
type Profile {
|
||||
avatar: String
|
||||
bio: String
|
||||
}
|
||||
|
||||
type Post {
|
||||
title: String
|
||||
content: String
|
||||
}
|
||||
|
||||
type Settings {
|
||||
theme: String
|
||||
language: String
|
||||
}
|
||||
|
||||
type Query {
|
||||
user: User
|
||||
users: [User]
|
||||
}
|
||||
`);
|
||||
|
||||
const createMockInfo = (query: string): GraphQLResolveInfo => {
|
||||
const document = parse(query);
|
||||
const operation = document.definitions[0] as any;
|
||||
const fieldNode = operation.selectionSet.selections[0] as FieldNode;
|
||||
|
||||
return {
|
||||
fieldName: fieldNode.name.value,
|
||||
fieldNodes: [fieldNode],
|
||||
returnType: schema.getType('User') as any,
|
||||
parentType: schema.getType('Query') as any,
|
||||
path: { prev: undefined, key: fieldNode.name.value, typename: 'Query' },
|
||||
schema,
|
||||
fragments: {},
|
||||
rootValue: {},
|
||||
operation,
|
||||
variableValues: {},
|
||||
} as GraphQLResolveInfo;
|
||||
};
|
||||
|
||||
describe('getRequestedFields', () => {
|
||||
it('should return flat fields structure', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const fields = GraphQLFieldHelper.getRequestedFields(mockInfo);
|
||||
|
||||
expect(fields).toEqual({
|
||||
id: {},
|
||||
name: {},
|
||||
email: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return nested fields structure', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
profile {
|
||||
avatar
|
||||
bio
|
||||
}
|
||||
settings {
|
||||
theme
|
||||
language
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const fields = GraphQLFieldHelper.getRequestedFields(mockInfo);
|
||||
|
||||
expect(fields).toEqual({
|
||||
id: {},
|
||||
profile: {
|
||||
avatar: {},
|
||||
bio: {},
|
||||
},
|
||||
settings: {
|
||||
theme: {},
|
||||
language: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFieldRequested', () => {
|
||||
it('should return true for requested top-level field', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
email
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'id')).toBe(true);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'name')).toBe(true);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'email')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-requested field', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'email')).toBe(false);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'profile')).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle nested field paths', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
profile {
|
||||
avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'profile')).toBe(true);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'profile.avatar')).toBe(true);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'profile.bio')).toBe(false);
|
||||
expect(GraphQLFieldHelper.isFieldRequested(mockInfo, 'settings')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequestedFieldsList', () => {
|
||||
it('should return list of top-level field names', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
email
|
||||
profile {
|
||||
avatar
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const fieldsList = GraphQLFieldHelper.getRequestedFieldsList(mockInfo);
|
||||
|
||||
expect(fieldsList).toEqual(['id', 'name', 'email', 'profile']);
|
||||
});
|
||||
|
||||
it('should return empty array for no fields', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user
|
||||
}
|
||||
`);
|
||||
|
||||
const fieldsList = GraphQLFieldHelper.getRequestedFieldsList(mockInfo);
|
||||
|
||||
expect(fieldsList).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasNestedFields', () => {
|
||||
it('should return true when field has nested selections', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
profile {
|
||||
avatar
|
||||
bio
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.hasNestedFields(mockInfo, 'profile')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when field has no nested selections', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.hasNestedFields(mockInfo, 'id')).toBe(false);
|
||||
expect(GraphQLFieldHelper.hasNestedFields(mockInfo, 'name')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for non-existent field', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.hasNestedFields(mockInfo, 'profile')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNestedFields', () => {
|
||||
it('should return nested fields object', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
profile {
|
||||
avatar
|
||||
bio
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const nestedFields = GraphQLFieldHelper.getNestedFields(mockInfo, 'profile');
|
||||
|
||||
expect(nestedFields).toEqual({
|
||||
avatar: {},
|
||||
bio: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null for field without nested selections', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.getNestedFields(mockInfo, 'id')).toBeNull();
|
||||
expect(GraphQLFieldHelper.getNestedFields(mockInfo, 'name')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-existent field', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.getNestedFields(mockInfo, 'profile')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldFetchRelation', () => {
|
||||
it('should return true when relation is requested with nested fields', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
profile {
|
||||
avatar
|
||||
}
|
||||
posts {
|
||||
title
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'profile')).toBe(true);
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'posts')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when relation has no nested fields', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'id')).toBe(false);
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'name')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when relation is not requested', () => {
|
||||
const mockInfo = createMockInfo(`
|
||||
query {
|
||||
user {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'profile')).toBe(false);
|
||||
expect(GraphQLFieldHelper.shouldFetchRelation(mockInfo, 'posts')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
63
api/src/unraid-api/utils/graphql-field-helper.ts
Normal file
63
api/src/unraid-api/utils/graphql-field-helper.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { GraphQLResolveInfo } from 'graphql';
|
||||
import graphqlFields from 'graphql-fields';
|
||||
|
||||
export interface RequestedFields {
|
||||
[key: string]: RequestedFields | {};
|
||||
}
|
||||
|
||||
export interface GraphQLFieldOptions {
|
||||
processArguments?: boolean;
|
||||
excludedFields?: string[];
|
||||
}
|
||||
|
||||
export class GraphQLFieldHelper {
|
||||
static getRequestedFields(info: GraphQLResolveInfo, options?: GraphQLFieldOptions): RequestedFields {
|
||||
return graphqlFields(info, {}, options);
|
||||
}
|
||||
|
||||
static isFieldRequested(info: GraphQLResolveInfo, fieldPath: string): boolean {
|
||||
const fields = this.getRequestedFields(info);
|
||||
const pathParts = fieldPath.split('.');
|
||||
|
||||
let current: RequestedFields | {} = fields;
|
||||
for (const part of pathParts) {
|
||||
if (!(part in current)) {
|
||||
return false;
|
||||
}
|
||||
current = current[part as keyof typeof current] as RequestedFields | {};
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static getRequestedFieldsList(info: GraphQLResolveInfo): string[] {
|
||||
const fields = this.getRequestedFields(info);
|
||||
return Object.keys(fields);
|
||||
}
|
||||
|
||||
static hasNestedFields(info: GraphQLResolveInfo, fieldName: string): boolean {
|
||||
const fields = this.getRequestedFields(info);
|
||||
const field = fields[fieldName];
|
||||
return field !== undefined && Object.keys(field).length > 0;
|
||||
}
|
||||
|
||||
static getNestedFields(info: GraphQLResolveInfo, fieldName: string): RequestedFields | null {
|
||||
const fields = this.getRequestedFields(info);
|
||||
const field = fields[fieldName];
|
||||
|
||||
if (!field || typeof field !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// graphql-fields returns {} for fields without nested selections
|
||||
if (Object.keys(field).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return field as RequestedFields;
|
||||
}
|
||||
|
||||
static shouldFetchRelation(info: GraphQLResolveInfo, relationName: string): boolean {
|
||||
return this.isFieldRequested(info, relationName) && this.hasNestedFields(info, relationName);
|
||||
}
|
||||
}
|
||||
@@ -71,9 +71,6 @@
|
||||
],
|
||||
"unraid-ui/**/*.{js,ts,tsx,vue}": [
|
||||
"pnpm --filter @unraid/ui lint:fix"
|
||||
],
|
||||
"packages/**/*.{js,ts}": [
|
||||
"eslint --fix"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.15.0"
|
||||
|
||||
@@ -17,6 +17,7 @@ const config: CodegenConfig = {
|
||||
URL: 'URL',
|
||||
Port: 'number',
|
||||
UUID: 'string',
|
||||
BigInt: 'number',
|
||||
},
|
||||
scalarSchemas: {
|
||||
URL: 'z.instanceof(URL)',
|
||||
@@ -24,6 +25,7 @@ const config: CodegenConfig = {
|
||||
JSON: 'z.record(z.string(), z.any())',
|
||||
Port: 'z.number()',
|
||||
UUID: 'z.string()',
|
||||
BigInt: 'z.number()',
|
||||
},
|
||||
},
|
||||
generates: {
|
||||
|
||||
@@ -731,10 +731,17 @@ export type RemoteGraphQlEventFragmentFragment = { __typename?: 'RemoteGraphQLEv
|
||||
export type EventsSubscriptionVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type EventsSubscription = { __typename?: 'Subscription', events?: Array<{ __typename: 'ClientConnectedEvent', connectedEvent: EventType, connectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } } | { __typename: 'ClientDisconnectedEvent', disconnectedEvent: EventType, disconnectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } } | { __typename: 'ClientPingEvent' } | { __typename: 'RemoteAccessEvent' } | (
|
||||
{ __typename: 'RemoteGraphQLEvent' }
|
||||
& { ' $fragmentRefs'?: { 'RemoteGraphQlEventFragmentFragment': RemoteGraphQlEventFragmentFragment } }
|
||||
) | { __typename: 'UpdateEvent' }> | null };
|
||||
export type EventsSubscription = { __typename?: 'Subscription', events?: Array<
|
||||
| { __typename: 'ClientConnectedEvent', connectedEvent: EventType, connectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } }
|
||||
| { __typename: 'ClientDisconnectedEvent', disconnectedEvent: EventType, disconnectedData: { __typename?: 'ClientConnectionEventData', type: ClientType, version: string, apiKey: string } }
|
||||
| { __typename: 'ClientPingEvent' }
|
||||
| { __typename: 'RemoteAccessEvent' }
|
||||
| (
|
||||
{ __typename: 'RemoteGraphQLEvent' }
|
||||
& { ' $fragmentRefs'?: { 'RemoteGraphQlEventFragmentFragment': RemoteGraphQlEventFragmentFragment } }
|
||||
)
|
||||
| { __typename: 'UpdateEvent' }
|
||||
> | null };
|
||||
|
||||
export type SendRemoteGraphQlResponseMutationVariables = Exact<{
|
||||
input: RemoteGraphQlServerInput;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
Menu="ManagementAccess:99"
|
||||
Title="Unraid API Status"
|
||||
Icon="icon-u-globe"
|
||||
Tag="globe"
|
||||
---
|
||||
<!-- API Status Manager -->
|
||||
<unraid-api-status-manager></unraid-api-status-manager>
|
||||
|
||||
<!-- end unraid-api section -->
|
||||
@@ -1,5 +1,5 @@
|
||||
Menu="ManagementAccess:100"
|
||||
Title="Unraid API"
|
||||
Title="Unraid API Settings"
|
||||
Icon="icon-u-globe"
|
||||
Tag="globe"
|
||||
---
|
||||
@@ -596,8 +596,10 @@ $(function() {
|
||||
_(Unraid API extra origins)_:
|
||||
_(Connect Remote Access)_:
|
||||
_(GraphQL API Developer Sandbox)_:
|
||||
_(OIDC Configuration)_:
|
||||
|
||||
</div>
|
||||
|
||||
<!-- start unraid-api section -->
|
||||
<unraid-connect-settings></unraid-connect-settings>
|
||||
<!-- end unraid-api section -->
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ $validCommands = [
|
||||
'start',
|
||||
'restart',
|
||||
'stop',
|
||||
'status',
|
||||
'report',
|
||||
'wanip'
|
||||
];
|
||||
@@ -68,7 +69,12 @@ switch ($command) {
|
||||
response_complete(200, array('result' => $output), $output);
|
||||
break;
|
||||
case 'restart':
|
||||
exec('unraid-api restart 2>/dev/null', $output, $retval);
|
||||
exec('/etc/rc.d/rc.unraid-api restart 2>&1', $output, $retval);
|
||||
$output = implode(PHP_EOL, $output);
|
||||
response_complete(200, array('success' => ($retval === 0), 'result' => $output, 'error' => ($retval !== 0 ? $output : null)), $output);
|
||||
break;
|
||||
case 'status':
|
||||
exec('unraid-api status 2>&1', $output, $retval);
|
||||
$output = implode(PHP_EOL, $output);
|
||||
response_complete(200, array('result' => $output), $output);
|
||||
break;
|
||||
|
||||
@@ -1,169 +1,77 @@
|
||||
import useTeleport from '@/composables/useTeleport';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock Vue's computed while preserving other exports
|
||||
vi.mock('vue', async () => ({
|
||||
...(await vi.importActual('vue')),
|
||||
computed: vi.fn((fn) => {
|
||||
const result = { value: fn() };
|
||||
return result;
|
||||
}),
|
||||
}));
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
describe('useTeleport', () => {
|
||||
beforeEach(() => {
|
||||
// Clear the DOM before each test
|
||||
document.body.innerHTML = '';
|
||||
document.head.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
// Clean up virtual container if it exists
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
if (virtualContainer) {
|
||||
virtualContainer.remove();
|
||||
}
|
||||
// Reset the module to clear the virtualModalContainer variable
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('should return teleportTarget computed property', () => {
|
||||
it('should return teleportTarget ref with correct value', () => {
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget).toBeDefined();
|
||||
expect(teleportTarget).toHaveProperty('value');
|
||||
expect(teleportTarget.value).toBe('#unraid-api-modals-virtual');
|
||||
});
|
||||
|
||||
it('should return #modals when element with id="modals" exists', () => {
|
||||
// Create element with id="modals"
|
||||
const modalsDiv = document.createElement('div');
|
||||
modalsDiv.id = 'modals';
|
||||
document.body.appendChild(modalsDiv);
|
||||
it('should create virtual container element on mount with correct properties', () => {
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
return { teleportTarget };
|
||||
},
|
||||
template: '<div>{{ teleportTarget }}</div>',
|
||||
});
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('#modals');
|
||||
// Initially, virtual container should not exist
|
||||
expect(document.getElementById('unraid-api-modals-virtual')).toBeNull();
|
||||
|
||||
// Mount the component
|
||||
mount(TestComponent);
|
||||
|
||||
// After mount, virtual container should be created with correct properties
|
||||
const virtualContainer = document.getElementById('unraid-api-modals-virtual');
|
||||
expect(virtualContainer).toBeTruthy();
|
||||
expect(virtualContainer?.className).toBe('unapi');
|
||||
expect(virtualContainer?.style.position).toBe('relative');
|
||||
expect(virtualContainer?.style.zIndex).toBe('999999');
|
||||
expect(virtualContainer?.parentElement).toBe(document.body);
|
||||
});
|
||||
|
||||
it('should prioritize #modals id over mounted unraid-modals', () => {
|
||||
// Create both elements
|
||||
const modalsDiv = document.createElement('div');
|
||||
modalsDiv.id = 'modals';
|
||||
document.body.appendChild(modalsDiv);
|
||||
it('should reuse existing virtual container within same test', () => {
|
||||
// Manually create the container first
|
||||
const manualContainer = document.createElement('div');
|
||||
manualContainer.id = 'unraid-api-modals-virtual';
|
||||
manualContainer.className = 'unapi';
|
||||
manualContainer.style.position = 'relative';
|
||||
manualContainer.style.zIndex = '999999';
|
||||
document.body.appendChild(manualContainer);
|
||||
|
||||
const unraidModals = document.createElement('unraid-modals');
|
||||
unraidModals.setAttribute('data-vue-mounted', 'true');
|
||||
document.body.appendChild(unraidModals);
|
||||
const TestComponent = defineComponent({
|
||||
setup() {
|
||||
const { teleportTarget } = useTeleport();
|
||||
return { teleportTarget };
|
||||
},
|
||||
template: '<div>{{ teleportTarget }}</div>',
|
||||
});
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('#modals');
|
||||
});
|
||||
// Mount component - should not create a new container
|
||||
mount(TestComponent);
|
||||
|
||||
it('should return mounted unraid-modals with inner #modals div', () => {
|
||||
// Create mounted unraid-modals with inner modals div
|
||||
const unraidModals = document.createElement('unraid-modals');
|
||||
unraidModals.setAttribute('data-vue-mounted', 'true');
|
||||
|
||||
const innerModals = document.createElement('div');
|
||||
innerModals.id = 'modals';
|
||||
unraidModals.appendChild(innerModals);
|
||||
|
||||
document.body.appendChild(unraidModals);
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('#modals');
|
||||
});
|
||||
|
||||
it('should add id to mounted unraid-modals when no inner modals div exists', () => {
|
||||
// Create mounted unraid-modals without inner div
|
||||
const unraidModals = document.createElement('unraid-modals');
|
||||
unraidModals.setAttribute('data-vue-mounted', 'true');
|
||||
document.body.appendChild(unraidModals);
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(unraidModals.id).toBe('unraid-modals-teleport-target');
|
||||
expect(teleportTarget.value).toBe('#unraid-modals-teleport-target');
|
||||
});
|
||||
|
||||
it('should use existing id of mounted unraid-modals if present', () => {
|
||||
// Create mounted unraid-modals with existing id
|
||||
const unraidModals = document.createElement('unraid-modals');
|
||||
unraidModals.setAttribute('data-vue-mounted', 'true');
|
||||
unraidModals.id = 'custom-modals-id';
|
||||
document.body.appendChild(unraidModals);
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('#custom-modals-id');
|
||||
});
|
||||
|
||||
it('should ignore unmounted unraid-modals elements', () => {
|
||||
// Create unmounted unraid-modals (without data-vue-mounted attribute)
|
||||
const unraidModals = document.createElement('unraid-modals');
|
||||
document.body.appendChild(unraidModals);
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('body');
|
||||
});
|
||||
|
||||
it('should ignore unraid-modals with data-vue-mounted="false"', () => {
|
||||
// Create unraid-modals with data-vue-mounted="false"
|
||||
const unraidModals = document.createElement('unraid-modals');
|
||||
unraidModals.setAttribute('data-vue-mounted', 'false');
|
||||
document.body.appendChild(unraidModals);
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('body');
|
||||
});
|
||||
|
||||
it('should return body as fallback when no suitable target exists', () => {
|
||||
// No elements in DOM
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('body');
|
||||
});
|
||||
|
||||
it('should handle multiple unraid-modals elements correctly', () => {
|
||||
// Create multiple unraid-modals, only one mounted
|
||||
const unmountedModals1 = document.createElement('unraid-modals');
|
||||
document.body.appendChild(unmountedModals1);
|
||||
|
||||
const mountedModals = document.createElement('unraid-modals');
|
||||
mountedModals.setAttribute('data-vue-mounted', 'true');
|
||||
mountedModals.id = 'mounted-modals';
|
||||
document.body.appendChild(mountedModals);
|
||||
|
||||
const unmountedModals2 = document.createElement('unraid-modals');
|
||||
document.body.appendChild(unmountedModals2);
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('#mounted-modals');
|
||||
});
|
||||
|
||||
it('should handle nested modal elements correctly', () => {
|
||||
// Create nested structure
|
||||
const container = document.createElement('div');
|
||||
|
||||
const unraidModals = document.createElement('unraid-modals');
|
||||
unraidModals.setAttribute('data-vue-mounted', 'true');
|
||||
|
||||
const innerDiv = document.createElement('div');
|
||||
const innerModals = document.createElement('div');
|
||||
innerModals.id = 'modals';
|
||||
|
||||
innerDiv.appendChild(innerModals);
|
||||
unraidModals.appendChild(innerDiv);
|
||||
container.appendChild(unraidModals);
|
||||
document.body.appendChild(container);
|
||||
|
||||
const { teleportTarget } = useTeleport();
|
||||
expect(teleportTarget.value).toBe('#modals');
|
||||
});
|
||||
|
||||
it('should be reactive to DOM changes', () => {
|
||||
const { teleportTarget } = useTeleport();
|
||||
|
||||
// Initially should be body
|
||||
expect(teleportTarget.value).toBe('body');
|
||||
|
||||
// Add modals element
|
||||
const modalsDiv = document.createElement('div');
|
||||
modalsDiv.id = 'modals';
|
||||
document.body.appendChild(modalsDiv);
|
||||
|
||||
// Recreate the composable to test updated DOM state
|
||||
const { teleportTarget: newTeleportTarget } = useTeleport();
|
||||
expect(newTeleportTarget.value).toBe('#modals');
|
||||
// Should still have only one container
|
||||
const containers = document.querySelectorAll('#unraid-api-modals-virtual');
|
||||
expect(containers.length).toBe(1);
|
||||
expect(containers[0]).toBe(manualContainer);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,24 @@
|
||||
import { computed } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
let virtualModalContainer: HTMLDivElement | null = null;
|
||||
|
||||
const ensureVirtualContainer = () => {
|
||||
if (!virtualModalContainer) {
|
||||
virtualModalContainer = document.createElement('div');
|
||||
virtualModalContainer.id = 'unraid-api-modals-virtual';
|
||||
virtualModalContainer.className = 'unapi';
|
||||
virtualModalContainer.style.position = 'relative';
|
||||
virtualModalContainer.style.zIndex = '999999';
|
||||
document.body.appendChild(virtualModalContainer);
|
||||
}
|
||||
return virtualModalContainer;
|
||||
};
|
||||
|
||||
const useTeleport = () => {
|
||||
// Computed property that finds the correct teleport target
|
||||
const teleportTarget = computed(() => {
|
||||
// #modals should be unique (id), but let's be defensive
|
||||
const modalsElement = document.getElementById('modals');
|
||||
if (modalsElement) return `#modals`;
|
||||
const teleportTarget = ref<string>('#unraid-api-modals-virtual');
|
||||
|
||||
// Find only mounted unraid-modals components (data-vue-mounted="true")
|
||||
// This ensures we don't target unmounted or duplicate elements
|
||||
const mountedModals = document.querySelector('unraid-modals[data-vue-mounted="true"]');
|
||||
if (mountedModals) {
|
||||
// Check if it has the inner #modals div
|
||||
const innerModals = mountedModals.querySelector('#modals');
|
||||
if (innerModals && innerModals.id) {
|
||||
return `#${innerModals.id}`;
|
||||
}
|
||||
// Use the mounted component itself as fallback
|
||||
// Add a unique identifier if it doesn't have one
|
||||
if (!mountedModals.id) {
|
||||
mountedModals.id = 'unraid-modals-teleport-target';
|
||||
}
|
||||
return `#${mountedModals.id}`;
|
||||
}
|
||||
|
||||
// Final fallback to body - modals component not mounted yet
|
||||
return 'body';
|
||||
onMounted(() => {
|
||||
ensureVirtualContainer();
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -17,6 +17,7 @@ const config: CodegenConfig = {
|
||||
Port: 'number',
|
||||
UUID: 'string',
|
||||
PrefixedID: 'string',
|
||||
BigInt: 'number',
|
||||
},
|
||||
},
|
||||
generates: {
|
||||
|
||||
2
web/components.d.ts
vendored
2
web/components.d.ts
vendored
@@ -16,6 +16,8 @@ declare module 'vue' {
|
||||
ApiKeyCreate: typeof import('./src/components/ApiKey/ApiKeyCreate.vue')['default']
|
||||
ApiKeyManager: typeof import('./src/components/ApiKey/ApiKeyManager.vue')['default']
|
||||
'ApiKeyPage.standalone': typeof import('./src/components/ApiKeyPage.standalone.vue')['default']
|
||||
ApiStatus: typeof import('./src/components/ApiStatus/ApiStatus.vue')['default']
|
||||
'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']
|
||||
Beta: typeof import('./src/components/UserProfile/Beta.vue')['default']
|
||||
|
||||
5
web/src/components/ApiStatus/ApiStatus.standalone.vue
Normal file
5
web/src/components/ApiStatus/ApiStatus.standalone.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import ApiStatus from '@/components/ApiStatus/ApiStatus.vue';
|
||||
|
||||
export default ApiStatus;
|
||||
</script>
|
||||
139
web/src/components/ApiStatus/ApiStatus.vue
Normal file
139
web/src/components/ApiStatus/ApiStatus.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import { WebguiUnraidApiCommand } from '~/composables/services/webgui';
|
||||
import { useServerStore } from '~/store/server';
|
||||
|
||||
const serverStore = useServerStore();
|
||||
|
||||
const apiStatus = ref<string>('');
|
||||
const isRunning = ref<boolean>(false);
|
||||
const isLoading = ref<boolean>(false);
|
||||
const isRestarting = ref<boolean>(false);
|
||||
const statusMessage = ref<string>('');
|
||||
const messageType = ref<'success' | 'error' | 'info' | ''>('');
|
||||
|
||||
const checkStatus = async () => {
|
||||
isLoading.value = true;
|
||||
statusMessage.value = '';
|
||||
try {
|
||||
const response = await WebguiUnraidApiCommand({
|
||||
csrf_token: serverStore.csrf,
|
||||
command: 'status',
|
||||
});
|
||||
|
||||
if (response?.result) {
|
||||
apiStatus.value = response.result;
|
||||
isRunning.value =
|
||||
response.result.includes('running') ||
|
||||
response.result.includes('active') ||
|
||||
response.result.includes('status : online');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to get API status:', error);
|
||||
apiStatus.value = 'Error fetching status';
|
||||
isRunning.value = false;
|
||||
statusMessage.value = 'Failed to fetch API status';
|
||||
messageType.value = 'error';
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restartApi = async () => {
|
||||
const confirmed = window.confirm(
|
||||
'Are you sure you want to restart the Unraid API service? This will temporarily interrupt API connections.'
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
isRestarting.value = true;
|
||||
statusMessage.value = 'Restarting API service...';
|
||||
messageType.value = 'info';
|
||||
|
||||
try {
|
||||
const response = await WebguiUnraidApiCommand({
|
||||
csrf_token: serverStore.csrf,
|
||||
command: 'restart',
|
||||
});
|
||||
|
||||
if (response?.success) {
|
||||
statusMessage.value = 'API service restart initiated. Please wait a few seconds.';
|
||||
messageType.value = 'success';
|
||||
setTimeout(() => {
|
||||
checkStatus();
|
||||
}, 3000);
|
||||
} else {
|
||||
statusMessage.value = response?.error || 'Failed to restart API service';
|
||||
messageType.value = 'error';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to restart API:', error);
|
||||
statusMessage.value = 'Failed to restart API service';
|
||||
messageType.value = 'error';
|
||||
} finally {
|
||||
isRestarting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkStatus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-muted border-muted my-4 rounded-lg border p-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="mb-2 text-lg font-semibold">API Service Status</h3>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="font-medium">Status:</span>
|
||||
<span :class="['font-semibold', isRunning ? 'text-green-500' : 'text-orange-500']">
|
||||
{{ isLoading ? 'Loading...' : isRunning ? 'Running' : 'Not Running' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="my-4">
|
||||
<pre
|
||||
class="max-h-52 overflow-y-auto rounded bg-black p-4 font-mono text-xs break-words whitespace-pre-wrap text-white"
|
||||
>{{ apiStatus }}</pre
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="statusMessage"
|
||||
:class="[
|
||||
'my-4 rounded px-4 py-3 text-sm',
|
||||
messageType === 'success' && 'bg-green-500 text-white',
|
||||
messageType === 'error' && 'bg-red-500 text-white',
|
||||
messageType === 'info' && 'bg-blue-500 text-white',
|
||||
]"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-4">
|
||||
<button
|
||||
@click="checkStatus"
|
||||
:disabled="isLoading"
|
||||
class="bg-secondary hover:bg-secondary/80 text-secondary-foreground rounded px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{{ isLoading ? 'Refreshing...' : 'Refresh Status' }}
|
||||
</button>
|
||||
<button
|
||||
@click="restartApi"
|
||||
:disabled="isRestarting"
|
||||
class="bg-destructive hover:bg-destructive/90 text-destructive-foreground rounded px-4 py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{{ isRestarting ? 'Restarting...' : 'Restart API' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-muted mt-6 border-t pt-4">
|
||||
<p class="text-muted-foreground text-sm">
|
||||
View the current status of the Unraid API service and restart if needed. Use this to debug API
|
||||
connection issues.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -141,4 +141,9 @@ export const componentMappings: ComponentMapping[] = [
|
||||
selector: 'unraid-test-theme-switcher',
|
||||
appId: 'test-theme-switcher',
|
||||
},
|
||||
{
|
||||
component: defineAsyncComponent(() => import('../ApiStatus/ApiStatus.standalone.vue')),
|
||||
selector: 'unraid-api-status-manager',
|
||||
appId: 'api-status-manager',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -15,7 +15,7 @@ export type Scalars = {
|
||||
Int: { input: number; output: number; }
|
||||
Float: { input: number; output: number; }
|
||||
/** The `BigInt` scalar type represents non-fractional signed whole numeric values. */
|
||||
BigInt: { input: any; output: any; }
|
||||
BigInt: { input: number; output: number; }
|
||||
/** A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. */
|
||||
DateTime: { input: string; output: string; }
|
||||
/** The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://www.ecma-international.org/publications/files/ECMA-ST/ECMA-404.pdf). */
|
||||
@@ -241,6 +241,8 @@ export type ArrayDisk = Node & {
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** Array slot number. Parity1 is always 0 and Parity2 is always 29. Array slots will be 1 - 28. Cache slots are 30 - 53. Flash is 54. */
|
||||
idx: Scalars['Int']['output'];
|
||||
/** Whether the disk is currently spinning */
|
||||
isSpinning?: Maybe<Scalars['Boolean']['output']>;
|
||||
name?: Maybe<Scalars['String']['output']>;
|
||||
/** Number of unrecoverable errors reported by the device I/O drivers. Missing data due to unrecoverable array read errors is filled in on-the-fly using parity reconstruct (and we attempt to write this data back to the sector(s) which failed). Any unrecoverable write error results in disabling the disk. */
|
||||
numErrors?: Maybe<Scalars['BigInt']['output']>;
|
||||
@@ -607,6 +609,8 @@ export type Disk = Node & {
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
/** The interface type of the disk */
|
||||
interfaceType: DiskInterfaceType;
|
||||
/** Whether the disk is spinning or not */
|
||||
isSpinning: Scalars['Boolean']['output'];
|
||||
/** The model name of the disk */
|
||||
name: Scalars['String']['output'];
|
||||
/** The partitions on the disk */
|
||||
@@ -674,6 +678,7 @@ export enum DiskSmartStatus {
|
||||
|
||||
export type Docker = Node & {
|
||||
__typename?: 'Docker';
|
||||
containerUpdateStatuses: Array<ExplicitStatusItem>;
|
||||
containers: Array<DockerContainer>;
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
networks: Array<DockerNetwork>;
|
||||
@@ -699,13 +704,15 @@ export type DockerContainer = Node & {
|
||||
id: Scalars['PrefixedID']['output'];
|
||||
image: Scalars['String']['output'];
|
||||
imageId: Scalars['String']['output'];
|
||||
isRebuildReady?: Maybe<Scalars['Boolean']['output']>;
|
||||
isUpdateAvailable?: Maybe<Scalars['Boolean']['output']>;
|
||||
labels?: Maybe<Scalars['JSON']['output']>;
|
||||
mounts?: Maybe<Array<Scalars['JSON']['output']>>;
|
||||
names: Array<Scalars['String']['output']>;
|
||||
networkSettings?: Maybe<Scalars['JSON']['output']>;
|
||||
ports: Array<ContainerPort>;
|
||||
/** Total size of all the files in the container */
|
||||
sizeRootFs?: Maybe<Scalars['Int']['output']>;
|
||||
/** Total size of all files in the container (in bytes) */
|
||||
sizeRootFs?: Maybe<Scalars['BigInt']['output']>;
|
||||
state: ContainerState;
|
||||
status: Scalars['String']['output'];
|
||||
};
|
||||
@@ -770,6 +777,12 @@ export type EnableDynamicRemoteAccessInput = {
|
||||
url: AccessUrlInput;
|
||||
};
|
||||
|
||||
export type ExplicitStatusItem = {
|
||||
__typename?: 'ExplicitStatusItem';
|
||||
name: Scalars['String']['output'];
|
||||
updateStatus: UpdateStatus;
|
||||
};
|
||||
|
||||
export type Flash = Node & {
|
||||
__typename?: 'Flash';
|
||||
guid: Scalars['String']['output'];
|
||||
@@ -1225,6 +1238,7 @@ export type Mutation = {
|
||||
rclone: RCloneMutations;
|
||||
/** Reads each notification to recompute & update the overview. */
|
||||
recalculateOverview: NotificationOverview;
|
||||
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'];
|
||||
setDockerFolderChildren: ResolvedOrganizerV1;
|
||||
@@ -2260,6 +2274,14 @@ export type UpdateSettingsResponse = {
|
||||
warnings?: Maybe<Array<Scalars['String']['output']>>;
|
||||
};
|
||||
|
||||
/** Update status of a container. */
|
||||
export enum UpdateStatus {
|
||||
REBUILD_READY = 'REBUILD_READY',
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
UPDATE_AVAILABLE = 'UPDATE_AVAILABLE',
|
||||
UP_TO_DATE = 'UP_TO_DATE'
|
||||
}
|
||||
|
||||
export type Uptime = {
|
||||
__typename?: 'Uptime';
|
||||
timestamp?: Maybe<Scalars['String']['output']>;
|
||||
|
||||
@@ -35,7 +35,7 @@ export const WebguiState = request.url('/plugins/dynamix.my.servers/data/server-
|
||||
*/
|
||||
export interface WebguiUnraidApiCommandPayload {
|
||||
csrf_token: string;
|
||||
command: 'report' | 'restart' | 'start';
|
||||
command: 'report' | 'restart' | 'start' | 'status';
|
||||
param1?: '-v' | '-vv';
|
||||
}
|
||||
export const WebguiUnraidApiCommand = async (payload: WebguiUnraidApiCommandPayload) => {
|
||||
|
||||
Reference in New Issue
Block a user