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