diff --git a/packages/client/client/plugins/loader.ts b/packages/client/client/plugins/loader.ts index c17176b..02e519b 100644 --- a/packages/client/client/plugins/loader.ts +++ b/packages/client/client/plugins/loader.ts @@ -87,7 +87,11 @@ export default class LoaderService extends Service { const part = parts.shift()! node = node[part] } - Object.assign(node, data) + if (Array.isArray(node)) { + node.push(data) + } else { + Object.assign(node, data) + } }) } diff --git a/plugins/logger/client/icons/index.ts b/plugins/logger/client/icons/index.ts new file mode 100644 index 0000000..b630f67 --- /dev/null +++ b/plugins/logger/client/icons/index.ts @@ -0,0 +1,4 @@ +import { icons } from '@cordisjs/client' +import Logs from './logs.vue' + +icons.register('activity:logs', Logs) diff --git a/plugins/logger/client/icons/logs.vue b/plugins/logger/client/icons/logs.vue new file mode 100644 index 0000000..0f651cd --- /dev/null +++ b/plugins/logger/client/icons/logs.vue @@ -0,0 +1,5 @@ + diff --git a/plugins/logger/client/index.scss b/plugins/logger/client/index.scss new file mode 100644 index 0000000..71ddf4a --- /dev/null +++ b/plugins/logger/client/index.scss @@ -0,0 +1,9 @@ +:root { + --terminal-bg: #24292f; + --terminal-fg: #d0d7de; + --terminal-bg-hover: #32383f; + --terminal-fg-hover: #f6f8fa; + --terminal-bg-selection: rgba(33,139,255,0.15); + --terminal-separator: rgba(140,149,159,0.75); + --terminal-timestamp: #8c959f; +} diff --git a/plugins/logger/client/index.ts b/plugins/logger/client/index.ts new file mode 100644 index 0000000..a58d38b --- /dev/null +++ b/plugins/logger/client/index.ts @@ -0,0 +1,35 @@ +import { Context } from '@cordisjs/client' +import {} from '../src' +import Logs from './index.vue' +import Settings from './settings.vue' +import './index.scss' +import './icons' + +export const inject = { + manager: false, +} + +export function apply(ctx: Context) { + ctx.page({ + path: '/logs', + name: '日志', + icon: 'activity:logs', + order: 0, + component: Logs, + }) + + ctx.slot({ + type: 'plugin-details', + component: Settings, + order: -800, + }) + + // this.subroute({ + // path: 'logs', + // title: '日志', + // component: ServicesPage, + // hidden: (entry) => { + // return !this.data.value.packages[entry.name]?.runtime + // }, + // }) +} diff --git a/plugins/logger/client/index.vue b/plugins/logger/client/index.vue new file mode 100644 index 0000000..fac0971 --- /dev/null +++ b/plugins/logger/client/index.vue @@ -0,0 +1,15 @@ + + + diff --git a/plugins/logger/client/logs.vue b/plugins/logger/client/logs.vue new file mode 100644 index 0000000..9823da0 --- /dev/null +++ b/plugins/logger/client/logs.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/plugins/logger/client/settings.vue b/plugins/logger/client/settings.vue new file mode 100644 index 0000000..4d294e4 --- /dev/null +++ b/plugins/logger/client/settings.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/plugins/logger/client/tsconfig.json b/plugins/logger/client/tsconfig.json new file mode 100644 index 0000000..1e774e2 --- /dev/null +++ b/plugins/logger/client/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.client", + "include": [ + ".", + ], + "references": [ + { + "path": "../tsconfig.json", + }, + ], +} diff --git a/plugins/logger/package.json b/plugins/logger/package.json new file mode 100644 index 0000000..e635055 --- /dev/null +++ b/plugins/logger/package.json @@ -0,0 +1,64 @@ +{ + "name": "@cordisjs/plugin-logger", + "description": "Logger service for Cordis", + "version": "0.1.0", + "type": "module", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.mjs" + }, + "./src/*": "./src/*", + "./client": "./client/index.ts", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "dist" + ], + "author": "Shigma ", + "license": "MIT", + "scripts": { + "lint": "eslint src --ext .ts" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/cordiverse/webui.git", + "directory": "plugins/logger" + }, + "bugs": { + "url": "https://github.com/cordiverse/webui/issues" + }, + "keywords": [ + "cordis", + "plugin", + "logger", + "webui" + ], + "cordis": { + "public": [ + "dist" + ], + "description": { + "en": "Dump log files and show them in the console", + "zh": "保存日志文件并在控制台中显示" + }, + "service": { + "optional": [ + "webui" + ] + } + }, + "peerDependencies": { + "@cordisjs/plugin-webui": "^0.1.7", + "cordis": "^3.16.2" + }, + "devDependencies": { + "@cordisjs/client": "^0.1.7", + "@cordisjs/plugin-manager": "^0.3.0", + "ansi_up": "^6.0.2" + }, + "dependencies": { + "cosmokit": "^1.6.2" + } +} diff --git a/plugins/logger/src/file.ts b/plugins/logger/src/file.ts new file mode 100644 index 0000000..a2833fe --- /dev/null +++ b/plugins/logger/src/file.ts @@ -0,0 +1,56 @@ +import { FileHandle, open } from 'fs/promises' +import { Logger } from 'cordis' +import { Buffer } from 'buffer' + +export class FileWriter { + public data: Logger.Record[] = [] + public task: Promise + public size: number = 0 + + private temp: Logger.Record[] = [] + + constructor(public date: string, public path: string) { + this.task = open(path, 'a+').then(async (handle) => { + const buffer = await handle.readFile() + this.data = this.parse(new TextDecoder().decode(buffer)) + this.size = buffer.byteLength + return handle + }) + this.task.then(() => this.flush()) + } + + flush() { + if (!this.temp.length) return + this.task = this.task.then(async (handle) => { + const content = Buffer.from(this.temp.map((record) => JSON.stringify(record) + '\n').join('')) + await handle.write(content) + this.data.push(...this.temp) + this.size += content.byteLength + this.temp = [] + return handle + }) + } + + parse(text: string) { + return text.split('\n').map((line) => { + try { + return JSON.parse(line) + } catch {} + }).filter(Boolean) + } + + async read() { + await this.task + return this.data + } + + write(record: Logger.Record) { + this.temp.push(record) + this.flush() + } + + async close() { + const handle = await this.task + await handle.close() + } +} diff --git a/plugins/logger/src/index.ts b/plugins/logger/src/index.ts new file mode 100644 index 0000000..cf36d99 --- /dev/null +++ b/plugins/logger/src/index.ts @@ -0,0 +1,116 @@ +import { Context, Logger, Schema } from 'cordis' +import { Dict, remove, Time } from 'cosmokit' +import { resolve } from 'path' +import { mkdir, readdir, rm } from 'fs/promises' +import {} from '@cordisjs/plugin-webui' +import { FileWriter } from './file' + +export const name = 'logger' + +export interface Config { + root: string + maxAge: number + maxSize: number +} + +export const Config: Schema = Schema.object({ + root: Schema.string().role('path', { + filters: ['directory'], + allowCreate: true, + }).default('data/logs').description('存放输出日志的本地目录。'), + maxAge: Schema.natural().default(30).description('日志文件保存的最大天数。'), + maxSize: Schema.natural().default(1024 * 100).description('单个日志文件的最大大小。'), +}) + +export const inject = ['webui'] + +export async function apply(ctx: Context, config: Config) { + const root = resolve(ctx.baseDir, config.root) + await mkdir(root, { recursive: true }) + + const files: Dict = {} + for (const filename of await readdir(root)) { + const capture = /^(\d{4}-\d{2}-\d{2})-(\d+)\.log$/.exec(filename) + if (!capture) continue + files[capture[1]] ??= [] + files[capture[1]].push(+capture[2]) + } + + let writer: FileWriter + async function createFile(date: string, index: number) { + writer = new FileWriter(date, `${root}/${date}-${index}.log`) + + const { maxAge } = config + if (!maxAge) return + + const now = Date.now() + for (const date of Object.keys(files)) { + if (now - +new Date(date) < maxAge * Time.day) continue + for (const index of files[date]) { + await rm(`${root}/${date}-${index}.log`).catch((error) => { + ctx.logger('logger').warn(error) + }) + } + delete files[date] + } + } + + const date = new Date().toISOString().slice(0, 10) + createFile(date, Math.max(...files[date] ?? [0]) + 1) + + const entry = ctx.webui.addEntry({ + base: import.meta.url, + dev: '../client/index.ts', + prod: [ + '../dist/index.js', + '../dist/style.css', + ], + }, () => writer.data) + + const update = ctx.throttle(() => { + entry.patch(buffer) + buffer = [] + }, 100) + + let buffer: Logger.Record[] = [] + const loader = ctx.get('loader') + const target: Logger.Target = { + colors: 3, + record: (record: Logger.Record) => { + record.meta ||= {} + const scope = record.meta[Context.current]?.scope + if (loader && scope) { + record.meta.paths = loader.paths(scope) + } + const date = new Date(record.timestamp).toISOString().slice(0, 10) + if (writer.date !== date) { + writer.close() + files[date] = [1] + createFile(date, 1) + } + writer.write(record) + buffer.push(record) + update() + if (writer.size >= config.maxSize) { + writer.close() + const index = Math.max(...files[date] ?? [0]) + 1 + files[date] ??= [] + files[date].push(index) + createFile(date, index) + } + }, + } + + ctx.effect(() => { + Logger.targets.push(target) + return () => { + writer?.close() + remove(Logger.targets, target) + if (loader) loader.prolog = [] + } + }) + + for (const record of loader?.prolog || []) { + target.record!(record) + } +} diff --git a/plugins/logger/tsconfig.json b/plugins/logger/tsconfig.json new file mode 100644 index 0000000..e193a11 --- /dev/null +++ b/plugins/logger/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + }, + "include": [ + "src", + ], +} \ No newline at end of file