Skip to content

Commit 7173a8b

Browse files
committed
feat: add resolver for logging
1 parent 4409be1 commit 7173a8b

File tree

10 files changed

+519
-1
lines changed

10 files changed

+519
-1
lines changed

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, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, 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, ArrayType, ArrayCapacity, ArrayDisk, ArrayDiskFsColor, ArrayDiskStatus, ArrayDiskType, ArrayPendingState, ArrayState, Baseboard, Capacity, Case, Cloud, CloudResponse, Config, ConfigErrorState, Connect, 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<{
@@ -564,6 +564,25 @@ export function KeyFileSchema(): z.ZodObject<Properties<KeyFile>> {
564564
})
565565
}
566566

567+
export function LogFileSchema(): z.ZodObject<Properties<LogFile>> {
568+
return z.object({
569+
__typename: z.literal('LogFile').optional(),
570+
modifiedAt: z.string(),
571+
name: z.string(),
572+
path: z.string(),
573+
size: z.number()
574+
})
575+
}
576+
577+
export function LogFileContentSchema(): z.ZodObject<Properties<LogFileContent>> {
578+
return z.object({
579+
__typename: z.literal('LogFileContent').optional(),
580+
content: z.string(),
581+
path: z.string(),
582+
totalLines: z.number()
583+
})
584+
}
585+
567586
export function MeSchema(): z.ZodObject<Properties<Me>> {
568587
return z.object({
569588
__typename: z.literal('Me').optional(),

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

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,30 @@ export type KeyFile = {
594594
location?: Maybe<Scalars['String']['output']>;
595595
};
596596

597+
/** Represents a log file in the system */
598+
export type LogFile = {
599+
__typename?: 'LogFile';
600+
/** Last modified timestamp */
601+
modifiedAt: Scalars['DateTime']['output'];
602+
/** Name of the log file */
603+
name: Scalars['String']['output'];
604+
/** Full path to the log file */
605+
path: Scalars['String']['output'];
606+
/** Size of the log file in bytes */
607+
size: Scalars['Int']['output'];
608+
};
609+
610+
/** Content of a log file */
611+
export type LogFileContent = {
612+
__typename?: 'LogFileContent';
613+
/** Content of the log file */
614+
content: Scalars['String']['output'];
615+
/** Path to the log file */
616+
path: Scalars['String']['output'];
617+
/** Total number of lines in the file */
618+
totalLines: Scalars['Int']['output'];
619+
};
620+
597621
/** The current user */
598622
export type Me = UserAccount & {
599623
__typename?: 'Me';
@@ -1067,6 +1091,14 @@ export type Query = {
10671091
extraAllowedOrigins: Array<Scalars['String']['output']>;
10681092
flash?: Maybe<Flash>;
10691093
info?: Maybe<Info>;
1094+
/**
1095+
* Get the content of a specific log file
1096+
* @param path Path to the log file
1097+
* @param lines Number of lines to read from the end of the file (default: 100)
1098+
*/
1099+
logFile: LogFileContent;
1100+
/** List all available log files */
1101+
logFiles: Array<LogFile>;
10701102
/** Current user account */
10711103
me?: Maybe<Me>;
10721104
network?: Maybe<Network>;
@@ -1117,6 +1149,12 @@ export type QuerydockerNetworksArgs = {
11171149
};
11181150

11191151

1152+
export type QuerylogFileArgs = {
1153+
lines?: InputMaybe<Scalars['Int']['input']>;
1154+
path: Scalars['String']['input'];
1155+
};
1156+
1157+
11201158
export type QueryuserArgs = {
11211159
id: Scalars['ID']['input'];
11221160
};
@@ -1307,6 +1345,11 @@ export type Subscription = {
13071345
dockerNetworks: Array<Maybe<DockerNetwork>>;
13081346
flash: Flash;
13091347
info: Info;
1348+
/**
1349+
* Subscribe to changes in a log file
1350+
* @param path Path to the log file
1351+
*/
1352+
logFile: LogFileContent;
13101353
me?: Maybe<Me>;
13111354
notificationAdded: Notification;
13121355
notificationsOverview: NotificationOverview;
@@ -1337,6 +1380,11 @@ export type SubscriptiondockerNetworkArgs = {
13371380
};
13381381

13391382

1383+
export type SubscriptionlogFileArgs = {
1384+
path: Scalars['String']['input'];
1385+
};
1386+
1387+
13401388
export type SubscriptionserviceArgs = {
13411389
name: Scalars['String']['input'];
13421390
};
@@ -1878,6 +1926,8 @@ export type ResolversTypes = ResolversObject<{
18781926
Int: ResolverTypeWrapper<Scalars['Int']['output']>;
18791927
JSON: ResolverTypeWrapper<Scalars['JSON']['output']>;
18801928
KeyFile: ResolverTypeWrapper<KeyFile>;
1929+
LogFile: ResolverTypeWrapper<LogFile>;
1930+
LogFileContent: ResolverTypeWrapper<LogFileContent>;
18811931
Long: ResolverTypeWrapper<Scalars['Long']['output']>;
18821932
Me: ResolverTypeWrapper<Me>;
18831933
MemoryFormFactor: MemoryFormFactor;
@@ -1995,6 +2045,8 @@ export type ResolversParentTypes = ResolversObject<{
19952045
Int: Scalars['Int']['output'];
19962046
JSON: Scalars['JSON']['output'];
19972047
KeyFile: KeyFile;
2048+
LogFile: LogFile;
2049+
LogFileContent: LogFileContent;
19982050
Long: Scalars['Long']['output'];
19992051
Me: Me;
20002052
MemoryLayout: MemoryLayout;
@@ -2411,6 +2463,21 @@ export type KeyFileResolvers<ContextType = Context, ParentType extends Resolvers
24112463
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
24122464
}>;
24132465

2466+
export type LogFileResolvers<ContextType = Context, ParentType extends ResolversParentTypes['LogFile'] = ResolversParentTypes['LogFile']> = ResolversObject<{
2467+
modifiedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
2468+
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2469+
path?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2470+
size?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
2471+
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2472+
}>;
2473+
2474+
export type LogFileContentResolvers<ContextType = Context, ParentType extends ResolversParentTypes['LogFileContent'] = ResolversParentTypes['LogFileContent']> = ResolversObject<{
2475+
content?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2476+
path?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2477+
totalLines?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
2478+
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2479+
}>;
2480+
24142481
export interface LongScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['Long'], any> {
24152482
name: 'Long';
24162483
}
@@ -2692,6 +2759,8 @@ export type QueryResolvers<ContextType = Context, ParentType extends ResolversPa
26922759
extraAllowedOrigins?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
26932760
flash?: Resolver<Maybe<ResolversTypes['Flash']>, ParentType, ContextType>;
26942761
info?: Resolver<Maybe<ResolversTypes['Info']>, ParentType, ContextType>;
2762+
logFile?: Resolver<ResolversTypes['LogFileContent'], ParentType, ContextType, RequireFields<QuerylogFileArgs, 'path'>>;
2763+
logFiles?: Resolver<Array<ResolversTypes['LogFile']>, ParentType, ContextType>;
26952764
me?: Resolver<Maybe<ResolversTypes['Me']>, ParentType, ContextType>;
26962765
network?: Resolver<Maybe<ResolversTypes['Network']>, ParentType, ContextType>;
26972766
notifications?: Resolver<ResolversTypes['Notifications'], ParentType, ContextType>;
@@ -2786,6 +2855,7 @@ export type SubscriptionResolvers<ContextType = Context, ParentType extends Reso
27862855
dockerNetworks?: SubscriptionResolver<Array<Maybe<ResolversTypes['DockerNetwork']>>, "dockerNetworks", ParentType, ContextType>;
27872856
flash?: SubscriptionResolver<ResolversTypes['Flash'], "flash", ParentType, ContextType>;
27882857
info?: SubscriptionResolver<ResolversTypes['Info'], "info", ParentType, ContextType>;
2858+
logFile?: SubscriptionResolver<ResolversTypes['LogFileContent'], "logFile", ParentType, ContextType, RequireFields<SubscriptionlogFileArgs, 'path'>>;
27892859
me?: SubscriptionResolver<Maybe<ResolversTypes['Me']>, "me", ParentType, ContextType>;
27902860
notificationAdded?: SubscriptionResolver<ResolversTypes['Notification'], "notificationAdded", ParentType, ContextType>;
27912861
notificationsOverview?: SubscriptionResolver<ResolversTypes['NotificationOverview'], "notificationsOverview", ParentType, ContextType>;
@@ -3139,6 +3209,8 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
31393209
InfoMemory?: InfoMemoryResolvers<ContextType>;
31403210
JSON?: GraphQLScalarType;
31413211
KeyFile?: KeyFileResolvers<ContextType>;
3212+
LogFile?: LogFileResolvers<ContextType>;
3213+
LogFileContent?: LogFileContentResolvers<ContextType>;
31423214
Long?: GraphQLScalarType;
31433215
Me?: MeResolvers<ContextType>;
31443216
MemoryLayout?: MemoryLayoutResolvers<ContextType>;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
"""
12+
logFile(path: String!, lines: Int): LogFileContent!
13+
}
14+
15+
type Subscription {
16+
"""
17+
Subscribe to changes in a log file
18+
@param path Path to the log file
19+
"""
20+
logFile(path: String!): LogFileContent!
21+
}
22+
23+
"""
24+
Represents a log file in the system
25+
"""
26+
type LogFile {
27+
"""
28+
Name of the log file
29+
"""
30+
name: String!
31+
32+
"""
33+
Full path to the log file
34+
"""
35+
path: String!
36+
37+
"""
38+
Size of the log file in bytes
39+
"""
40+
size: Int!
41+
42+
"""
43+
Last modified timestamp
44+
"""
45+
modifiedAt: DateTime!
46+
}
47+
48+
"""
49+
Content of a log file
50+
"""
51+
type LogFileContent {
52+
"""
53+
Path to the log file
54+
"""
55+
path: String!
56+
57+
"""
58+
Content of the log file
59+
"""
60+
content: String!
61+
62+
"""
63+
Total number of lines in the file
64+
"""
65+
totalLines: Int!
66+
}

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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { LogsResolver } from './logs.resolver.js';
3+
import { LogsService } from './logs.service.js';
4+
5+
@Module({
6+
providers: [LogsResolver, LogsService],
7+
exports: [LogsService],
8+
})
9+
export class LogsModule {}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { LogsResolver } from './logs.resolver';
3+
4+
describe('LogsResolver', () => {
5+
let resolver: LogsResolver;
6+
7+
beforeEach(async () => {
8+
const module: TestingModule = await Test.createTestingModule({
9+
providers: [LogsResolver],
10+
}).compile();
11+
12+
resolver = module.get<LogsResolver>(LogsResolver);
13+
});
14+
15+
it('should be defined', () => {
16+
expect(resolver).toBeDefined();
17+
});
18+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { Args, Query, Resolver, Subscription } from '@nestjs/graphql';
2+
3+
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
4+
5+
import { Resource } from '@app/graphql/generated/api/types.js';
6+
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
7+
import { LogsService } from './logs.service.js';
8+
9+
@Resolver('Logs')
10+
export class LogsResolver {
11+
constructor(
12+
private readonly logsService: LogsService
13+
) {}
14+
15+
@Query()
16+
@UsePermissions({
17+
action: AuthActionVerb.READ,
18+
resource: Resource.LOGS,
19+
possession: AuthPossession.ANY,
20+
})
21+
async logFiles() {
22+
return this.logsService.listLogFiles();
23+
}
24+
25+
@Query()
26+
@UsePermissions({
27+
action: AuthActionVerb.READ,
28+
resource: Resource.LOGS,
29+
possession: AuthPossession.ANY,
30+
})
31+
async logFile(@Args('path') path: string, @Args('lines') lines?: number) {
32+
return this.logsService.getLogFileContent(path, lines);
33+
}
34+
35+
@Subscription('logFile')
36+
@UsePermissions({
37+
action: AuthActionVerb.READ,
38+
resource: Resource.LOGS,
39+
possession: AuthPossession.ANY,
40+
})
41+
async logFileSubscription(@Args('path') path: string) {
42+
// Start watching the file
43+
this.logsService.getLogFileSubscriptionChannel(path);
44+
45+
// Create the async iterator
46+
const asyncIterator = createSubscription(PUBSUB_CHANNEL.LOG_FILE);
47+
48+
// Store the original return method to wrap it
49+
const originalReturn = asyncIterator.return;
50+
51+
// Override the return method to clean up resources
52+
asyncIterator.return = async () => {
53+
// Stop watching the file when subscription ends
54+
this.logsService.stopWatchingLogFile(path);
55+
56+
// Call the original return method
57+
return originalReturn ? originalReturn.call(asyncIterator) : Promise.resolve({ value: undefined, done: true });
58+
};
59+
60+
return asyncIterator;
61+
}
62+
}

0 commit comments

Comments
 (0)