mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
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 -->
This commit is contained in:
@@ -25,6 +25,7 @@ test('Returns paths', async () => {
|
||||
"keyfile-base",
|
||||
"machine-id",
|
||||
"log-base",
|
||||
"unraid-log-base",
|
||||
"var-run",
|
||||
"auth-sessions",
|
||||
"auth-keys",
|
||||
|
||||
@@ -18,6 +18,7 @@ export enum PUBSUB_CHANNEL {
|
||||
SERVERS = 'SERVERS',
|
||||
VMS = 'VMS',
|
||||
REGISTRATION = 'REGISTRATION',
|
||||
LOG_FILE = 'LOG_FILE',
|
||||
}
|
||||
|
||||
export const pubsub = new PubSub({ eventEmitter });
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as Types from '@app/graphql/generated/api/types.js';
|
||||
|
||||
import { z } from 'zod'
|
||||
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'
|
||||
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'
|
||||
import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||
|
||||
type Properties<T> = Required<{
|
||||
@@ -596,6 +596,25 @@ export function KeyFileSchema(): z.ZodObject<Properties<KeyFile>> {
|
||||
})
|
||||
}
|
||||
|
||||
export function LogFileSchema(): z.ZodObject<Properties<LogFile>> {
|
||||
return z.object({
|
||||
__typename: z.literal('LogFile').optional(),
|
||||
modifiedAt: z.string(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
size: z.number()
|
||||
})
|
||||
}
|
||||
|
||||
export function LogFileContentSchema(): z.ZodObject<Properties<LogFileContent>> {
|
||||
return z.object({
|
||||
__typename: z.literal('LogFileContent').optional(),
|
||||
content: z.string(),
|
||||
path: z.string(),
|
||||
totalLines: z.number()
|
||||
})
|
||||
}
|
||||
|
||||
export function MeSchema(): z.ZodObject<Properties<Me>> {
|
||||
return z.object({
|
||||
__typename: z.literal('Me').optional(),
|
||||
|
||||
@@ -97,7 +97,10 @@ export type ApiSettingsInput = {
|
||||
extraOrigins?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/** The type of port forwarding to use for Remote Access. */
|
||||
forwardType?: InputMaybe<WAN_FORWARD_TYPE>;
|
||||
/** The port to use for Remote Access. */
|
||||
/**
|
||||
* The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType.
|
||||
* Ignored if accessType is DISABLED or forwardType is UPNP.
|
||||
*/
|
||||
port?: InputMaybe<Scalars['Port']['input']>;
|
||||
/**
|
||||
* If true, the GraphQL sandbox will be enabled and available at /graphql.
|
||||
@@ -330,10 +333,18 @@ export type ConnectSettings = Node & {
|
||||
/** Intersection type of ApiSettings and RemoteAccess */
|
||||
export type ConnectSettingsValues = {
|
||||
__typename?: 'ConnectSettingsValues';
|
||||
/** The type of WAN access used for Remote Access. */
|
||||
accessType: WAN_ACCESS_TYPE;
|
||||
/** A list of origins allowed to interact with the API. */
|
||||
extraOrigins: Array<Scalars['String']['output']>;
|
||||
/** The type of port forwarding used for Remote Access. */
|
||||
forwardType?: Maybe<WAN_FORWARD_TYPE>;
|
||||
/** The port used for Remote Access. */
|
||||
port?: Maybe<Scalars['Port']['output']>;
|
||||
/**
|
||||
* If true, the GraphQL sandbox is enabled and available at /graphql.
|
||||
* If false, the GraphQL sandbox is disabled and only the production API will be available.
|
||||
*/
|
||||
sandbox: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
@@ -634,6 +645,30 @@ export type KeyFile = {
|
||||
location?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Represents a log file in the system */
|
||||
export type LogFile = {
|
||||
__typename?: 'LogFile';
|
||||
/** Last modified timestamp */
|
||||
modifiedAt: Scalars['DateTime']['output'];
|
||||
/** Name of the log file */
|
||||
name: Scalars['String']['output'];
|
||||
/** Full path to the log file */
|
||||
path: Scalars['String']['output'];
|
||||
/** Size of the log file in bytes */
|
||||
size: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
/** Content of a log file */
|
||||
export type LogFileContent = {
|
||||
__typename?: 'LogFileContent';
|
||||
/** Content of the log file */
|
||||
content: Scalars['String']['output'];
|
||||
/** Path to the log file */
|
||||
path: Scalars['String']['output'];
|
||||
/** Total number of lines in the file */
|
||||
totalLines: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
/** The current user */
|
||||
export type Me = UserAccount & {
|
||||
__typename?: 'Me';
|
||||
@@ -744,6 +779,10 @@ export type Mutation = {
|
||||
unmountArrayDisk?: Maybe<Disk>;
|
||||
/** Marks a notification as unread. */
|
||||
unreadNotification: Notification;
|
||||
/**
|
||||
* Update the API settings.
|
||||
* Some setting combinations may be required or disallowed. Please refer to each setting for more information.
|
||||
*/
|
||||
updateApiSettings: ConnectSettingsValues;
|
||||
};
|
||||
|
||||
@@ -1113,6 +1152,14 @@ export type Query = {
|
||||
extraAllowedOrigins: Array<Scalars['String']['output']>;
|
||||
flash?: Maybe<Flash>;
|
||||
info?: Maybe<Info>;
|
||||
/**
|
||||
* Get the content of a specific log file
|
||||
* @param path Path to the log file
|
||||
* @param lines Number of lines to read from the end of the file (default: 100)
|
||||
*/
|
||||
logFile: LogFileContent;
|
||||
/** List all available log files */
|
||||
logFiles: Array<LogFile>;
|
||||
/** Current user account */
|
||||
me?: Maybe<Me>;
|
||||
network?: Maybe<Network>;
|
||||
@@ -1163,6 +1210,12 @@ export type QuerydockerNetworksArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QuerylogFileArgs = {
|
||||
lines?: InputMaybe<Scalars['Int']['input']>;
|
||||
path: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type QueryuserArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -1353,6 +1406,11 @@ export type Subscription = {
|
||||
dockerNetworks: Array<Maybe<DockerNetwork>>;
|
||||
flash: Flash;
|
||||
info: Info;
|
||||
/**
|
||||
* Subscribe to changes in a log file
|
||||
* @param path Path to the log file
|
||||
*/
|
||||
logFile: LogFileContent;
|
||||
me?: Maybe<Me>;
|
||||
notificationAdded: Notification;
|
||||
notificationsOverview: NotificationOverview;
|
||||
@@ -1383,6 +1441,11 @@ export type SubscriptiondockerNetworkArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type SubscriptionlogFileArgs = {
|
||||
path: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type SubscriptionserviceArgs = {
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
@@ -1927,6 +1990,8 @@ export type ResolversTypes = ResolversObject<{
|
||||
Int: ResolverTypeWrapper<Scalars['Int']['output']>;
|
||||
JSON: ResolverTypeWrapper<Scalars['JSON']['output']>;
|
||||
KeyFile: ResolverTypeWrapper<KeyFile>;
|
||||
LogFile: ResolverTypeWrapper<LogFile>;
|
||||
LogFileContent: ResolverTypeWrapper<LogFileContent>;
|
||||
Long: ResolverTypeWrapper<Scalars['Long']['output']>;
|
||||
Me: ResolverTypeWrapper<Me>;
|
||||
MemoryFormFactor: MemoryFormFactor;
|
||||
@@ -2047,6 +2112,8 @@ export type ResolversParentTypes = ResolversObject<{
|
||||
Int: Scalars['Int']['output'];
|
||||
JSON: Scalars['JSON']['output'];
|
||||
KeyFile: KeyFile;
|
||||
LogFile: LogFile;
|
||||
LogFileContent: LogFileContent;
|
||||
Long: Scalars['Long']['output'];
|
||||
Me: Me;
|
||||
MemoryLayout: MemoryLayout;
|
||||
@@ -2481,6 +2548,21 @@ export type KeyFileResolvers<ContextType = Context, ParentType extends Resolvers
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type LogFileResolvers<ContextType = Context, ParentType extends ResolversParentTypes['LogFile'] = ResolversParentTypes['LogFile']> = ResolversObject<{
|
||||
modifiedAt?: Resolver<ResolversTypes['DateTime'], ParentType, ContextType>;
|
||||
name?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
path?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
size?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export type LogFileContentResolvers<ContextType = Context, ParentType extends ResolversParentTypes['LogFileContent'] = ResolversParentTypes['LogFileContent']> = ResolversObject<{
|
||||
content?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
path?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
|
||||
totalLines?: Resolver<ResolversTypes['Int'], ParentType, ContextType>;
|
||||
__isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
|
||||
}>;
|
||||
|
||||
export interface LongScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['Long'], any> {
|
||||
name: 'Long';
|
||||
}
|
||||
@@ -2763,6 +2845,8 @@ export type QueryResolvers<ContextType = Context, ParentType extends ResolversPa
|
||||
extraAllowedOrigins?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType>;
|
||||
flash?: Resolver<Maybe<ResolversTypes['Flash']>, ParentType, ContextType>;
|
||||
info?: Resolver<Maybe<ResolversTypes['Info']>, ParentType, ContextType>;
|
||||
logFile?: Resolver<ResolversTypes['LogFileContent'], ParentType, ContextType, RequireFields<QuerylogFileArgs, 'path'>>;
|
||||
logFiles?: Resolver<Array<ResolversTypes['LogFile']>, ParentType, ContextType>;
|
||||
me?: Resolver<Maybe<ResolversTypes['Me']>, ParentType, ContextType>;
|
||||
network?: Resolver<Maybe<ResolversTypes['Network']>, ParentType, ContextType>;
|
||||
notifications?: Resolver<ResolversTypes['Notifications'], ParentType, ContextType>;
|
||||
@@ -2857,6 +2941,7 @@ export type SubscriptionResolvers<ContextType = Context, ParentType extends Reso
|
||||
dockerNetworks?: SubscriptionResolver<Array<Maybe<ResolversTypes['DockerNetwork']>>, "dockerNetworks", ParentType, ContextType>;
|
||||
flash?: SubscriptionResolver<ResolversTypes['Flash'], "flash", ParentType, ContextType>;
|
||||
info?: SubscriptionResolver<ResolversTypes['Info'], "info", ParentType, ContextType>;
|
||||
logFile?: SubscriptionResolver<ResolversTypes['LogFileContent'], "logFile", ParentType, ContextType, RequireFields<SubscriptionlogFileArgs, 'path'>>;
|
||||
me?: SubscriptionResolver<Maybe<ResolversTypes['Me']>, "me", ParentType, ContextType>;
|
||||
notificationAdded?: SubscriptionResolver<ResolversTypes['Notification'], "notificationAdded", ParentType, ContextType>;
|
||||
notificationsOverview?: SubscriptionResolver<ResolversTypes['NotificationOverview'], "notificationsOverview", ParentType, ContextType>;
|
||||
@@ -3212,6 +3297,8 @@ export type Resolvers<ContextType = Context> = ResolversObject<{
|
||||
InfoMemory?: InfoMemoryResolvers<ContextType>;
|
||||
JSON?: GraphQLScalarType;
|
||||
KeyFile?: KeyFileResolvers<ContextType>;
|
||||
LogFile?: LogFileResolvers<ContextType>;
|
||||
LogFileContent?: LogFileContentResolvers<ContextType>;
|
||||
Long?: GraphQLScalarType;
|
||||
Me?: MeResolvers<ContextType>;
|
||||
MemoryLayout?: MemoryLayoutResolvers<ContextType>;
|
||||
|
||||
72
api/src/graphql/schema/types/logs/logs.graphql
Normal file
72
api/src/graphql/schema/types/logs/logs.graphql
Normal file
@@ -0,0 +1,72 @@
|
||||
type Query {
|
||||
"""
|
||||
List all available log files
|
||||
"""
|
||||
logFiles: [LogFile!]!
|
||||
|
||||
"""
|
||||
Get the content of a specific log file
|
||||
@param path Path to the log file
|
||||
@param lines Number of lines to read from the end of the file (default: 100)
|
||||
@param startLine Optional starting line number (1-indexed)
|
||||
"""
|
||||
logFile(path: String!, lines: Int, startLine: Int): LogFileContent!
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
"""
|
||||
Subscribe to changes in a log file
|
||||
@param path Path to the log file
|
||||
"""
|
||||
logFile(path: String!): LogFileContent!
|
||||
}
|
||||
|
||||
"""
|
||||
Represents a log file in the system
|
||||
"""
|
||||
type LogFile {
|
||||
"""
|
||||
Name of the log file
|
||||
"""
|
||||
name: String!
|
||||
|
||||
"""
|
||||
Full path to the log file
|
||||
"""
|
||||
path: String!
|
||||
|
||||
"""
|
||||
Size of the log file in bytes
|
||||
"""
|
||||
size: Int!
|
||||
|
||||
"""
|
||||
Last modified timestamp
|
||||
"""
|
||||
modifiedAt: DateTime!
|
||||
}
|
||||
|
||||
"""
|
||||
Content of a log file
|
||||
"""
|
||||
type LogFileContent {
|
||||
"""
|
||||
Path to the log file
|
||||
"""
|
||||
path: String!
|
||||
|
||||
"""
|
||||
Content of the log file
|
||||
"""
|
||||
content: String!
|
||||
|
||||
"""
|
||||
Total number of lines in the file
|
||||
"""
|
||||
totalLines: Int!
|
||||
|
||||
"""
|
||||
Starting line number of the content (1-indexed)
|
||||
"""
|
||||
startLine: Int
|
||||
}
|
||||
@@ -60,6 +60,7 @@ const initialState = {
|
||||
'keyfile-base': resolvePath(process.env.PATHS_KEYFILE_BASE ?? ('/boot/config' as const)),
|
||||
'machine-id': resolvePath(process.env.PATHS_MACHINE_ID ?? ('/var/lib/dbus/machine-id' as const)),
|
||||
'log-base': resolvePath('/var/log/unraid-api/' as const),
|
||||
'unraid-log-base': resolvePath('/var/log/' as const),
|
||||
'var-run': '/var/run' as const,
|
||||
// contains sess_ files that correspond to authenticated user sessions
|
||||
'auth-sessions': process.env.PATHS_AUTH_SESSIONS ?? '/var/lib/php',
|
||||
|
||||
10
api/src/unraid-api/graph/resolvers/logs/logs.module.ts
Normal file
10
api/src/unraid-api/graph/resolvers/logs/logs.module.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js';
|
||||
import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [LogsResolver, LogsService],
|
||||
exports: [LogsService],
|
||||
})
|
||||
export class LogsModule {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js';
|
||||
import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.js';
|
||||
|
||||
describe('LogsResolver', () => {
|
||||
let resolver: LogsResolver;
|
||||
let service: LogsService;
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LogsResolver,
|
||||
{
|
||||
provide: LogsService,
|
||||
useValue: {
|
||||
// Add mock implementations for service methods used by resolver
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
resolver = module.get<LogsResolver>(LogsResolver);
|
||||
service = module.get<LogsService>(LogsService);
|
||||
});
|
||||
it('should be defined', () => {
|
||||
expect(resolver).toBeDefined();
|
||||
});
|
||||
// Add more tests for resolver methods
|
||||
});
|
||||
66
api/src/unraid-api/graph/resolvers/logs/logs.resolver.ts
Normal file
66
api/src/unraid-api/graph/resolvers/logs/logs.resolver.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Args, Query, Resolver, Subscription } from '@nestjs/graphql';
|
||||
|
||||
import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';
|
||||
|
||||
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { Resource } from '@app/graphql/generated/api/types.js';
|
||||
import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.js';
|
||||
|
||||
@Resolver('Logs')
|
||||
export class LogsResolver {
|
||||
constructor(private readonly logsService: LogsService) {}
|
||||
|
||||
@Query()
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.LOGS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async logFiles() {
|
||||
return this.logsService.listLogFiles();
|
||||
}
|
||||
|
||||
@Query()
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.LOGS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async logFile(
|
||||
@Args('path') path: string,
|
||||
@Args('lines') lines?: number,
|
||||
@Args('startLine') startLine?: number
|
||||
) {
|
||||
return this.logsService.getLogFileContent(path, lines, startLine);
|
||||
}
|
||||
|
||||
@Subscription('logFile')
|
||||
@UsePermissions({
|
||||
action: AuthActionVerb.READ,
|
||||
resource: Resource.LOGS,
|
||||
possession: AuthPossession.ANY,
|
||||
})
|
||||
async logFileSubscription(@Args('path') path: string) {
|
||||
// Start watching the file
|
||||
this.logsService.getLogFileSubscriptionChannel(path);
|
||||
|
||||
// Create the async iterator
|
||||
const asyncIterator = createSubscription(PUBSUB_CHANNEL.LOG_FILE);
|
||||
|
||||
// Store the original return method to wrap it
|
||||
const originalReturn = asyncIterator.return;
|
||||
|
||||
// Override the return method to clean up resources
|
||||
asyncIterator.return = async () => {
|
||||
// Stop watching the file when subscription ends
|
||||
this.logsService.stopWatchingLogFile(path);
|
||||
|
||||
// Call the original return method
|
||||
return originalReturn
|
||||
? originalReturn.call(asyncIterator)
|
||||
: Promise.resolve({ value: undefined, done: true });
|
||||
};
|
||||
|
||||
return asyncIterator;
|
||||
}
|
||||
}
|
||||
355
api/src/unraid-api/graph/resolvers/logs/logs.service.ts
Normal file
355
api/src/unraid-api/graph/resolvers/logs/logs.service.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { readdir, readFile, stat } from 'node:fs/promises';
|
||||
import { basename, join } from 'node:path';
|
||||
import { createInterface } from 'node:readline';
|
||||
|
||||
import * as chokidar from 'chokidar';
|
||||
|
||||
import { pubsub, PUBSUB_CHANNEL } from '@app/core/pubsub.js';
|
||||
import { getters } from '@app/store/index.js';
|
||||
|
||||
interface LogFile {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modifiedAt: Date;
|
||||
}
|
||||
|
||||
interface LogFileContent {
|
||||
path: string;
|
||||
content: string;
|
||||
totalLines: number;
|
||||
startLine?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LogsService {
|
||||
private readonly logger = new Logger(LogsService.name);
|
||||
private readonly logWatchers = new Map<
|
||||
string,
|
||||
{ watcher: chokidar.FSWatcher; position: number; subscriptionCount: number }
|
||||
>();
|
||||
private readonly DEFAULT_LINES = 100;
|
||||
|
||||
/**
|
||||
* Get the base path for log files
|
||||
*/
|
||||
private get logBasePath(): string {
|
||||
return getters.paths()['unraid-log-base'];
|
||||
}
|
||||
|
||||
/**
|
||||
* List all log files in the log directory
|
||||
*/
|
||||
async listLogFiles(): Promise<LogFile[]> {
|
||||
try {
|
||||
const files = await readdir(this.logBasePath);
|
||||
|
||||
const logFiles: LogFile[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(this.logBasePath, file);
|
||||
const fileStat = await stat(filePath);
|
||||
if (fileStat.isFile()) {
|
||||
logFiles.push({
|
||||
name: file,
|
||||
path: filePath,
|
||||
size: fileStat.size,
|
||||
modifiedAt: fileStat.mtime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return logFiles;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error listing log files: ${error}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the content of a log file
|
||||
* @param path Path to the log file
|
||||
* @param lines Number of lines to read from the end of the file (default: 100)
|
||||
* @param startLine Optional starting line number (1-indexed)
|
||||
*/
|
||||
async getLogFileContent(
|
||||
path: string,
|
||||
lines = this.DEFAULT_LINES,
|
||||
startLine?: number
|
||||
): Promise<LogFileContent> {
|
||||
try {
|
||||
// Validate that the path is within the log directory
|
||||
const normalizedPath = join(this.logBasePath, basename(path));
|
||||
|
||||
// Count total lines
|
||||
const totalLines = await this.countFileLines(normalizedPath);
|
||||
|
||||
let content: string;
|
||||
|
||||
if (startLine !== undefined) {
|
||||
// Read from specific starting line
|
||||
content = await this.readLinesFromPosition(normalizedPath, startLine, lines);
|
||||
} else {
|
||||
// Read the last N lines (default behavior)
|
||||
content = await this.readLastLines(normalizedPath, lines);
|
||||
}
|
||||
|
||||
return {
|
||||
path: normalizedPath,
|
||||
content,
|
||||
totalLines,
|
||||
startLine: startLine !== undefined ? startLine : Math.max(1, totalLines - lines + 1),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Error reading log file: ${error}`);
|
||||
throw new Error(
|
||||
`Failed to read log file: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the subscription channel for a log file
|
||||
* @param path Path to the log file
|
||||
*/
|
||||
getLogFileSubscriptionChannel(path: string): PUBSUB_CHANNEL {
|
||||
const normalizedPath = join(this.logBasePath, basename(path));
|
||||
|
||||
// Start watching the file if not already watching
|
||||
if (!this.logWatchers.has(normalizedPath)) {
|
||||
this.startWatchingLogFile(normalizedPath);
|
||||
} else {
|
||||
// Increment subscription count for existing watcher
|
||||
const watcher = this.logWatchers.get(normalizedPath);
|
||||
if (watcher) {
|
||||
watcher.subscriptionCount++;
|
||||
this.logger.debug(
|
||||
`Incremented subscription count for ${normalizedPath} to ${watcher.subscriptionCount}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return PUBSUB_CHANNEL.LOG_FILE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watching a log file for changes using chokidar
|
||||
* @param path Path to the log file
|
||||
*/
|
||||
private async startWatchingLogFile(path: string): Promise<void> {
|
||||
try {
|
||||
// Get initial file size
|
||||
const stats = await stat(path);
|
||||
let position = stats.size;
|
||||
|
||||
// Create a watcher for the file using chokidar
|
||||
const watcher = chokidar.watch(path, {
|
||||
persistent: true,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 300,
|
||||
pollInterval: 100,
|
||||
},
|
||||
});
|
||||
|
||||
watcher.on('change', async () => {
|
||||
try {
|
||||
const newStats = await stat(path);
|
||||
|
||||
// If the file has grown
|
||||
if (newStats.size > position) {
|
||||
// Read only the new content
|
||||
const stream = createReadStream(path, {
|
||||
start: position,
|
||||
end: newStats.size - 1,
|
||||
});
|
||||
|
||||
let newContent = '';
|
||||
stream.on('data', (chunk) => {
|
||||
newContent += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on('end', () => {
|
||||
if (newContent) {
|
||||
pubsub.publish(PUBSUB_CHANNEL.LOG_FILE, {
|
||||
logFile: {
|
||||
path,
|
||||
content: newContent,
|
||||
totalLines: 0, // We don't need to count lines for updates
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Update position for next read
|
||||
position = newStats.size;
|
||||
});
|
||||
} else if (newStats.size < position) {
|
||||
// File was truncated, reset position and read from beginning
|
||||
position = 0;
|
||||
this.logger.debug(`File ${path} was truncated, resetting position`);
|
||||
|
||||
// Read the entire file content
|
||||
const content = await this.getLogFileContent(path);
|
||||
|
||||
pubsub.publish(PUBSUB_CHANNEL.LOG_FILE, {
|
||||
logFile: content,
|
||||
});
|
||||
|
||||
position = newStats.size;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Error processing file change for ${path}: ${error}`);
|
||||
}
|
||||
});
|
||||
|
||||
watcher.on('error', (error) => {
|
||||
this.logger.error(`Chokidar watcher error for ${path}: ${error}`);
|
||||
});
|
||||
|
||||
// Store the watcher and current position with initial subscription count of 1
|
||||
this.logWatchers.set(path, { watcher, position, subscriptionCount: 1 });
|
||||
|
||||
this.logger.debug(
|
||||
`Started watching log file with chokidar: ${path} (subscription count: 1)`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error(`Error setting up chokidar file watcher for ${path}: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching a log file
|
||||
* @param path Path to the log file
|
||||
*/
|
||||
public stopWatchingLogFile(path: string): void {
|
||||
const normalizedPath = join(this.logBasePath, basename(path));
|
||||
const watcher = this.logWatchers.get(normalizedPath);
|
||||
|
||||
if (watcher) {
|
||||
// Decrement subscription count
|
||||
watcher.subscriptionCount--;
|
||||
this.logger.debug(
|
||||
`Decremented subscription count for ${normalizedPath} to ${watcher.subscriptionCount}`
|
||||
);
|
||||
|
||||
// Only close the watcher when subscription count reaches 0
|
||||
if (watcher.subscriptionCount <= 0) {
|
||||
watcher.watcher.close();
|
||||
this.logWatchers.delete(normalizedPath);
|
||||
this.logger.debug(`Stopped watching log file: ${normalizedPath} (no more subscribers)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of lines in a file
|
||||
* @param filePath Path to the file
|
||||
*/
|
||||
private async countFileLines(filePath: string): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let lineCount = 0;
|
||||
const stream = createReadStream(filePath);
|
||||
const rl = createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
rl.on('line', () => {
|
||||
lineCount++;
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
resolve(lineCount);
|
||||
});
|
||||
|
||||
rl.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the last N lines of a file
|
||||
* @param filePath Path to the file
|
||||
* @param lineCount Number of lines to read
|
||||
*/
|
||||
private async readLastLines(filePath: string, lineCount: number): Promise<string> {
|
||||
const totalLines = await this.countFileLines(filePath);
|
||||
const linesToSkip = Math.max(0, totalLines - lineCount);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let currentLine = 0;
|
||||
let content = '';
|
||||
|
||||
const stream = createReadStream(filePath);
|
||||
const rl = createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
rl.on('line', (line) => {
|
||||
currentLine++;
|
||||
if (currentLine > linesToSkip) {
|
||||
content += line + '\n';
|
||||
}
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
resolve(content);
|
||||
});
|
||||
|
||||
rl.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read lines from a specific position in the file
|
||||
* @param filePath Path to the file
|
||||
* @param startLine Starting line number (1-indexed)
|
||||
* @param lineCount Number of lines to read
|
||||
*/
|
||||
private async readLinesFromPosition(
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
lineCount: number
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let currentLine = 0;
|
||||
let content = '';
|
||||
let linesRead = 0;
|
||||
|
||||
const stream = createReadStream(filePath);
|
||||
const rl = createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
rl.on('line', (line) => {
|
||||
currentLine++;
|
||||
|
||||
// Skip lines before the starting position
|
||||
if (currentLine >= startLine) {
|
||||
// Only read the requested number of lines
|
||||
if (linesRead < lineCount) {
|
||||
content += line + '\n';
|
||||
linesRead++;
|
||||
} else {
|
||||
// We've read enough lines, close the stream
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
resolve(content);
|
||||
});
|
||||
|
||||
rl.on('error', (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { DisplayResolver } from '@app/unraid-api/graph/resolvers/display/display
|
||||
import { DockerResolver } from '@app/unraid-api/graph/resolvers/docker/docker.resolver.js';
|
||||
import { FlashResolver } from '@app/unraid-api/graph/resolvers/flash/flash.resolver.js';
|
||||
import { InfoResolver } from '@app/unraid-api/graph/resolvers/info/info.resolver.js';
|
||||
import { LogsResolver } from '@app/unraid-api/graph/resolvers/logs/logs.resolver.js';
|
||||
import { LogsService } from '@app/unraid-api/graph/resolvers/logs/logs.service.js';
|
||||
import { MeResolver } from '@app/unraid-api/graph/resolvers/me/me.resolver.js';
|
||||
import { NotificationsResolver } from '@app/unraid-api/graph/resolvers/notifications/notifications.resolver.js';
|
||||
import { NotificationsService } from '@app/unraid-api/graph/resolvers/notifications/notifications.service.js';
|
||||
@@ -43,6 +45,8 @@ import { VmsResolver } from '@app/unraid-api/graph/resolvers/vms/vms.resolver.js
|
||||
NotificationsService,
|
||||
MeResolver,
|
||||
ConnectSettingsService,
|
||||
LogsResolver,
|
||||
LogsService,
|
||||
],
|
||||
exports: [AuthModule, ApiKeyResolver],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
Menu="UNRAID-OS"
|
||||
Title="Log Viewer (new)"
|
||||
Icon="icon-log"
|
||||
Tag="list"
|
||||
---
|
||||
<?php
|
||||
/* Copyright 2005-2023, Lime Technology
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU General Public License version 2,
|
||||
* as published by the Free Software Foundation.
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*/
|
||||
?>
|
||||
<unraid-i18n-host>
|
||||
<unraid-log-viewer></unraid-log-viewer>
|
||||
</unraid-i18n-host>
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -732,6 +732,9 @@ importers:
|
||||
hex-to-rgba:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
isomorphic-dompurify:
|
||||
specifier: ^2.19.0
|
||||
version: 2.22.0
|
||||
@@ -3846,7 +3849,6 @@ packages:
|
||||
'@unraid/libvirt@1.1.3':
|
||||
resolution: {integrity: sha512-aZNHkwgQ/0e+5BE7i3Ru4GC3Ev8fEUlnU0wmTcuSbpN0r74rMpiGwzA/4cqIJU8X+Kj//I80pkUufzXzHmMWwQ==}
|
||||
engines: {node: '>=14'}
|
||||
cpu: [x64, arm64]
|
||||
os: [linux, darwin]
|
||||
|
||||
'@unraid/tailwind-rem-to-rem@1.1.0':
|
||||
@@ -7024,6 +7026,10 @@ packages:
|
||||
hex-to-rgba@2.0.1:
|
||||
resolution: {integrity: sha512-5XqPJBpsEUMsseJUi2w2Hl7cHFFi3+OO10M2pzAvKB1zL6fc+koGMhmBqoDOCB4GemiRM/zvDMRIhVw6EkB8dQ==}
|
||||
|
||||
highlight.js@11.11.1:
|
||||
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
|
||||
|
||||
@@ -9382,7 +9388,6 @@ packages:
|
||||
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
|
||||
deprecated: |-
|
||||
You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.
|
||||
|
||||
(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)
|
||||
|
||||
qs@6.13.0:
|
||||
@@ -11209,8 +11214,8 @@ packages:
|
||||
vue-component-type-helpers@2.2.0:
|
||||
resolution: {integrity: sha512-cYrAnv2me7bPDcg9kIcGwjJiSB6Qyi08+jLDo9yuvoFQjzHiPTzML7RnkJB1+3P6KMsX/KbCD4QE3Tv/knEllw==}
|
||||
|
||||
vue-component-type-helpers@2.2.8:
|
||||
resolution: {integrity: sha512-4bjIsC284coDO9om4HPA62M7wfsTvcmZyzdfR0aUlFXqq4tXxM1APyXpNVxPC8QazKw9OhmZNHBVDA6ODaZsrA==}
|
||||
vue-component-type-helpers@3.0.0-alpha.2:
|
||||
resolution: {integrity: sha512-dv9YzsuJFLnpRNxKU0exwIlCIA/v+rXrgCsEtaENsFJLPFMw1Sr4IRctilwfjnjCzoJGgGACHRZfxo6ZwlH2fQ==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -14820,7 +14825,7 @@ snapshots:
|
||||
ts-dedent: 2.2.0
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.7.3)
|
||||
vue-component-type-helpers: 2.2.8
|
||||
vue-component-type-helpers: 3.0.0-alpha.2
|
||||
|
||||
'@stylistic/eslint-plugin@4.0.1(eslint@9.21.0(jiti@2.4.2))(typescript@5.7.3)':
|
||||
dependencies:
|
||||
@@ -19174,6 +19179,8 @@ snapshots:
|
||||
|
||||
hex-to-rgba@2.0.1: {}
|
||||
|
||||
highlight.js@11.11.1: {}
|
||||
|
||||
hoist-non-react-statics@3.3.2:
|
||||
dependencies:
|
||||
react-is: 16.13.1
|
||||
@@ -23901,7 +23908,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@2.2.0: {}
|
||||
|
||||
vue-component-type-helpers@2.2.8: {}
|
||||
vue-component-type-helpers@3.0.0-alpha.2: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.7.3)):
|
||||
dependencies:
|
||||
|
||||
204
web/components/Logs/LogViewer.ce.vue
Normal file
204
web/components/Logs/LogViewer.ce.vue
Normal file
@@ -0,0 +1,204 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useQuery } from '@vue/apollo-composable';
|
||||
|
||||
import {
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
useTeleport,
|
||||
} from '@unraid/ui';
|
||||
|
||||
import { GET_LOG_FILES } from './log.query';
|
||||
import SingleLogViewer from './SingleLogViewer.vue';
|
||||
|
||||
// Component state
|
||||
const selectedLogFile = ref<string>('');
|
||||
const lineCount = ref<number>(100);
|
||||
const autoScroll = ref<boolean>(true);
|
||||
const highlightLanguage = ref<string>('plaintext');
|
||||
|
||||
// Available highlight languages
|
||||
const highlightLanguages = [
|
||||
{ value: 'plaintext', label: 'Plain Text' },
|
||||
{ value: 'bash', label: 'Bash/Shell' },
|
||||
{ value: 'ini', label: 'INI/Config' },
|
||||
{ value: 'xml', label: 'XML/HTML' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'yaml', label: 'YAML' },
|
||||
{ value: 'nginx', label: 'Nginx' },
|
||||
{ value: 'apache', label: 'Apache' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'php', label: 'PHP' },
|
||||
];
|
||||
|
||||
// Fetch log files
|
||||
const {
|
||||
result: logFilesResult,
|
||||
loading: loadingLogFiles,
|
||||
error: logFilesError,
|
||||
} = useQuery(GET_LOG_FILES);
|
||||
|
||||
const logFiles = computed(() => {
|
||||
return logFilesResult.value?.logFiles || [];
|
||||
});
|
||||
|
||||
// Format file size for display
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Auto-detect language based on file extension
|
||||
const autoDetectLanguage = (filePath: string): string => {
|
||||
const fileName = filePath.split('/').pop() || '';
|
||||
|
||||
if (fileName.endsWith('.sh') || fileName.endsWith('.bash') || fileName.includes('syslog')) {
|
||||
return 'bash';
|
||||
} else if (fileName.endsWith('.conf') || fileName.endsWith('.ini') || fileName.endsWith('.cfg')) {
|
||||
return 'ini';
|
||||
} else if (fileName.endsWith('.xml') || fileName.endsWith('.html')) {
|
||||
return 'xml';
|
||||
} else if (fileName.endsWith('.json')) {
|
||||
return 'json';
|
||||
} else if (fileName.endsWith('.yml') || fileName.endsWith('.yaml')) {
|
||||
return 'yaml';
|
||||
} else if (fileName.includes('nginx')) {
|
||||
return 'nginx';
|
||||
} else if (fileName.includes('apache') || fileName.includes('httpd')) {
|
||||
return 'apache';
|
||||
} else if (fileName.endsWith('.js')) {
|
||||
return 'javascript';
|
||||
} else if (fileName.endsWith('.php')) {
|
||||
return 'php';
|
||||
}
|
||||
|
||||
return 'plaintext';
|
||||
};
|
||||
|
||||
// Watch for file selection changes to auto-detect language
|
||||
watch(selectedLogFile, (newValue) => {
|
||||
if (newValue) {
|
||||
highlightLanguage.value = autoDetectLanguage(newValue);
|
||||
}
|
||||
});
|
||||
|
||||
// Without this, the select dropdown will not be visible, unless it's already in a teleported context.
|
||||
const { teleportTarget, determineTeleportTarget } = useTeleport();
|
||||
const onSelectOpen = () => {
|
||||
determineTeleportTarget();
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-full min-h-[400px] bg-background text-foreground rounded-lg border border-border overflow-hidden"
|
||||
>
|
||||
<div class="p-4 border-b border-border">
|
||||
<h2 class="text-lg font-semibold mb-4">Log Viewer</h2>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<Label for="log-file-select">Log File</Label>
|
||||
<Select v-model="selectedLogFile" @update:open="onSelectOpen">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Select a log file" />
|
||||
</SelectTrigger>
|
||||
<SelectContent :to="teleportTarget">
|
||||
<SelectItem v-for="file in logFiles" :key="file.path" :value="file.path">
|
||||
{{ file.name }} ({{ formatFileSize(file.size) }})
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="line-count">Lines</Label>
|
||||
<Input
|
||||
id="line-count"
|
||||
v-model.number="lineCount"
|
||||
type="number"
|
||||
min="10"
|
||||
max="1000"
|
||||
class="w-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="highlight-language">Syntax</Label>
|
||||
<Select v-model="highlightLanguage" @update:open="onSelectOpen">
|
||||
<SelectTrigger id="highlight-language" class="w-full">
|
||||
<SelectValue placeholder="Select language" />
|
||||
</SelectTrigger>
|
||||
<SelectContent :to="teleportTarget">
|
||||
<SelectItem v-for="lang in highlightLanguages" :key="lang.value" :value="lang.value">
|
||||
{{ lang.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="auto-scroll">Auto-scroll</Label>
|
||||
<Switch id="auto-scroll" v-model:checked="autoScroll" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-hidden relative">
|
||||
<div
|
||||
v-if="loadingLogFiles"
|
||||
class="flex items-center justify-center h-full p-4 text-center text-muted-foreground"
|
||||
>
|
||||
Loading log files...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="logFilesError"
|
||||
class="flex items-center justify-center h-full p-4 text-center text-destructive"
|
||||
>
|
||||
Error loading log files: {{ logFilesError.message }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="logFiles.length === 0"
|
||||
class="flex items-center justify-center h-full p-4 text-center text-muted-foreground"
|
||||
>
|
||||
No log files found.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!selectedLogFile"
|
||||
class="flex items-center justify-center h-full p-4 text-center text-muted-foreground"
|
||||
>
|
||||
Please select a log file to view.
|
||||
</div>
|
||||
|
||||
<SingleLogViewer
|
||||
v-else
|
||||
:log-file-path="selectedLogFile"
|
||||
:line-count="lineCount"
|
||||
:auto-scroll="autoScroll"
|
||||
:highlight-language="highlightLanguage"
|
||||
class="h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="postcss">
|
||||
/* Import unraid-ui globals first */
|
||||
@import '@unraid/ui/styles';
|
||||
@import '~/assets/main.css';
|
||||
</style>
|
||||
590
web/components/Logs/SingleLogViewer.vue
Normal file
590
web/components/Logs/SingleLogViewer.vue
Normal file
@@ -0,0 +1,590 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
import { useQuery, useApolloClient } from '@vue/apollo-composable';
|
||||
import { vInfiniteScroll } from '@vueuse/components';
|
||||
import { ArrowPathIcon, ArrowDownTrayIcon } from '@heroicons/vue/24/outline';
|
||||
import { Button, Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from '@unraid/ui';
|
||||
import DOMPurify from 'isomorphic-dompurify';
|
||||
import hljs from 'highlight.js/lib/core';
|
||||
import 'highlight.js/styles/github-dark.css'; // You can choose a different style
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
|
||||
// Register the languages you want to support
|
||||
import plaintext from 'highlight.js/lib/languages/plaintext';
|
||||
import bash from 'highlight.js/lib/languages/bash';
|
||||
import ini from 'highlight.js/lib/languages/ini';
|
||||
import xml from 'highlight.js/lib/languages/xml';
|
||||
import json from 'highlight.js/lib/languages/json';
|
||||
import yaml from 'highlight.js/lib/languages/yaml';
|
||||
import nginx from 'highlight.js/lib/languages/nginx';
|
||||
import apache from 'highlight.js/lib/languages/apache';
|
||||
import javascript from 'highlight.js/lib/languages/javascript';
|
||||
import php from 'highlight.js/lib/languages/php';
|
||||
|
||||
import type { LogFileContentQuery, LogFileContentQueryVariables } from '~/composables/gql/graphql';
|
||||
import { GET_LOG_FILE_CONTENT } from './log.query';
|
||||
import { LOG_FILE_SUBSCRIPTION } from './log.subscription';
|
||||
|
||||
// Get theme information
|
||||
const themeStore = useThemeStore();
|
||||
const isDarkMode = computed(() => themeStore.darkMode);
|
||||
|
||||
// Register the languages
|
||||
hljs.registerLanguage('plaintext', plaintext);
|
||||
hljs.registerLanguage('bash', bash);
|
||||
hljs.registerLanguage('ini', ini);
|
||||
hljs.registerLanguage('xml', xml);
|
||||
hljs.registerLanguage('json', json);
|
||||
hljs.registerLanguage('yaml', yaml);
|
||||
hljs.registerLanguage('nginx', nginx);
|
||||
hljs.registerLanguage('apache', apache);
|
||||
hljs.registerLanguage('javascript', javascript);
|
||||
hljs.registerLanguage('php', php);
|
||||
|
||||
const props = defineProps<{
|
||||
logFilePath: string;
|
||||
lineCount: number;
|
||||
autoScroll: boolean;
|
||||
highlightLanguage?: string; // Optional prop to specify the language for highlighting
|
||||
}>();
|
||||
|
||||
// Default language for highlighting
|
||||
const defaultLanguage = 'plaintext';
|
||||
|
||||
const DEFAULT_CHUNK_SIZE = 100;
|
||||
const scrollViewportRef = ref<HTMLElement | null>(null);
|
||||
const state = reactive({
|
||||
loadedContentChunks: [] as { content: string; startLine: number }[],
|
||||
currentStartLine: undefined as number | undefined,
|
||||
isLoadingMore: false,
|
||||
isAtTop: false,
|
||||
canLoadMore: false,
|
||||
initialLoadComplete: false,
|
||||
isDownloading: false,
|
||||
isSubscriptionActive: false
|
||||
});
|
||||
|
||||
// Get Apollo client for direct queries
|
||||
const { client } = useApolloClient();
|
||||
|
||||
// Fetch log content
|
||||
const {
|
||||
result: logContentResult,
|
||||
loading: loadingLogContent,
|
||||
error: logContentError,
|
||||
refetch: refetchLogContent,
|
||||
subscribeToMore,
|
||||
} = useQuery<LogFileContentQuery, LogFileContentQueryVariables>(
|
||||
GET_LOG_FILE_CONTENT,
|
||||
() => ({
|
||||
path: props.logFilePath,
|
||||
lines: props.lineCount || DEFAULT_CHUNK_SIZE,
|
||||
startLine: state.currentStartLine,
|
||||
}),
|
||||
() => ({
|
||||
enabled: !!props.logFilePath,
|
||||
fetchPolicy: 'network-only',
|
||||
})
|
||||
);
|
||||
|
||||
// Force-scroll to bottom after DOM updates
|
||||
const forceScrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (scrollViewportRef.value) {
|
||||
scrollViewportRef.value.scrollTop = scrollViewportRef.value.scrollHeight;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// MutationObserver to detect changes in log content
|
||||
let observer: MutationObserver | null = null;
|
||||
onMounted(() => {
|
||||
if (scrollViewportRef.value) {
|
||||
observer = new MutationObserver(() => {
|
||||
if (props.autoScroll) {
|
||||
forceScrollToBottom();
|
||||
}
|
||||
});
|
||||
observer.observe(scrollViewportRef.value, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
if (props.logFilePath) {
|
||||
subscribeToMore({
|
||||
document: LOG_FILE_SUBSCRIPTION,
|
||||
variables: { path: props.logFilePath },
|
||||
updateQuery: (prev, { subscriptionData }) => {
|
||||
if (!subscriptionData.data || !prev) return prev;
|
||||
|
||||
// Set subscription as active when we receive data
|
||||
state.isSubscriptionActive = true;
|
||||
|
||||
const existingContent = prev.logFile?.content || '';
|
||||
const newContent = subscriptionData.data.logFile.content;
|
||||
|
||||
// Update the local state with the new content
|
||||
if (newContent && state.loadedContentChunks.length > 0) {
|
||||
const lastChunk = state.loadedContentChunks[state.loadedContentChunks.length - 1];
|
||||
lastChunk.content += newContent;
|
||||
|
||||
// Force scroll to bottom if auto-scroll is enabled
|
||||
if (props.autoScroll) {
|
||||
nextTick(() => forceScrollToBottom());
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
logFile: {
|
||||
...prev.logFile,
|
||||
content: existingContent + newContent,
|
||||
totalLines: (prev.logFile?.totalLines || 0) + (newContent.split('\n').length - 1),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Set subscription as active
|
||||
state.isSubscriptionActive = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup observer on unmount
|
||||
onUnmounted(() => {
|
||||
observer?.disconnect();
|
||||
});
|
||||
|
||||
// Handle log content updates
|
||||
watch(
|
||||
logContentResult,
|
||||
(newResult) => {
|
||||
if (!newResult?.logFile) return;
|
||||
|
||||
const { content, startLine } = newResult.logFile;
|
||||
const effectiveStartLine = startLine || 1;
|
||||
|
||||
if (state.isLoadingMore) {
|
||||
state.loadedContentChunks.unshift({ content, startLine: effectiveStartLine });
|
||||
state.isLoadingMore = false;
|
||||
|
||||
nextTick(() => state.canLoadMore = true);
|
||||
} else {
|
||||
state.loadedContentChunks = [{ content, startLine: effectiveStartLine }];
|
||||
|
||||
nextTick(() => {
|
||||
forceScrollToBottom();
|
||||
state.initialLoadComplete = true;
|
||||
setTimeout(() => (state.canLoadMore = true), 300);
|
||||
});
|
||||
}
|
||||
|
||||
state.isAtTop = effectiveStartLine === 1;
|
||||
if (state.isAtTop) {
|
||||
state.canLoadMore = false;
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// Function to highlight log content
|
||||
const highlightLog = (content: string): string => {
|
||||
try {
|
||||
// Determine which language to use for highlighting
|
||||
const language = props.highlightLanguage || defaultLanguage;
|
||||
|
||||
// Apply syntax highlighting
|
||||
let highlighted = hljs.highlight(content, { language }).value;
|
||||
|
||||
// Apply additional custom highlighting for common log patterns
|
||||
|
||||
// Highlight timestamps (various formats)
|
||||
highlighted = highlighted.replace(
|
||||
/\b(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\b/g,
|
||||
'<span class="hljs-timestamp">$1</span>'
|
||||
);
|
||||
|
||||
// Highlight IP addresses
|
||||
highlighted = highlighted.replace(
|
||||
/\b(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\b/g,
|
||||
'<span class="hljs-ip">$1</span>'
|
||||
);
|
||||
|
||||
// Split the content into lines
|
||||
let lines = highlighted.split('\n');
|
||||
|
||||
// Process each line to add error, warning, and success highlighting
|
||||
lines = lines.map(line => {
|
||||
if (/(error|exception|fail|failed|failure)/i.test(line)) {
|
||||
// Highlight error keywords
|
||||
line = line.replace(
|
||||
/\b(error|exception|fail|failed|failure)\b/gi,
|
||||
'<span class="hljs-error-keyword">$1</span>'
|
||||
);
|
||||
// Wrap the entire line
|
||||
return `<span class="hljs-error">${line}</span>`;
|
||||
} else if (/(warning|warn)/i.test(line)) {
|
||||
// Highlight warning keywords
|
||||
line = line.replace(
|
||||
/\b(warning|warn)\b/gi,
|
||||
'<span class="hljs-warning-keyword">$1</span>'
|
||||
);
|
||||
// Wrap the entire line
|
||||
return `<span class="hljs-warning">${line}</span>`;
|
||||
} else if (/(success|successful|completed|done)/i.test(line)) {
|
||||
// Highlight success keywords
|
||||
line = line.replace(
|
||||
/\b(success|successful|completed|done)\b/gi,
|
||||
'<span class="hljs-success-keyword">$1</span>'
|
||||
);
|
||||
// Wrap the entire line
|
||||
return `<span class="hljs-success">${line}</span>`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
|
||||
// Join the lines back together
|
||||
highlighted = lines.join('\n');
|
||||
|
||||
// Sanitize the highlighted HTML
|
||||
return DOMPurify.sanitize(highlighted);
|
||||
} catch (error) {
|
||||
console.error('Error highlighting log content:', error);
|
||||
// Fallback to sanitized but not highlighted content
|
||||
return DOMPurify.sanitize(content);
|
||||
}
|
||||
};
|
||||
|
||||
// Computed properties
|
||||
const logContent = computed(() => {
|
||||
const rawContent = state.loadedContentChunks.map(chunk => chunk.content).join('');
|
||||
return highlightLog(rawContent);
|
||||
});
|
||||
|
||||
const totalLines = computed(() => logContentResult.value?.logFile?.totalLines || 0);
|
||||
const shouldLoadMore = computed(() => state.canLoadMore && !state.isLoadingMore && !state.isAtTop);
|
||||
|
||||
// Load older log content
|
||||
const loadMoreContent = async () => {
|
||||
if (state.isLoadingMore || state.isAtTop || !state.canLoadMore) return;
|
||||
|
||||
state.isLoadingMore = true;
|
||||
state.canLoadMore = false;
|
||||
|
||||
const firstChunk = state.loadedContentChunks[0];
|
||||
if (firstChunk) {
|
||||
const newStartLine = Math.max(1, firstChunk.startLine - DEFAULT_CHUNK_SIZE);
|
||||
state.currentStartLine = newStartLine;
|
||||
|
||||
const prevScrollHeight = scrollViewportRef.value?.scrollHeight || 0;
|
||||
|
||||
await refetchLogContent();
|
||||
|
||||
nextTick(() => {
|
||||
if (scrollViewportRef.value) {
|
||||
scrollViewportRef.value.scrollTop += scrollViewportRef.value.scrollHeight - prevScrollHeight;
|
||||
}
|
||||
});
|
||||
|
||||
if (newStartLine === 1) {
|
||||
state.isAtTop = true;
|
||||
state.canLoadMore = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Download log file
|
||||
const downloadLogFile = async () => {
|
||||
if (!props.logFilePath || state.isDownloading) return;
|
||||
|
||||
try {
|
||||
state.isDownloading = true;
|
||||
|
||||
// Get the filename from the path
|
||||
const fileName = props.logFilePath.split('/').pop() || 'log.txt';
|
||||
|
||||
// Query for the entire log file content
|
||||
const result = await client.query({
|
||||
query: GET_LOG_FILE_CONTENT,
|
||||
variables: {
|
||||
path: props.logFilePath,
|
||||
// Don't specify lines or startLine to get the entire file
|
||||
},
|
||||
fetchPolicy: 'network-only',
|
||||
});
|
||||
|
||||
if (!result.data?.logFile?.content) {
|
||||
throw new Error('Failed to fetch log content');
|
||||
}
|
||||
|
||||
// Create a blob with the content
|
||||
const blob = new Blob([result.data.logFile.content], { type: 'text/plain' });
|
||||
|
||||
// Create a download link
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
|
||||
// Trigger the download
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading log file:', error);
|
||||
alert(`Error downloading log file: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
state.isDownloading = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh logs
|
||||
const refreshLogContent = () => {
|
||||
state.loadedContentChunks = [];
|
||||
state.currentStartLine = undefined;
|
||||
state.isAtTop = false;
|
||||
state.canLoadMore = false;
|
||||
state.initialLoadComplete = false;
|
||||
state.isLoadingMore = false;
|
||||
refetchLogContent();
|
||||
|
||||
nextTick(() => {
|
||||
forceScrollToBottom();
|
||||
});
|
||||
};
|
||||
|
||||
watch(() => props.logFilePath, refreshLogContent);
|
||||
defineExpose({ refreshLogContent });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full max-h-full overflow-hidden">
|
||||
<div class="flex justify-between px-4 py-2 bg-muted text-xs text-muted-foreground shrink-0 items-center">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Total lines: {{ totalLines }}</span>
|
||||
<TooltipProvider v-if="state.isSubscriptionActive">
|
||||
<Tooltip :delay-duration="300">
|
||||
<TooltipTrigger as-child>
|
||||
<div class="w-2 h-2 rounded-full bg-green-500 animate-pulse cursor-help" aria-hidden="true"></div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Watching log file</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<span>{{ state.isAtTop ? 'Showing all available lines' : 'Scroll up to load more' }}</span>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" :disabled="loadingLogContent || state.isDownloading" @click="downloadLogFile">
|
||||
<ArrowDownTrayIcon class="h-3 w-3 mr-1" :class="{ 'animate-pulse': state.isDownloading }" aria-hidden="true" />
|
||||
<span class="text-sm">{{ state.isDownloading ? 'Downloading...' : 'Download' }}</span>
|
||||
</Button>
|
||||
<Button variant="outline" :disabled="loadingLogContent" @click="refreshLogContent">
|
||||
<ArrowPathIcon class="h-3 w-3 mr-1" aria-hidden="true" />
|
||||
<span class="text-sm">Refresh</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingLogContent && !state.isLoadingMore" class="flex items-center justify-center flex-1 p-4 text-muted-foreground">
|
||||
Loading log content...
|
||||
</div>
|
||||
|
||||
<div v-else-if="logContentError" class="flex items-center justify-center flex-1 p-4 text-destructive">
|
||||
Error loading log content: {{ logContentError.message }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
ref="scrollViewportRef"
|
||||
v-infinite-scroll="[loadMoreContent, { direction: 'top', distance: 200, canLoadMore: () => shouldLoadMore }]"
|
||||
class="flex-1 overflow-y-auto max-h-[500px]"
|
||||
:class="{ 'theme-dark': isDarkMode, 'theme-light': !isDarkMode }"
|
||||
>
|
||||
<!-- Loading indicator for loading more content -->
|
||||
<div v-if="state.isLoadingMore" class="sticky top-0 z-10 bg-muted/80 backdrop-blur-sm border-b border-border rounded-md mx-2 mt-2">
|
||||
<div class="flex items-center justify-center p-2 text-xs text-primary-foreground">
|
||||
<ArrowPathIcon class="h-3 w-3 mr-2 animate-spin" aria-hidden="true" />
|
||||
Loading more lines...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
class="font-mono whitespace-pre-wrap p-4 m-0 text-xs leading-6 hljs"
|
||||
:class="{ 'theme-dark': isDarkMode, 'theme-light': !isDarkMode }"
|
||||
v-html="logContent"
|
||||
></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Define CSS variables for both light and dark themes */
|
||||
:root {
|
||||
/* Light theme colors (default) - adjusted for better readability */
|
||||
--log-background: transparent;
|
||||
--log-keyword-color: hsl(var(--destructive) / 0.9); /* Slightly dimmed */
|
||||
--log-string-color: hsl(var(--primary) / 0.7); /* Dimmed primary color */
|
||||
--log-comment-color: hsl(var(--muted-foreground));
|
||||
--log-number-color: hsl(var(--accent-foreground) / 0.8); /* Slightly dimmed */
|
||||
--log-timestamp-color: hsl(210, 90%, 40%); /* Darker blue for timestamps */
|
||||
--log-ip-color: hsl(32, 90%, 40%); /* Darker orange for IPs */
|
||||
--log-error-color: hsl(var(--destructive) / 0.9); /* Slightly dimmed */
|
||||
--log-warning-color: hsl(40, 90%, 40%); /* Darker yellow for warnings */
|
||||
--log-success-color: hsl(142, 70%, 35%); /* Darker green for success */
|
||||
--log-error-bg: hsl(var(--destructive) / 0.08); /* Lighter background */
|
||||
--log-warning-bg: hsl(40, 90%, 50% / 0.08); /* Lighter background */
|
||||
--log-success-bg: hsl(142, 70%, 40% / 0.08); /* Lighter background */
|
||||
}
|
||||
|
||||
/* Dark theme colors - use slightly different color combinations for better visibility */
|
||||
.theme-dark {
|
||||
--log-background: transparent;
|
||||
--log-keyword-color: hsl(var(--destructive) / 0.9);
|
||||
--log-string-color: hsl(var(--primary) / 0.9);
|
||||
--log-comment-color: hsl(var(--muted-foreground) / 0.9);
|
||||
--log-number-color: hsl(var(--accent-foreground) / 0.9);
|
||||
--log-timestamp-color: hsl(210, 100%, 66%); /* Brighter blue for timestamps in dark mode */
|
||||
--log-ip-color: hsl(32, 100%, 56%); /* Brighter orange for IPs in dark mode */
|
||||
--log-error-color: hsl(350, 100%, 66%); /* Brighter red for errors in dark mode */
|
||||
--log-warning-color: hsl(50, 100%, 60%); /* Brighter yellow for warnings in dark mode */
|
||||
--log-success-color: hsl(120, 100%, 45%); /* Brighter green for success in dark mode */
|
||||
--log-error-bg: hsl(350, 100%, 40% / 0.15);
|
||||
--log-warning-bg: hsl(50, 100%, 50% / 0.15);
|
||||
--log-success-bg: hsl(120, 100%, 40% / 0.15);
|
||||
}
|
||||
|
||||
/* Add some basic styling for the highlighted logs */
|
||||
.hljs {
|
||||
background: var(--log-background);
|
||||
}
|
||||
|
||||
/* Style for error messages */
|
||||
.hljs .hljs-keyword,
|
||||
.hljs .hljs-selector-tag,
|
||||
.hljs .hljs-literal,
|
||||
.hljs .hljs-section,
|
||||
.hljs .hljs-link {
|
||||
color: var(--log-keyword-color);
|
||||
}
|
||||
|
||||
/* Style for warnings */
|
||||
.hljs .hljs-string,
|
||||
.hljs .hljs-title,
|
||||
.hljs .hljs-name,
|
||||
.hljs .hljs-type,
|
||||
.hljs .hljs-attribute,
|
||||
.hljs .hljs-symbol,
|
||||
.hljs .hljs-bullet,
|
||||
.hljs .hljs-built_in,
|
||||
.hljs .hljs-addition,
|
||||
.hljs .hljs-variable,
|
||||
.hljs .hljs-template-tag,
|
||||
.hljs .hljs-template-variable {
|
||||
color: var(--log-string-color);
|
||||
}
|
||||
|
||||
/* Style for info messages */
|
||||
.hljs .hljs-comment,
|
||||
.hljs .hljs-quote,
|
||||
.hljs .hljs-deletion,
|
||||
.hljs .hljs-meta {
|
||||
color: var(--log-comment-color);
|
||||
}
|
||||
|
||||
/* Style for timestamps and IDs */
|
||||
.hljs .hljs-number,
|
||||
.hljs .hljs-regexp,
|
||||
.hljs .hljs-literal,
|
||||
.hljs .hljs-variable,
|
||||
.hljs .hljs-template-variable,
|
||||
.hljs .hljs-tag .hljs-attr,
|
||||
.hljs .hljs-tag .hljs-string,
|
||||
.hljs .hljs-attr,
|
||||
.hljs .hljs-string {
|
||||
color: var(--log-number-color);
|
||||
}
|
||||
|
||||
/* Style for success messages */
|
||||
.hljs .hljs-function .hljs-keyword,
|
||||
.hljs .hljs-class .hljs-keyword {
|
||||
color: var(--log-success-color);
|
||||
}
|
||||
|
||||
/* Custom log pattern styles */
|
||||
.hljs-timestamp {
|
||||
color: var(--log-timestamp-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.hljs-ip {
|
||||
color: var(--log-ip-color);
|
||||
}
|
||||
|
||||
/* Error line and keyword styling */
|
||||
.hljs-error {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
padding-left: 4px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.theme-light .hljs-error {
|
||||
background-color: hsl(var(--destructive) / 0.05);
|
||||
border-left: 2px solid hsl(var(--destructive) / 0.7);
|
||||
}
|
||||
|
||||
.theme-dark .hljs-error {
|
||||
background-color: var(--log-error-bg);
|
||||
}
|
||||
|
||||
.hljs-error-keyword {
|
||||
color: var(--log-error-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Warning line and keyword styling */
|
||||
.hljs-warning {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
padding-left: 4px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.theme-light .hljs-warning {
|
||||
background-color: hsl(40, 90%, 50% / 0.05);
|
||||
border-left: 2px solid hsl(40, 90%, 40% / 0.7);
|
||||
}
|
||||
|
||||
.theme-dark .hljs-warning {
|
||||
background-color: var(--log-warning-bg);
|
||||
}
|
||||
|
||||
.hljs-warning-keyword {
|
||||
color: var(--log-warning-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Success line and keyword styling */
|
||||
.hljs-success {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
padding-left: 4px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
||||
.theme-light .hljs-success {
|
||||
background-color: hsl(142, 70%, 40% / 0.05);
|
||||
border-left: 2px solid hsl(142, 70%, 35% / 0.7);
|
||||
}
|
||||
|
||||
.theme-dark .hljs-success {
|
||||
background-color: var(--log-success-bg);
|
||||
}
|
||||
|
||||
.hljs-success-keyword {
|
||||
color: var(--log-success-color);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
23
web/components/Logs/log.query.ts
Normal file
23
web/components/Logs/log.query.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { graphql } from '~/composables/gql/gql';
|
||||
|
||||
export const GET_LOG_FILES = graphql(/* GraphQL */ `
|
||||
query LogFiles {
|
||||
logFiles {
|
||||
name
|
||||
path
|
||||
size
|
||||
modifiedAt
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
export const GET_LOG_FILE_CONTENT = graphql(/* GraphQL */ `
|
||||
query LogFileContent($path: String!, $lines: Int, $startLine: Int) {
|
||||
logFile(path: $path, lines: $lines, startLine: $startLine) {
|
||||
path
|
||||
content
|
||||
totalLines
|
||||
startLine
|
||||
}
|
||||
}
|
||||
`);
|
||||
11
web/components/Logs/log.subscription.ts
Normal file
11
web/components/Logs/log.subscription.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { graphql } from '~/composables/gql/gql';
|
||||
|
||||
export const LOG_FILE_SUBSCRIPTION = graphql(/* GraphQL */ `
|
||||
subscription LogFileSubscription($path: String!) {
|
||||
logFile(path: $path) {
|
||||
path
|
||||
content
|
||||
totalLines
|
||||
}
|
||||
}
|
||||
`);
|
||||
@@ -16,6 +16,9 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
|
||||
type Documents = {
|
||||
"\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n }\n }\n": typeof types.GetConnectSettingsFormDocument,
|
||||
"\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n": typeof types.UpdateConnectSettingsDocument,
|
||||
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": typeof types.LogFilesDocument,
|
||||
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": typeof types.LogFileContentDocument,
|
||||
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": typeof types.LogFileSubscriptionDocument,
|
||||
"\n fragment NotificationFragment on Notification {\n id\n title\n subject\n description\n importance\n link\n type\n timestamp\n formattedTimestamp\n }\n": typeof types.NotificationFragmentFragmentDoc,
|
||||
"\n fragment NotificationCountFragment on NotificationCounts {\n total\n info\n warning\n alert\n }\n": typeof types.NotificationCountFragmentFragmentDoc,
|
||||
"\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n": typeof types.NotificationsDocument,
|
||||
@@ -39,6 +42,9 @@ type Documents = {
|
||||
const documents: Documents = {
|
||||
"\n query GetConnectSettingsForm {\n connect {\n id\n settings {\n id\n dataSchema\n uiSchema\n values {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n }\n }\n": types.GetConnectSettingsFormDocument,
|
||||
"\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n": types.UpdateConnectSettingsDocument,
|
||||
"\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n": types.LogFilesDocument,
|
||||
"\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n": types.LogFileContentDocument,
|
||||
"\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n": types.LogFileSubscriptionDocument,
|
||||
"\n fragment NotificationFragment on Notification {\n id\n title\n subject\n description\n importance\n link\n type\n timestamp\n formattedTimestamp\n }\n": types.NotificationFragmentFragmentDoc,
|
||||
"\n fragment NotificationCountFragment on NotificationCounts {\n total\n info\n warning\n alert\n }\n": types.NotificationCountFragmentFragmentDoc,
|
||||
"\n query Notifications($filter: NotificationFilter!) {\n notifications {\n id\n list(filter: $filter) {\n ...NotificationFragment\n }\n }\n }\n": types.NotificationsDocument,
|
||||
@@ -82,6 +88,18 @@ export function graphql(source: "\n query GetConnectSettingsForm {\n connect
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n"): (typeof documents)["\n mutation UpdateConnectSettings($input: ApiSettingsInput!) {\n updateApiSettings(input: $input) {\n sandbox\n extraOrigins\n accessType\n forwardType\n port\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n"): (typeof documents)["\n query LogFiles {\n logFiles {\n name\n path\n size\n modifiedAt\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n"): (typeof documents)["\n query LogFileContent($path: String!, $lines: Int, $startLine: Int) {\n logFile(path: $path, lines: $lines, startLine: $startLine) {\n path\n content\n totalLines\n startLine\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(source: "\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n"): (typeof documents)["\n subscription LogFileSubscription($path: String!) {\n logFile(path: $path) {\n path\n content\n totalLines\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
|
||||
@@ -100,7 +100,10 @@ export type ApiSettingsInput = {
|
||||
extraOrigins?: InputMaybe<Array<Scalars['String']['input']>>;
|
||||
/** The type of port forwarding to use for Remote Access. */
|
||||
forwardType?: InputMaybe<WAN_FORWARD_TYPE>;
|
||||
/** The port to use for Remote Access. */
|
||||
/**
|
||||
* The port to use for Remote Access. Not required for UPNP forwardType. Required for STATIC forwardType.
|
||||
* Ignored if accessType is DISABLED or forwardType is UPNP.
|
||||
*/
|
||||
port?: InputMaybe<Scalars['Port']['input']>;
|
||||
/**
|
||||
* If true, the GraphQL sandbox will be enabled and available at /graphql.
|
||||
@@ -333,10 +336,18 @@ export type ConnectSettings = Node & {
|
||||
/** Intersection type of ApiSettings and RemoteAccess */
|
||||
export type ConnectSettingsValues = {
|
||||
__typename?: 'ConnectSettingsValues';
|
||||
/** The type of WAN access used for Remote Access. */
|
||||
accessType: WAN_ACCESS_TYPE;
|
||||
/** A list of origins allowed to interact with the API. */
|
||||
extraOrigins: Array<Scalars['String']['output']>;
|
||||
/** The type of port forwarding used for Remote Access. */
|
||||
forwardType?: Maybe<WAN_FORWARD_TYPE>;
|
||||
/** The port used for Remote Access. */
|
||||
port?: Maybe<Scalars['Port']['output']>;
|
||||
/**
|
||||
* If true, the GraphQL sandbox is enabled and available at /graphql.
|
||||
* If false, the GraphQL sandbox is disabled and only the production API will be available.
|
||||
*/
|
||||
sandbox: Scalars['Boolean']['output'];
|
||||
};
|
||||
|
||||
@@ -637,6 +648,32 @@ export type KeyFile = {
|
||||
location?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
/** Represents a log file in the system */
|
||||
export type LogFile = {
|
||||
__typename?: 'LogFile';
|
||||
/** Last modified timestamp */
|
||||
modifiedAt: Scalars['DateTime']['output'];
|
||||
/** Name of the log file */
|
||||
name: Scalars['String']['output'];
|
||||
/** Full path to the log file */
|
||||
path: Scalars['String']['output'];
|
||||
/** Size of the log file in bytes */
|
||||
size: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
/** Content of a log file */
|
||||
export type LogFileContent = {
|
||||
__typename?: 'LogFileContent';
|
||||
/** Content of the log file */
|
||||
content: Scalars['String']['output'];
|
||||
/** Path to the log file */
|
||||
path: Scalars['String']['output'];
|
||||
/** Starting line number of the content (1-indexed) */
|
||||
startLine?: Maybe<Scalars['Int']['output']>;
|
||||
/** Total number of lines in the file */
|
||||
totalLines: Scalars['Int']['output'];
|
||||
};
|
||||
|
||||
/** The current user */
|
||||
export type Me = UserAccount & {
|
||||
__typename?: 'Me';
|
||||
@@ -747,6 +784,10 @@ export type Mutation = {
|
||||
unmountArrayDisk?: Maybe<Disk>;
|
||||
/** Marks a notification as unread. */
|
||||
unreadNotification: Notification;
|
||||
/**
|
||||
* Update the API settings.
|
||||
* Some setting combinations may be required or disallowed. Please refer to each setting for more information.
|
||||
*/
|
||||
updateApiSettings: ConnectSettingsValues;
|
||||
};
|
||||
|
||||
@@ -1116,6 +1157,15 @@ export type Query = {
|
||||
extraAllowedOrigins: Array<Scalars['String']['output']>;
|
||||
flash?: Maybe<Flash>;
|
||||
info?: Maybe<Info>;
|
||||
/**
|
||||
* Get the content of a specific log file
|
||||
* @param path Path to the log file
|
||||
* @param lines Number of lines to read from the end of the file (default: 100)
|
||||
* @param startLine Optional starting line number (1-indexed)
|
||||
*/
|
||||
logFile: LogFileContent;
|
||||
/** List all available log files */
|
||||
logFiles: Array<LogFile>;
|
||||
/** Current user account */
|
||||
me?: Maybe<Me>;
|
||||
network?: Maybe<Network>;
|
||||
@@ -1166,6 +1216,13 @@ export type QuerydockerNetworksArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type QuerylogFileArgs = {
|
||||
lines?: InputMaybe<Scalars['Int']['input']>;
|
||||
path: Scalars['String']['input'];
|
||||
startLine?: InputMaybe<Scalars['Int']['input']>;
|
||||
};
|
||||
|
||||
|
||||
export type QueryuserArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -1356,6 +1413,11 @@ export type Subscription = {
|
||||
dockerNetworks: Array<Maybe<DockerNetwork>>;
|
||||
flash: Flash;
|
||||
info: Info;
|
||||
/**
|
||||
* Subscribe to changes in a log file
|
||||
* @param path Path to the log file
|
||||
*/
|
||||
logFile: LogFileContent;
|
||||
me?: Maybe<Me>;
|
||||
notificationAdded: Notification;
|
||||
notificationsOverview: NotificationOverview;
|
||||
@@ -1386,6 +1448,11 @@ export type SubscriptiondockerNetworkArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type SubscriptionlogFileArgs = {
|
||||
path: Scalars['String']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type SubscriptionserviceArgs = {
|
||||
name: Scalars['String']['input'];
|
||||
};
|
||||
@@ -1802,6 +1869,27 @@ export type UpdateConnectSettingsMutationVariables = Exact<{
|
||||
|
||||
export type UpdateConnectSettingsMutation = { __typename?: 'Mutation', updateApiSettings: { __typename?: 'ConnectSettingsValues', sandbox: boolean, extraOrigins: Array<string>, accessType: WAN_ACCESS_TYPE, forwardType?: WAN_FORWARD_TYPE | null, port?: number | null } };
|
||||
|
||||
export type LogFilesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
|
||||
export type LogFilesQuery = { __typename?: 'Query', logFiles: Array<{ __typename?: 'LogFile', name: string, path: string, size: number, modifiedAt: string }> };
|
||||
|
||||
export type LogFileContentQueryVariables = Exact<{
|
||||
path: Scalars['String']['input'];
|
||||
lines?: InputMaybe<Scalars['Int']['input']>;
|
||||
startLine?: InputMaybe<Scalars['Int']['input']>;
|
||||
}>;
|
||||
|
||||
|
||||
export type LogFileContentQuery = { __typename?: 'Query', logFile: { __typename?: 'LogFileContent', path: string, content: string, totalLines: number, startLine?: number | null } };
|
||||
|
||||
export type LogFileSubscriptionSubscriptionVariables = Exact<{
|
||||
path: Scalars['String']['input'];
|
||||
}>;
|
||||
|
||||
|
||||
export type LogFileSubscriptionSubscription = { __typename?: 'Subscription', logFile: { __typename?: 'LogFileContent', path: string, content: string, totalLines: number } };
|
||||
|
||||
export type NotificationFragmentFragment = { __typename?: 'Notification', id: string, title: string, subject: string, description: string, importance: Importance, link?: string | null, type: NotificationType, timestamp?: string | null, formattedTimestamp?: string | null } & { ' $fragmentName'?: 'NotificationFragmentFragment' };
|
||||
|
||||
export type NotificationCountFragmentFragment = { __typename?: 'NotificationCounts', total: number, info: number, warning: number, alert: number } & { ' $fragmentName'?: 'NotificationCountFragmentFragment' };
|
||||
@@ -1930,6 +2018,9 @@ export const NotificationCountFragmentFragmentDoc = {"kind":"Document","definiti
|
||||
export const PartialCloudFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"PartialCloud"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Cloud"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"valid"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"cloud"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"minigraphql"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relay"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode<PartialCloudFragment, unknown>;
|
||||
export const GetConnectSettingsFormDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetConnectSettingsForm"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"connect"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"settings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSchema"}},{"kind":"Field","name":{"kind":"Name","value":"uiSchema"}},{"kind":"Field","name":{"kind":"Name","value":"values"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sandbox"}},{"kind":"Field","name":{"kind":"Name","value":"extraOrigins"}},{"kind":"Field","name":{"kind":"Name","value":"accessType"}},{"kind":"Field","name":{"kind":"Name","value":"forwardType"}},{"kind":"Field","name":{"kind":"Name","value":"port"}}]}}]}}]}}]}}]} as unknown as DocumentNode<GetConnectSettingsFormQuery, GetConnectSettingsFormQueryVariables>;
|
||||
export const UpdateConnectSettingsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateConnectSettings"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ApiSettingsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateApiSettings"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"sandbox"}},{"kind":"Field","name":{"kind":"Name","value":"extraOrigins"}},{"kind":"Field","name":{"kind":"Name","value":"accessType"}},{"kind":"Field","name":{"kind":"Name","value":"forwardType"}},{"kind":"Field","name":{"kind":"Name","value":"port"}}]}}]}}]} as unknown as DocumentNode<UpdateConnectSettingsMutation, UpdateConnectSettingsMutationVariables>;
|
||||
export const LogFilesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFiles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"size"}},{"kind":"Field","name":{"kind":"Name","value":"modifiedAt"}}]}}]}}]} as unknown as DocumentNode<LogFilesQuery, LogFilesQueryVariables>;
|
||||
export const LogFileContentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"LogFileContent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"lines"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}},{"kind":"Argument","name":{"kind":"Name","value":"lines"},"value":{"kind":"Variable","name":{"kind":"Name","value":"lines"}}},{"kind":"Argument","name":{"kind":"Name","value":"startLine"},"value":{"kind":"Variable","name":{"kind":"Name","value":"startLine"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}},{"kind":"Field","name":{"kind":"Name","value":"startLine"}}]}}]}}]} as unknown as DocumentNode<LogFileContentQuery, LogFileContentQueryVariables>;
|
||||
export const LogFileSubscriptionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"subscription","name":{"kind":"Name","value":"LogFileSubscription"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"path"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"logFile"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"path"},"value":{"kind":"Variable","name":{"kind":"Name","value":"path"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"path"}},{"kind":"Field","name":{"kind":"Name","value":"content"}},{"kind":"Field","name":{"kind":"Name","value":"totalLines"}}]}}]}}]} as unknown as DocumentNode<LogFileSubscriptionSubscription, LogFileSubscriptionSubscriptionVariables>;
|
||||
export const NotificationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Notifications"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"filter"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NotificationFilter"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"notifications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"list"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"filter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationFragment"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode<NotificationsQuery, NotificationsQueryVariables>;
|
||||
export const ArchiveNotificationDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ArchiveNotification"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archiveNotification"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"NotificationFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"NotificationFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Notification"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"subject"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"importance"}},{"kind":"Field","name":{"kind":"Name","value":"link"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"timestamp"}},{"kind":"Field","name":{"kind":"Name","value":"formattedTimestamp"}}]}}]} as unknown as DocumentNode<ArchiveNotificationMutation, ArchiveNotificationMutationVariables>;
|
||||
export const ArchiveAllNotificationsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ArchiveAllNotifications"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"archiveAll"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"unread"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"total"}}]}},{"kind":"Field","name":{"kind":"Name","value":"archive"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"info"}},{"kind":"Field","name":{"kind":"Name","value":"warning"}},{"kind":"Field","name":{"kind":"Name","value":"alert"}},{"kind":"Field","name":{"kind":"Name","value":"total"}}]}}]}}]}}]} as unknown as DocumentNode<ArchiveAllNotificationsMutation, ArchiveAllNotificationsMutationVariables>;
|
||||
|
||||
@@ -128,6 +128,10 @@ export default defineNuxtConfig({
|
||||
name: 'UnraidSsoButton',
|
||||
path: '@/components/SsoButton.ce',
|
||||
},
|
||||
{
|
||||
name: 'UnraidLogViewer',
|
||||
path: '@/components/Logs/LogViewer.ce',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"hex-to-rgba": "^2.0.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"isomorphic-dompurify": "^2.19.0",
|
||||
"lucide-vue-next": "^0.475.0",
|
||||
"marked": "^12.0.2",
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { SendPayloads } from '~/store/callback';
|
||||
|
||||
import SsoButtonCe from '~/components/SsoButton.ce.vue';
|
||||
import { useThemeStore } from '~/store/theme';
|
||||
import LogViewerCe from '~/components/Logs/LogViewer.ce.vue';
|
||||
|
||||
const serverStore = useDummyServerStore();
|
||||
const { serverState } = storeToRefs(serverStore);
|
||||
@@ -183,6 +184,11 @@ const bannerImage = watch(theme, () => {
|
||||
<h2 class="text-xl font-semibold font-mono">SSO Button Component</h2>
|
||||
<SsoButtonCe :ssoenabled="serverState.ssoEnabled" />
|
||||
</div>
|
||||
<div class="bg-background">
|
||||
<hr class="border-black dark:border-white" />
|
||||
<h2 class="text-xl font-semibold font-mono">Log Viewer Component</h2>
|
||||
<LogViewerCe />
|
||||
</div>
|
||||
</div>
|
||||
</client-only>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user