Compare commits

..

9 Commits

Author SHA1 Message Date
Eli Bosley
1d9ce0aa3d feat: add unraid api status manager (#1708)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- New Features
- Added “Unraid API Status” page under Management Access to view current
API status, refresh it, and restart the API with confirmation.
- Status view shows running state, detailed output, and in-app
success/error messages after actions.

- Style
- Minor theme adjustments to border colors; no layout changes expected.

- Chores
  - Updated UI text: “Unraid API” → “Unraid API Settings” in settings.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-16 13:04:01 -04:00
Eli Bosley
9714b21c5c fix: no sizeRootFs unless queried (#1710)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - sizeRootFs now reported in bytes as BigInt.
- Container listings include size only when requested; caching
distinguishes size vs no-size.
- New Docker-related fields: per-container update statuses,
rebuild/update indicators, spinning state, and a mutation to refresh
docker digests.
- **Tests**
- Added unit tests for GraphQL field inspection and container size/cache
behavior.
- **Chores**
  - Version bumped to 4.22.2.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-16 12:01:49 -04:00
Eli Bosley
44b4d77d80 fix: use virtual-modal-container (#1709)
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- Bug Fixes
- Ensured modals consistently render by using a dedicated container,
reducing cases where dialogs failed to open or appeared in the wrong
place.
- Improved reliability of modal mounting during page load and
navigation.

- Refactor
- Simplified the modal mounting mechanism to improve stability and
reduce reliance on DOM structure assumptions.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2025-09-15 16:04:03 -04:00
renovate[bot]
3f5039c342 chore(deps): pin dependency node to 22.19.0 (#1706)
Coming soon: The Renovate bot (GitHub App) will be renamed to Mend. PRs
from Renovate will soon appear from 'Mend'. Learn more
[here](https://redirect.github.com/renovatebot/renovate/discussions/37842).

This PR contains the following updates:

| Package | Type | Update | Change |
|---|---|---|---|
| [node](https://redirect.github.com/actions/node-versions) | uses-with
| pin | `22` -> `22.19.0` |

Add the preset `:preserveSemverRanges` to your config if you don't want
to pin your dependencies.

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined),
Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled by config. Please merge this manually once you
are satisfied.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the
rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update
again.

---

- [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check
this box

---

This PR was generated by [Mend Renovate](https://mend.io/renovate/).
View the [repository job
log](https://developer.mend.io/github/unraid/api).

<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0MS45Ny4xMCIsInVwZGF0ZWRJblZlciI6IjQxLjk3LjEwIiwidGFyZ2V0QnJhbmNoIjoibWFpbiIsImxhYmVscyI6W119-->

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 14:37:24 -04:00
Eli Bosley
1d2c6701ce fix(logging): remove colorized logs (#1705)
Move to simplified logging for PM2 (no more tables)
2025-09-15 13:34:07 -04:00
Eli Bosley
0ee09aefbb chore: prettier webhooks 2025-09-15 13:00:08 -04:00
Eli Bosley
c60a51dc1b chore: remove pnpm setup step from release workflow 2025-09-15 12:57:32 -04:00
Eli Bosley
c4fbf698b4 chore: specify node version in release workflow 2025-09-15 12:55:04 -04:00
Eli Bosley
00faa8f9d9 chore: update pnpm action to use the latest version 2025-09-15 12:52:19 -04:00
33 changed files with 897 additions and 254 deletions

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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: {

View File

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

View File

@@ -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!

View File

@@ -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,

View File

@@ -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'];
};

View File

@@ -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) {

View File

@@ -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());

View File

@@ -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'
);
}
}

View File

@@ -33,7 +33,8 @@ export class StopCommand extends CommandRunner {
{ tag: 'PM2 Delete', stdio: 'inherit' },
'delete',
ECOSYSTEM_PATH,
'--no-autorestart'
'--no-autorestart',
'--mini-list'
);
}
}

View File

@@ -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 })

View File

@@ -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 });
});
});

View File

@@ -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({

View File

@@ -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
});

View File

@@ -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;

View 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);
});
});
});

View 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);
}
}

View File

@@ -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"

View File

@@ -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: {

View File

@@ -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;

View File

@@ -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 -->

View File

@@ -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 -->

View File

@@ -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;

View File

@@ -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);
});
});

View File

@@ -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 {

View File

@@ -17,6 +17,7 @@ const config: CodegenConfig = {
Port: 'number',
UUID: 'string',
PrefixedID: 'string',
BigInt: 'number',
},
},
generates: {

2
web/components.d.ts vendored
View File

@@ -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']

View File

@@ -0,0 +1,5 @@
<script lang="ts">
import ApiStatus from '@/components/ApiStatus/ApiStatus.vue';
export default ApiStatus;
</script>

View 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>

View File

@@ -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',
},
];

View File

@@ -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']>;

View File

@@ -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) => {