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:
Eli Bosley
2025-03-17 11:44:10 -04:00
committed by GitHub
parent acbf46df3f
commit 3f590c56e3
22 changed files with 1629 additions and 9 deletions

View File

@@ -25,6 +25,7 @@ test('Returns paths', async () => {
"keyfile-base",
"machine-id",
"log-base",
"unraid-log-base",
"var-run",
"auth-sessions",
"auth-keys",

View File

@@ -18,6 +18,7 @@ export enum PUBSUB_CHANNEL {
SERVERS = 'SERVERS',
VMS = 'VMS',
REGISTRATION = 'REGISTRATION',
LOG_FILE = 'LOG_FILE',
}
export const pubsub = new PubSub({ eventEmitter });

View File

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

View File

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

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

View File

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

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

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

View File

@@ -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.
*/

View File

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

View File

@@ -128,6 +128,10 @@ export default defineNuxtConfig({
name: 'UnraidSsoButton',
path: '@/components/SsoButton.ce',
},
{
name: 'UnraidLogViewer',
path: '@/components/Logs/LogViewer.ce',
},
],
},
],

View File

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

View File

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