diff --git a/.github/workflows/translations-pull-request.yml b/.github/workflows/translations-pull-request.yml index bc9a9133a..5dd66df5a 100644 --- a/.github/workflows/translations-pull-request.yml +++ b/.github/workflows/translations-pull-request.yml @@ -5,8 +5,6 @@ on: schedule: - cron: '00 3 * * 1' - - jobs: crowdin-translations-to-pr: name: Create a PR with the latest translations from Crowdin diff --git a/crowdin.yml b/crowdin.yml index 94b4c48b3..b4cc555f7 100644 --- a/crowdin.yml +++ b/crowdin.yml @@ -4,9 +4,10 @@ base_path: "." preserve_hierarchy: true -files: [ - { - "source": "/src/i18n/locales/en/messages.json", - "translation": "/src/i18n/locales/%two_letters_code%/%original_file_name%", - } -] +files: + [ + { + 'source': '/src/i18n/locales/en/messages.json', + 'translation': '/src/i18n/locales/%two_letters_code%/%original_file_name%', + }, + ] diff --git a/graphql.schema.json b/graphql.schema.json index a1ccb34c8..f26a3809a 100644 --- a/graphql.schema.json +++ b/graphql.schema.json @@ -1140,6 +1140,128 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "SCALAR", + "name": "Int", + "description": "The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "JSONObject", + "description": "JSON object", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "LogEntry", + "description": null, + "fields": [ + { + "name": "context", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSONObject", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "level", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "message", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timestamp", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "LogFile", + "description": null, + "fields": [ + { + "name": "content", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "LogEntry", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "LuaScript", @@ -2071,6 +2193,39 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "logFile", + "description": null, + "args": [ + { + "name": "numberOfLines", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": "500", + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "LogFile", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "luaScript", "description": null, diff --git a/src/api/index.ts b/src/api/index.ts index d7921fdc3..aa2658770 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -33,6 +33,8 @@ import MulticastDnsService from './src/services/MulticastDns'; import MulticastDnsMonitorResolver from './src/graphql/resolvers/MulticastDnsMonitor.resolver'; import LuaService from './src/services/Lua'; import LuaResolver from './src/graphql/resolvers/Lua.resolver'; +import LogFileService from './src/services/LogFile'; +import LogFileResolver from './src/graphql/resolvers/LogFile.resolver'; import MulticastDnsSimulatorService from './src/services/MulticastDns/MulticastDnsSimulator'; import MulticastDnsNotificationsService from './src/services/MulticastDnsNotificationsService'; import TargetsLoader from './src/services/TargetsLoader'; @@ -170,6 +172,7 @@ export default class ApiServer { ); Container.set(LuaService, new LuaService(logger)); + Container.set(LogFileService, new LogFileService(logger)); } async start( @@ -202,6 +205,7 @@ export default class ApiServer { SerialMonitorResolver, MulticastDnsMonitorResolver, LuaResolver, + LogFileResolver, ], container: Container, pubSub: Container.get(PubSubToken), diff --git a/src/api/src/graphql/args/LogFile.ts b/src/api/src/graphql/args/LogFile.ts new file mode 100644 index 000000000..ebfe49a15 --- /dev/null +++ b/src/api/src/graphql/args/LogFile.ts @@ -0,0 +1,11 @@ +import { ArgsType, Field, Int } from 'type-graphql'; + +@ArgsType() +export default class LogFileArgs { + @Field(() => Int) + numberOfLines: number; + + constructor() { + this.numberOfLines = 500; + } +} diff --git a/src/api/src/graphql/resolvers/LogFile.resolver.ts b/src/api/src/graphql/resolvers/LogFile.resolver.ts new file mode 100644 index 000000000..a36ec6b7b --- /dev/null +++ b/src/api/src/graphql/resolvers/LogFile.resolver.ts @@ -0,0 +1,17 @@ +import { Args, Query, Resolver } from 'type-graphql'; +import { Service } from 'typedi'; +import LogFileService from '../../services/LogFile'; +import LogFile from '../../models/LogFile'; +import LogFileArgs from '../args/LogFile'; + +@Service() +@Resolver() +export default class LogFileResolver { + constructor(private logFileService: LogFileService) {} + + @Query(() => LogFile) + async logFile(@Args() args: LogFileArgs): Promise { + const content = await this.logFileService.loadLogFile(args); + return { content }; + } +} diff --git a/src/api/src/graphql/scalars/JSONObjectScalar.ts b/src/api/src/graphql/scalars/JSONObjectScalar.ts new file mode 100644 index 000000000..0a3af5ad8 --- /dev/null +++ b/src/api/src/graphql/scalars/JSONObjectScalar.ts @@ -0,0 +1,36 @@ +import { GraphQLScalarType, Kind } from 'graphql'; + +const JSONObjectScalar = new GraphQLScalarType({ + name: 'JSONObject', + description: 'JSON object', + parseValue: (value) => { + if (typeof value === 'object') { + return value; + } + if (typeof value === 'string') { + return JSON.parse(value); + } + return null; + }, + serialize: (value) => { + if (typeof value === 'object') { + return value; + } + if (typeof value === 'string') { + return JSON.parse(value); + } + return null; + }, + parseLiteral: (ast) => { + switch (ast.kind) { + case Kind.STRING: + return JSON.parse(ast.value); + case Kind.OBJECT: + throw new Error(`Not sure what to do with OBJECT for ObjectScalarType`); + default: + return null; + } + }, +}); + +export default JSONObjectScalar; diff --git a/src/api/src/logger/WinstonLogger.ts b/src/api/src/logger/WinstonLogger.ts index bdb59b184..c2200ef56 100644 --- a/src/api/src/logger/WinstonLogger.ts +++ b/src/api/src/logger/WinstonLogger.ts @@ -3,7 +3,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Logger } from 'winston'; import { Service } from 'typedi'; -import { LoggerService } from './index'; +import { LoggerService, QueryOptions } from './index'; @Service() export default class WinstonLoggerService implements LoggerService { @@ -93,4 +93,16 @@ export default class WinstonLoggerService implements LoggerService { return this.logger.verbose(message, { context }); } + + public query(options?: QueryOptions): Promise { + return new Promise((resolve, reject) => { + this.logger.query(options, (err: Error, result: any) => { + if (err) { + reject(err); + } else { + resolve(result.file); + } + }); + }); + } } diff --git a/src/api/src/logger/index.ts b/src/api/src/logger/index.ts index 9a611a159..5bd07c39d 100644 --- a/src/api/src/logger/index.ts +++ b/src/api/src/logger/index.ts @@ -1,5 +1,10 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ +export type QueryOptions = { + rows?: number; + order?: 'asc' | 'desc'; + fields: string[]; +}; +/* eslint-disable @typescript-eslint/no-explicit-any */ export interface LoggerService { log(message: any, context?: Record): void; @@ -10,4 +15,6 @@ export interface LoggerService { debug?(message: any, context?: Record): void; verbose?(message: any, context?: Record): void; + + query(options?: QueryOptions): Promise; } diff --git a/src/api/src/models/LogEntry.ts b/src/api/src/models/LogEntry.ts new file mode 100644 index 000000000..5ca6ab0ee --- /dev/null +++ b/src/api/src/models/LogEntry.ts @@ -0,0 +1,29 @@ +import { Field, ObjectType } from 'type-graphql'; +import JSONObjectScalar from '../graphql/scalars/JSONObjectScalar'; + +@ObjectType('LogEntry') +export default class LogEntry { + @Field() + level: string; + + @Field() + message: string; + + @Field() + timestamp: string; + + @Field(() => JSONObjectScalar, { nullable: true }) + context?: unknown; + + constructor( + level: string, + message: string, + timestamp: string, + context?: unknown + ) { + this.level = level; + this.message = message; + this.timestamp = timestamp; + this.context = context; + } +} diff --git a/src/api/src/models/LogFile.ts b/src/api/src/models/LogFile.ts new file mode 100644 index 000000000..1aff5ab31 --- /dev/null +++ b/src/api/src/models/LogFile.ts @@ -0,0 +1,12 @@ +import { Field, ObjectType } from 'type-graphql'; +import LogEntry from './LogEntry'; + +@ObjectType('LogFile') +export default class LogFile { + @Field(() => [LogEntry], { nullable: true }) + content: LogEntry[]; + + constructor(content: LogEntry[]) { + this.content = content; + } +} diff --git a/src/api/src/services/LogFile/index.ts b/src/api/src/services/LogFile/index.ts new file mode 100644 index 000000000..d3b93c47d --- /dev/null +++ b/src/api/src/services/LogFile/index.ts @@ -0,0 +1,26 @@ +import { Service } from 'typedi'; +import { LoggerService } from '../../logger'; +import LogEntry from '../../models/LogEntry'; + +export interface ILogFileArgs { + numberOfLines: number; +} + +export interface ILogFile { + loadLogFile(args: ILogFileArgs): Promise; +} + +@Service() +export default class LogFileService implements ILogFile { + constructor(private logger: LoggerService) {} + + async loadLogFile(args: ILogFileArgs) { + this.logger.log('Requested log content'); + const logFileContent = await this.logger.query({ + rows: args.numberOfLines, + order: 'desc', + fields: ['timestamp', 'level', 'message', 'context'], + }); + return logFileContent as LogEntry[]; + } +} diff --git a/src/main.dev.ts b/src/main.dev.ts index a39fc2b54..bc1bc1810 100644 --- a/src/main.dev.ts +++ b/src/main.dev.ts @@ -39,8 +39,8 @@ const winstonLogger = winston.createLogger({ transports: [ new winston.transports.Console({ format: winston.format.combine( - winston.format.prettyPrint(), - winston.format.timestamp() + winston.format.timestamp(), + winston.format.prettyPrint() ), }), new winston.transports.File({ @@ -49,8 +49,9 @@ const winstonLogger = winston.createLogger({ maxFiles: 10, maxsize: 5_000_000, // in bytes format: winston.format.combine( + winston.format.timestamp(), winston.format.prettyPrint(), - winston.format.timestamp() + winston.format.json() ), }), ], diff --git a/src/ui/gql/generated/types.ts b/src/ui/gql/generated/types.ts index 61e7ae6d7..ccd8bbb1d 100644 --- a/src/ui/gql/generated/types.ts +++ b/src/ui/gql/generated/types.ts @@ -20,6 +20,7 @@ export type Scalars = { Boolean: boolean; Int: number; Float: number; + JSONObject: any; }; export enum BuildFirmwareErrorType { @@ -162,6 +163,19 @@ export type GitRepositoryInput = { readonly url: Scalars['String']; }; +export type LogEntry = { + readonly __typename?: 'LogEntry'; + readonly context?: Maybe; + readonly level: Scalars['String']; + readonly message: Scalars['String']; + readonly timestamp: Scalars['String']; +}; + +export type LogFile = { + readonly __typename?: 'LogFile'; + readonly content?: Maybe>; +}; + export type LuaScript = { readonly __typename?: 'LuaScript'; readonly fileLocation?: Maybe; @@ -239,6 +253,7 @@ export type Query = { readonly checkForUpdates: UpdatesAvailability; readonly gitBranches: ReadonlyArray; readonly gitTags: ReadonlyArray; + readonly logFile: LogFile; readonly luaScript: LuaScript; readonly pullRequests: ReadonlyArray; readonly releases: ReadonlyArray; @@ -269,6 +284,10 @@ export type QueryGitTagsArgs = { repository: Scalars['String']; }; +export type QueryLogFileArgs = { + numberOfLines?: Scalars['Int']; +}; + export type QueryLuaScriptArgs = { gitBranch?: Scalars['String']; gitCommit?: Scalars['String']; @@ -692,6 +711,24 @@ export type GetReleasesQuery = { }>; }; +export type LogFileQueryVariables = Exact<{ + numberOfLines: Scalars['Int']; +}>; + +export type LogFileQuery = { + readonly __typename?: 'Query'; + readonly logFile: { + readonly __typename?: 'LogFile'; + readonly content?: ReadonlyArray<{ + readonly __typename?: 'LogEntry'; + readonly timestamp: string; + readonly level: string; + readonly message: string; + readonly context?: any | null; + }> | null; + }; +}; + export type LuaScriptQueryVariables = Exact<{ source: FirmwareSource; gitTag: Scalars['String']; @@ -1718,6 +1755,59 @@ export type GetReleasesQueryResult = Apollo.QueryResult< GetReleasesQuery, GetReleasesQueryVariables >; +export const LogFileDocument = gql` + query logFile($numberOfLines: Int!) { + logFile(numberOfLines: $numberOfLines) { + content { + timestamp + level + message + context + } + } + } +`; + +/** + * __useLogFileQuery__ + * + * To run a query within a React component, call `useLogFileQuery` and pass it any options that fit your needs. + * When your component renders, `useLogFileQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useLogFileQuery({ + * variables: { + * numberOfLines: // value for 'numberOfLines' + * }, + * }); + */ +export function useLogFileQuery( + baseOptions: Apollo.QueryHookOptions +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useQuery( + LogFileDocument, + options + ); +} +export function useLogFileLazyQuery( + baseOptions?: Apollo.LazyQueryHookOptions +) { + const options = { ...defaultOptions, ...baseOptions }; + return Apollo.useLazyQuery( + LogFileDocument, + options + ); +} +export type LogFileQueryHookResult = ReturnType; +export type LogFileLazyQueryHookResult = ReturnType; +export type LogFileQueryResult = Apollo.QueryResult< + LogFileQuery, + LogFileQueryVariables +>; export const LuaScriptDocument = gql` query luaScript( $source: FirmwareSource! diff --git a/src/ui/gql/queries/logFile.graphql b/src/ui/gql/queries/logFile.graphql new file mode 100644 index 000000000..d79c9d10a --- /dev/null +++ b/src/ui/gql/queries/logFile.graphql @@ -0,0 +1,10 @@ +query logFile($numberOfLines: Int!) { + logFile(numberOfLines: $numberOfLines) { + content { + timestamp + level + message + context + } + } +} diff --git a/src/ui/views/LogsView/index.tsx b/src/ui/views/LogsView/index.tsx index f6f61cfaf..8e77ae77d 100644 --- a/src/ui/views/LogsView/index.tsx +++ b/src/ui/views/LogsView/index.tsx @@ -1,31 +1,61 @@ -import { Button, Card, CardContent, Divider } from '@mui/material'; -import React, { FunctionComponent } from 'react'; +import { Box, Button, Card, CardContent, Divider } from '@mui/material'; +import React, { FunctionComponent, useState, useEffect } from 'react'; import ListIcon from '@mui/icons-material/List'; import { ipcRenderer } from 'electron'; import { useTranslation } from 'react-i18next'; +import Loader from '../../components/Loader'; import CardTitle from '../../components/CardTitle'; import { IpcRequest } from '../../../ipc'; +import { useLogFileLazyQuery, LogEntry } from '../../gql/generated/types'; import MainLayout from '../../layouts/MainLayout'; +import LogsViewEntry from './logsViewEntry'; const LogsView: FunctionComponent = () => { const { t } = useTranslation(); + const [logs, setLogs] = useState([]); + const [ + fetchLogs, + { + data: fetchLogsData, + loading: fetchLogsDataLoading, + error: fetchLogsDataError, + }, + ] = useLogFileLazyQuery({ fetchPolicy: 'network-only' }); + const onLogs = () => { ipcRenderer.send(IpcRequest.OpenLogsFolder); }; + + useEffect(() => { + fetchLogs({ variables: { numberOfLines: 500 } }); + }, [fetchLogs]); + + useEffect(() => { + setLogs((fetchLogsData?.logFile?.content as LogEntry[]) || []); + }, [fetchLogsData]); + return ( } title={t('LogsView.Logs')} /> - + + + + + {!fetchLogsDataLoading && + !fetchLogsDataError && + logs.map((log: LogEntry, idx: number) => ( + + ))} diff --git a/src/ui/views/LogsView/logsViewEntry.tsx b/src/ui/views/LogsView/logsViewEntry.tsx new file mode 100644 index 000000000..66e0ad1e4 --- /dev/null +++ b/src/ui/views/LogsView/logsViewEntry.tsx @@ -0,0 +1,53 @@ +import { Box, Typography } from '@mui/material'; +import React, { FunctionComponent } from 'react'; +import theme from '../../theme'; +import { LogEntry } from '../../gql/generated/types'; +import LogsViewEntryContext from './logsViewEntryContext'; + +enum LogLevel { + ERROR = 'error', + WARN = 'warn', + INFO = 'info', + HTTP = 'http', + VERBOSE = 'verbose', + DEBUG = 'debug', + SILLY = 'silly', +} + +// TODO: assign different colors +const LogLevelColor = new Map([ + [LogLevel.ERROR, theme.palette.error.main], + [LogLevel.WARN, theme.palette.primary.main], + [LogLevel.INFO, theme.palette.info.light], + [LogLevel.HTTP, theme.palette.primary.main], + [LogLevel.VERBOSE, theme.palette.primary.main], + [LogLevel.DEBUG, theme.palette.primary.main], + [LogLevel.SILLY, theme.palette.primary.main], +]); + +const LogEntryComponent: FunctionComponent<{ logEntry: LogEntry }> = ({ + logEntry, +}) => { + const { timestamp, message, level, context } = logEntry; + return ( + + + + [ + {Intl.DateTimeFormat('en-US', { + dateStyle: 'short', + timeStyle: 'medium', + }).format(new Date(timestamp))} + ] + + + [{level.toUpperCase()}] + + {message} + + {context && } + + ); +}; + +export default LogEntryComponent; diff --git a/src/ui/views/LogsView/logsViewEntryContext.tsx b/src/ui/views/LogsView/logsViewEntryContext.tsx new file mode 100644 index 000000000..23d98b061 --- /dev/null +++ b/src/ui/views/LogsView/logsViewEntryContext.tsx @@ -0,0 +1,35 @@ +import { Box, Typography } from '@mui/material'; +import React, { FunctionComponent } from 'react'; + +const LogsViewEntryContext: FunctionComponent<{ + entryContext: object; + paddings?: number; +}> = ({ entryContext, paddings = 1 }) => { + return ( + + {Object.entries(entryContext).map(([key, value]) => ( + + + {key}: + + {typeof value === 'object' && value !== null ? ( + + ) : ( + + {String(value)} + + )} + + ))} + + ); +}; + +export default LogsViewEntryContext;