Skip to content

Commit 2d90408

Browse files
authored
feat: add resolver for logging (#1222)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a comprehensive Log Viewer accessible from the web interface and Unraid management, allowing users to easily view, refresh, and download log files. - Enabled real-time log updates with auto-scroll functionality for seamless monitoring. - Enhanced log display with syntax highlighting and detailed file metadata for improved readability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 653de00 commit 2d90408

File tree

22 files changed

+1629
-9
lines changed

22 files changed

+1629
-9
lines changed

api/src/__test__/store/modules/paths.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ test('Returns paths', async () => {
2525
"keyfile-base",
2626
"machine-id",
2727
"log-base",
28+
"unraid-log-base",
2829
"var-run",
2930
"auth-sessions",
3031
"auth-keys",

api/src/core/pubsub.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export enum PUBSUB_CHANNEL {
1818
SERVERS = 'SERVERS',
1919
VMS = 'VMS',
2020
REGISTRATION = 'REGISTRATION',
21+
LOG_FILE = 'LOG_FILE',
2122
}
2223

2324
export const pubsub = new PubSub({ eventEmitter });

api/src/graphql/generated/api/operations.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import * as Types from '@app/graphql/generated/api/types.js';
33

44
import { z } from 'zod'
5-
import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, arrayDiskInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js'
5+
import { AccessUrl, AccessUrlInput, AddPermissionInput, AddRoleForApiKeyInput, AddRoleForUserInput, AllowedOriginInput, ApiKey, ApiKeyResponse, ApiKeyWithSecret, ApiSettingsInput, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, ConnectSettings, ConnectSettingsValues, ConnectSignInInput, ConnectUserInfoInput, ContainerHostConfig, ContainerMount, ContainerPort, ContainerPortType, ContainerState, CreateApiKeyInput, Devices, Disk, DiskFsType, DiskInterfaceType, DiskPartition, DiskSmartStatus, Display, Docker, DockerContainer, DockerNetwork, DynamicRemoteAccessStatus, DynamicRemoteAccessType, EnableDynamicRemoteAccessInput, Flash, Gpu, Importance, Info, InfoApps, InfoCpu, InfoMemory, KeyFile, LogFile, LogFileContent, Me, MemoryFormFactor, MemoryLayout, MemoryType, MinigraphStatus, MinigraphqlResponse, Mount, Network, Node, Notification, NotificationCounts, NotificationData, NotificationFilter, NotificationOverview, NotificationType, Notifications, NotificationslistArgs, Os, Owner, ParityCheck, Partition, Pci, Permission, ProfileModel, Registration, RegistrationState, RelayResponse, RemoteAccess, RemoveRoleFromApiKeyInput, Resource, Role, Server, ServerStatus, Service, SetupRemoteAccessInput, Share, System, Temperature, Theme, URL_TYPE, UnassignedDevice, Uptime, Usb, User, UserAccount, Vars, Versions, VmDomain, VmState, Vms, WAN_ACCESS_TYPE, WAN_FORWARD_TYPE, Welcome, addUserInput, arrayDiskInput, deleteUserInput, mdState, registrationType, usersInput } from '@app/graphql/generated/api/types.js'
66
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
77

88
type Properties<T> = Required<{
@@ -596,6 +596,25 @@ export function KeyFileSchema(): z.ZodObject<Properties<KeyFile>> {
596596
})
597597
}
598598

599+
export function LogFileSchema(): z.ZodObject<Properties<LogFile>> {
600+
return z.object({
601+
__typename: z.literal('LogFile').optional(),
602+
modifiedAt: z.string(),
603+
name: z.string(),
604+
path: z.string(),
605+
size: z.number()
606+
})
607+
}
608+
609+
export function LogFileContentSchema(): z.ZodObject<Properties<LogFileContent>> {
610+
return z.object({
611+
__typename: z.literal('LogFileContent').optional(),
612+
content: z.string(),
613+
path: z.string(),
614+
totalLines: z.number()
615+
})
616+
}
617+
599618
export function MeSchema(): z.ZodObject<Properties<Me>> {
600619
return z.object({
601620
__typename: z.literal('Me').optional(),

api/src/graphql/generated/api/types.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ export type ApiSettingsInput = {
9797
extraOrigins?: InputMaybe<Array<Scalars['String']['input']>>;
9898
/** The type of port forwarding to use for Remote Access. */
9999
forwardType?: InputMaybe<WAN_FORWARD_TYPE>;
100-
/** The port to use for Remote Access. */
100+
/**
101+
* The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType.
102+
* Ignored if accessType is DISABLED or forwardType is UPNP.
103+
*/
101104
port?: InputMaybe<Scalars['Port']['input']>;
102105
/**
103106
* If true, the GraphQL sandbox will be enabled and available at /graphql.
@@ -330,10 +333,18 @@ export type ConnectSettings = Node & {
330333
/** Intersection type of ApiSettings and RemoteAccess */
331334
export type ConnectSettingsValues = {
332335
__typename?: 'ConnectSettingsValues';
336+
/** The type of WAN access used for Remote Access. */
333337
accessType: WAN_ACCESS_TYPE;
338+
/** A list of origins allowed to interact with the API. */
334339
extraOrigins: Array<Scalars['String']['output']>;
340+
/** The type of port forwarding used for Remote Access. */
335341
forwardType?: Maybe<WAN_FORWARD_TYPE>;
342+
/** The port used for Remote Access. */
336343
port?: Maybe<Scalars['Port']['output']>;
344+
/**
345+
* If true, the GraphQL sandbox is enabled and available at /graphql.
346+
* If false, the GraphQL sandbox is disabled and only the production API will be available.
347+
*/
337348
sandbox: Scalars['Boolean']['output'];
338349
};
339350

@@ -634,6 +645,30 @@ export type KeyFile = {
634645
location?: Maybe<Scalars['String']['output']>;
635646
};
636647

648+
/** Represents a log file in the system */
649+
export type LogFile = {
650+
__typename?: 'LogFile';
651+
/** Last modified timestamp */
652+
modifiedAt: Scalars['DateTime']['output'];
653+
/** Name of the log file */
654+
name: Scalars['String']['output'];
655+
/** Full path to the log file */
656+
path: Scalars['String']['output'];
657+
/** Size of the log file in bytes */
658+
size: Scalars['Int']['output'];
659+
};
660+
661+
/** Content of a log file */
662+
export type LogFileContent = {
663+
__typename?: 'LogFileContent';
664+
/** Content of the log file */
665+
content: Scalars['String']['output'];
666+
/** Path to the log file */
667+
path: Scalars['String']['output'];
668+
/** Total number of lines in the file */
669+
totalLines: Scalars['Int']['output'];
670+
};
671+
637672
/** The current user */
638673
export type Me = UserAccount & {
639674
__typename?: 'Me';
@@ -744,6 +779,10 @@ export type Mutation = {
744779
unmountArrayDisk?: Maybe<Disk>;
745780
/** Marks a notification as unread. */
746781
unreadNotification: Notification;
782+
/**
783+
* Update the API settings.
784+
* Some setting combinations may be required or disallowed. Please refer to each setting for more information.
785+
*/
747786
updateApiSettings: ConnectSettingsValues;
748787
};
749788

@@ -1113,6 +1152,14 @@ export type Query = {
11131152
extraAllowedOrigins: Array<Scalars['String']['output']>;
11141153
flash?: Maybe<Flash>;
11151154
info?: Maybe<Info>;
1155+
/**
1156+
* Get the content of a specific log file
1157+
* @param path Path to the log file
1158+
* @param lines Number of lines to read from the end of the file (default: 100)
1159+
*/
1160+
logFile: LogFileContent;
1161+
/** List all available log files */
1162+
logFiles: Array<LogFile>;
11161163
/** Current user account */
11171164
me?: Maybe<Me>;
11181165
network?: Maybe<Network>;
@@ -1163,6 +1210,12 @@ export type QuerydockerNetworksArgs = {
11631210
};
11641211

11651212

1213+
export type QuerylogFileArgs = {
1214+
lines?: InputMaybe<Scalars['Int']['input']>;
1215+
path: Scalars['String']['input'];
1216+
};
1217+
1218+
11661219
export type QueryuserArgs = {
11671220
id: Scalars['ID']['input'];
11681221
};
@@ -1353,6 +1406,11 @@ export type Subscription = {
13531406
dockerNetworks: Array<Maybe<DockerNetwork>>;
13541407
flash: Flash;
13551408
info: Info;
1409+
/**
1410+
* Subscribe to changes in a log file
1411+
* @param path Path to the log file
1412+
*/
1413+
logFile: LogFileContent;
13561414
me?: Maybe<Me>;
13571415
notificationAdded: Notification;
13581416
notificationsOverview: NotificationOverview;
@@ -1383,6 +1441,11 @@ export type SubscriptiondockerNetworkArgs = {
13831441
};
13841442

13851443

1444+
export type SubscriptionlogFileArgs = {
1445+
path: Scalars['String']['input'];
1446+
};
1447+
1448+
13861449
export type SubscriptionserviceArgs = {
13871450
name: Scalars['String']['input'];
13881451
};
@@ -1927,6 +1990,8 @@ export type ResolversTypes = ResolversObject<{
19271990
Int: ResolverTypeWrapper<Scalars['Int']['output']>;
19281991
JSON: ResolverTypeWrapper<Scalars['JSON']['output']>;
19291992
KeyFile: ResolverTypeWrapper<KeyFile>;
1993+
LogFile: ResolverTypeWrapper<LogFile>;
1994+
LogFileContent: ResolverTypeWrapper<LogFileContent>;
19301995
Long: ResolverTypeWrapper<Scalars['Long']['output']>;
19311996
Me: ResolverTypeWrapper<Me>;
19321997
MemoryFormFactor: MemoryFormFactor;
@@ -2047,6 +2112,8 @@ export type ResolversParentTypes = ResolversObject<{
20472112
Int: Scalars['Int']['output'];
20482113
JSON: Scalars['JSON']['output'];
20492114
KeyFile: KeyFile;
2115+
LogFile: LogFile;
2116+
LogFileContent: LogFileContent;
20502117
Long: Scalars['Long']['output'];
20512118
Me: Me;
20522119
MemoryLayout: MemoryLayout;
@@ -2481,6 +2548,21 @@ export type KeyFileResolvers<ContextType = Context, ParentType extends Resolvers
24812548
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
24822549
}>;
24832550

2551+
export type LogFileResolvers<ContextType = Context, ParentType extends ResolversParentTypes['LogFile'] = ResolversParentTypes['LogFile']> = ResolversObject<{
2552+
modifiedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
2553+
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2554+
path?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2555+
size?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
2556+
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2557+
}>;
2558+
2559+
export type LogFileContentResolvers<ContextType = Context, ParentType extends ResolversParentTypes['LogFileContent'] = ResolversParentTypes['LogFileContent']> = ResolversObject<{
2560+
content?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2561+
path?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2562+
totalLines?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
2563+
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2564+
}>;
2565+
24842566
export interface LongScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['Long'], any> {
24852567
name: 'Long';
24862568
}
@@ -2763,6 +2845,8 @@ export type QueryResolvers<ContextType = Context, ParentType extends ResolversPa
27632845
extraAllowedOrigins?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
27642846
flash?: Resolver<Maybe<ResolversTypes['Flash']>, ParentType, ContextType>;
27652847
info?: Resolver<Maybe<ResolversTypes['Info']>, ParentType, ContextType>;
2848+
logFile?: Resolver<ResolversTypes['LogFileContent'], ParentType, ContextType, RequireFields<QuerylogFileArgs, 'path'>>;
2849+
logFiles?: Resolver<Array<ResolversTypes['LogFile']>, ParentType, ContextType>;
27662850
me?: Resolver<Maybe<ResolversTypes['Me']>, ParentType, ContextType>;
27672851
network?: Resolver<Maybe<ResolversTypes['Network']>, ParentType, ContextType>;
27682852
notifications?: Resolver<ResolversTypes['Notifications'], ParentType, ContextType>;
@@ -2857,6 +2941,7 @@ export type SubscriptionResolvers<ContextType = Context, ParentType extends Reso
28572941
dockerNetworks?: SubscriptionResolver<Array<Maybe<ResolversTypes['DockerNetwork']>>, "dockerNetworks", ParentType, ContextType>;
28582942
flash?: SubscriptionResolver<ResolversTypes['Flash'], "flash", ParentType, ContextType>;
28592943
info?: SubscriptionResolver<ResolversTypes['Info'], "info", ParentType, ContextType>;
2944+
logFile?: SubscriptionResolver<ResolversTypes['LogFileContent'], "logFile", ParentType, ContextType, RequireFields<SubscriptionlogFileArgs, 'path'>>;
28602945
me?: SubscriptionResolver<Maybe<ResolversTypes['Me']>, "me", ParentType, ContextType>;
28612946
notificationAdded?: SubscriptionResolver<ResolversTypes['Notification'], "notificationAdded", ParentType, ContextType>;
28622947
notificationsOverview?: SubscriptionResolver<ResolversTypes['NotificationOverview'], "notificationsOverview", ParentType, ContextType>;
@@ -3212,6 +3297,8 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
32123297
InfoMemory?: InfoMemoryResolvers<ContextType>;
32133298
JSON?: GraphQLScalarType;
32143299
KeyFile?: KeyFileResolvers<ContextType>;
3300+
LogFile?: LogFileResolvers<ContextType>;
3301+
LogFileContent?: LogFileContentResolvers<ContextType>;
32153302
Long?: GraphQLScalarType;
32163303
Me?: MeResolvers<ContextType>;
32173304
MemoryLayout?: MemoryLayoutResolvers<ContextType>;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
type Query {
2+
"""
3+
List all available log files
4+
"""
5+
logFiles: [LogFile!]!
6+
7+
"""
8+
Get the content of a specific log file
9+
@param path Path to the log file
10+
@param lines Number of lines to read from the end of the file (default: 100)
11+
@param startLine Optional starting line number (1-indexed)
12+
"""
13+
logFile(path: String!, lines: Int, startLine: Int): LogFileContent!
14+
}
15+
16+
type Subscription {
17+
"""
18+
Subscribe to changes in a log file
19+
@param path Path to the log file
20+
"""
21+
logFile(path: String!): LogFileContent!
22+
}
23+
24+
"""
25+
Represents a log file in the system
26+
"""
27+
type LogFile {
28+
"""
29+
Name of the log file
30+
"""
31+
name: String!
32+
33+
"""
34+
Full path to the log file
35+
"""
36+
path: String!
37+
38+
"""
39+
Size of the log file in bytes
40+
"""
41+
size: Int!
42+
43+
"""
44+
Last modified timestamp
45+
"""
46+
modifiedAt: DateTime!
47+
}
48+
49+
"""
50+
Content of a log file
51+
"""
52+
type LogFileContent {
53+
"""
54+
Path to the log file
55+
"""
56+
path: String!
57+
58+
"""
59+
Content of the log file
60+
"""
61+
content: String!
62+
63+
"""
64+
Total number of lines in the file
65+
"""
66+
totalLines: Int!
67+
68+
"""
69+
Starting line number of the content (1-indexed)
70+
"""
71+
startLine: Int
72+
}

api/src/store/modules/paths.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const initialState = {
6060
'keyfile-base': resolvePath(process.env.PATHS_KEYFILE_BASE ?? ('/boot/config' as const)),
6161
'machine-id': resolvePath(process.env.PATHS_MACHINE_ID ?? ('/var/lib/dbus/machine-id' as const)),
6262
'log-base': resolvePath('/var/log/unraid-api/' as const),
63+
'unraid-log-base': resolvePath('/var/log/' as const),
6364
'var-run': '/var/run' as const,
6465
// contains sess_ files that correspond to authenticated user sessions
6566
'auth-sessions': process.env.PATHS_AUTH_SESSIONS ?? '/var/lib/php',
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
3+
import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js';
4+
import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.js';
5+
6+
@Module({
7+
providers: [LogsResolver, LogsService],
8+
exports: [LogsService],
9+
})
10+
export class LogsModule {}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
3+
import { beforeEach, describe, expect, it } from 'vitest';
4+
5+
import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js';
6+
import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.js';
7+
8+
describe('LogsResolver', () => {
9+
let resolver: LogsResolver;
10+
let service: LogsService;
11+
beforeEach(async () => {
12+
const module: TestingModule = await Test.createTestingModule({
13+
providers: [
14+
LogsResolver,
15+
{
16+
provide: LogsService,
17+
useValue: {
18+
// Add mock implementations for service methods used by resolver
19+
},
20+
},
21+
],
22+
}).compile();
23+
resolver = module.get<LogsResolver>(LogsResolver);
24+
service = module.get<LogsService>(LogsService);
25+
});
26+
it('should be defined', () => {
27+
expect(resolver).toBeDefined();
28+
});
29+
// Add more tests for resolver methods
30+
});

0 commit comments

Comments
 (0)