mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
chore: 添加应用诊断日志
This commit is contained in:
@@ -0,0 +1,83 @@
|
|||||||
|
import { app, shell } from 'electron'
|
||||||
|
import fs from 'fs-extra'
|
||||||
|
import path from 'path'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
|
||||||
|
const MAX_LOG_BYTES = 5 * 1024 * 1024
|
||||||
|
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
|
||||||
|
|
||||||
|
const sanitize = (value: unknown, depth = 0): unknown => {
|
||||||
|
if (depth > 4) return '[depth-limited]'
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
|
||||||
|
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
|
||||||
|
.slice(0, 2000)
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
||||||
|
key,
|
||||||
|
REDACTED_KEY.test(key) ? '***' : sanitize(item, depth + 1)
|
||||||
|
])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppLogger {
|
||||||
|
private get logDir(): string {
|
||||||
|
return app.getPath('logs')
|
||||||
|
}
|
||||||
|
|
||||||
|
get logPath(): string {
|
||||||
|
return path.join(this.logDir, 'wechatexplorer.log')
|
||||||
|
}
|
||||||
|
|
||||||
|
private rotateIfNeeded(): void {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(this.logPath) || fs.statSync(this.logPath).size < MAX_LOG_BYTES) return
|
||||||
|
const previous = `${this.logPath}.1`
|
||||||
|
if (fs.existsSync(previous)) fs.removeSync(previous)
|
||||||
|
fs.moveSync(this.logPath, previous)
|
||||||
|
} catch {
|
||||||
|
// Logging must never interrupt the application.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
write(entry: AppLogEntry): void {
|
||||||
|
try {
|
||||||
|
fs.ensureDirSync(this.logDir)
|
||||||
|
this.rotateIfNeeded()
|
||||||
|
const record = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
mode: app.isPackaged ? 'packaged' : 'development',
|
||||||
|
level: entry.level,
|
||||||
|
scope: String(entry.scope || 'app').slice(0, 80),
|
||||||
|
message: String(entry.message || '').slice(0, 500),
|
||||||
|
details: sanitize(entry.details || {})
|
||||||
|
}
|
||||||
|
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
|
||||||
|
if (!app.isPackaged) {
|
||||||
|
const method =
|
||||||
|
entry.level === 'error'
|
||||||
|
? console.error
|
||||||
|
: entry.level === 'warn'
|
||||||
|
? console.warn
|
||||||
|
: console.log
|
||||||
|
method(`[${record.scope}] ${record.message}`, record.details)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Logging must never interrupt the application.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reveal(): void {
|
||||||
|
fs.ensureDirSync(this.logDir)
|
||||||
|
if (!fs.existsSync(this.logPath)) fs.writeFileSync(this.logPath, '', 'utf8')
|
||||||
|
shell.showItemInFolder(this.logPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appLogger = new AppLogger()
|
||||||
@@ -69,6 +69,8 @@ import {
|
|||||||
} from './services/bootstrap-cache'
|
} from './services/bootstrap-cache'
|
||||||
import { installSafeConsole } from './safe-log'
|
import { installSafeConsole } from './safe-log'
|
||||||
import { agentHubService } from './services/agent-hub-service'
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
|
import { appLogger } from './app-logger'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
|
||||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||||
@@ -141,6 +143,31 @@ function createWindow(): void {
|
|||||||
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
||||||
app.whenReady().then(async () => {
|
app.whenReady().then(async () => {
|
||||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||||
|
appLogger.write({
|
||||||
|
level: 'info',
|
||||||
|
scope: 'lifecycle',
|
||||||
|
message: 'WechatExplorer 启动',
|
||||||
|
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
|
||||||
|
})
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
appLogger.write({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'main-process',
|
||||||
|
message: error.message,
|
||||||
|
details: { stack: error.stack }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
process.on('unhandledRejection', (reason) => {
|
||||||
|
appLogger.write({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'main-process',
|
||||||
|
message: reason instanceof Error ? reason.message : 'Promise 未处理拒绝',
|
||||||
|
details: {
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
reason: reason instanceof Error ? undefined : String(reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
||||||
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
||||||
@@ -166,6 +193,9 @@ app.whenReady().then(async () => {
|
|||||||
|
|
||||||
// IPC test
|
// IPC test
|
||||||
ipcMain.on('ping', () => console.log('pong'))
|
ipcMain.on('ping', () => console.log('pong'))
|
||||||
|
ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry))
|
||||||
|
ipcMain.handle('app-log:getPath', () => appLogger.logPath)
|
||||||
|
ipcMain.handle('app-log:reveal', () => appLogger.reveal())
|
||||||
|
|
||||||
ipcMain.handle('db:init', async (_, key: string) => {
|
ipcMain.handle('db:init', async (_, key: string) => {
|
||||||
if (dbInitInFlight) return dbInitInFlight
|
if (dbInitInFlight) return dbInitInFlight
|
||||||
|
|||||||
Vendored
+4
@@ -38,6 +38,7 @@ import type {
|
|||||||
ImageInsight
|
ImageInsight
|
||||||
} from '../shared/image-insight'
|
} from '../shared/image-insight'
|
||||||
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
|
||||||
export type ParsedContent =
|
export type ParsedContent =
|
||||||
| { type: 'text'; content: string }
|
| { type: 'text'; content: string }
|
||||||
@@ -71,6 +72,9 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
electron: ElectronAPI
|
electron: ElectronAPI
|
||||||
api: {
|
api: {
|
||||||
|
writeAppLog: (entry: AppLogEntry) => Promise<void>
|
||||||
|
getAppLogPath: () => Promise<string>
|
||||||
|
revealAppLog: () => Promise<void>
|
||||||
initDb: (
|
initDb: (
|
||||||
key: string
|
key: string
|
||||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ import type {
|
|||||||
ImageInsight
|
ImageInsight
|
||||||
} from '../shared/image-insight'
|
} from '../shared/image-insight'
|
||||||
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||||
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
|
||||||
// 渲染器的自定义 API
|
// 渲染器的自定义 API
|
||||||
const api = {
|
const api = {
|
||||||
|
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
|
||||||
|
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
|
||||||
|
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
||||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||||
|
|||||||
@@ -3854,6 +3854,18 @@ body {
|
|||||||
margin: 4px 0 10px;
|
margin: 4px 0 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-task-error button + button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-task-log-path {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
color: var(--wxex-text-secondary);
|
||||||
|
font: 11px/16px var(--wxex-font);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1120px) {
|
@media (max-width: 1120px) {
|
||||||
.report-page {
|
.report-page {
|
||||||
grid-template-columns: 268px minmax(360px, 1fr) 280px;
|
grid-template-columns: 268px minmax(360px, 1fr) 280px;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
|
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
|
||||||
|
|
||||||
interface ReportTaskStatusPanelProps {
|
interface ReportTaskStatusPanelProps {
|
||||||
@@ -27,6 +27,14 @@ export function ReportTaskStatusPanel({
|
|||||||
}: ReportTaskStatusPanelProps): React.ReactElement {
|
}: ReportTaskStatusPanelProps): React.ReactElement {
|
||||||
const activeIndex = phaseIndex(phase)
|
const activeIndex = phaseIndex(phase)
|
||||||
const completedAll = phase === 'success'
|
const completedAll = phase === 'success'
|
||||||
|
const [logPath, setLogPath] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void window.api
|
||||||
|
.getAppLogPath()
|
||||||
|
.then(setLogPath)
|
||||||
|
.catch(() => undefined)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="report-task-panel">
|
<aside className="report-task-panel">
|
||||||
@@ -70,6 +78,10 @@ export function ReportTaskStatusPanel({
|
|||||||
<button type="button" onClick={onRetry}>
|
<button type="button" onClick={onRetry}>
|
||||||
重试
|
重试
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => void window.api.revealAppLog()}>
|
||||||
|
打开诊断日志
|
||||||
|
</button>
|
||||||
|
{logPath && <small className="report-task-log-path">{logPath}</small>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{phase === 'success' && (
|
{phase === 'success' && (
|
||||||
|
|||||||
@@ -108,6 +108,31 @@ const withTimeout = async <T>(
|
|||||||
const errorMessage = (error: unknown): string =>
|
const errorMessage = (error: unknown): string =>
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
|
|
||||||
|
const writeReportLog = (
|
||||||
|
level: 'info' | 'warn' | 'error',
|
||||||
|
message: string,
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
): void => {
|
||||||
|
void window.api
|
||||||
|
.writeAppLog({ level, scope: 'group-report', message, details })
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonErrorContext = (raw: string, error: unknown): Record<string, unknown> => {
|
||||||
|
const message = errorMessage(error)
|
||||||
|
const position = Number(/\bposition\s+(\d+)/i.exec(message)?.[1])
|
||||||
|
const safePosition = Number.isFinite(position) ? Math.max(0, Math.min(raw.length, position)) : 0
|
||||||
|
return {
|
||||||
|
error: message,
|
||||||
|
outputLength: raw.length,
|
||||||
|
position: Number.isFinite(position) ? position : undefined,
|
||||||
|
context:
|
||||||
|
raw.length && Number.isFinite(position)
|
||||||
|
? raw.slice(Math.max(0, safePosition - 300), Math.min(raw.length, safePosition + 300))
|
||||||
|
: raw.slice(0, 600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const isGroupContact = (contact: Contact | null): boolean =>
|
const isGroupContact = (contact: Contact | null): boolean =>
|
||||||
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
|
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
|
||||||
|
|
||||||
@@ -358,6 +383,7 @@ export function useGroupReportGeneration({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const startGenerateTime = Date.now()
|
const startGenerateTime = Date.now()
|
||||||
|
let failedAt = '初始化'
|
||||||
const logs: ReportGenerationLog[] = []
|
const logs: ReportGenerationLog[] = []
|
||||||
const pushLog = (log: ReportGenerationLog): void => {
|
const pushLog = (log: ReportGenerationLog): void => {
|
||||||
logs.push(log)
|
logs.push(log)
|
||||||
@@ -388,15 +414,29 @@ export function useGroupReportGeneration({
|
|||||||
modelName: modelConfig.model,
|
modelName: modelConfig.model,
|
||||||
generationLogs: []
|
generationLogs: []
|
||||||
})
|
})
|
||||||
|
writeReportLog('info', '开始生成群聊日报', {
|
||||||
|
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
|
||||||
|
dateRange: summaryDateRange,
|
||||||
|
selectedMessageTypes: summaryMessageTypes,
|
||||||
|
providerName: modelConfig.providerName,
|
||||||
|
model: modelConfig.model,
|
||||||
|
templateId
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
failedAt = '读取聊天记录'
|
||||||
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
|
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
|
||||||
|
|
||||||
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
|
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
|
||||||
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
|
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
|
||||||
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
|
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
|
||||||
|
writeReportLog('info', '聊天记录读取完成', {
|
||||||
|
sourceMessageCount: sourceMessages.length,
|
||||||
|
filteredMessageCount: filteredMessages.length
|
||||||
|
})
|
||||||
|
|
||||||
setPhase('preparingInput')
|
setPhase('preparingInput')
|
||||||
|
failedAt = '整理日报输入'
|
||||||
const input = await trackStep('整理输入', async () => {
|
const input = await trackStep('整理输入', async () => {
|
||||||
const namedReportMessages = await applyGroupMemberNames(
|
const namedReportMessages = await applyGroupMemberNames(
|
||||||
sourceContact,
|
sourceContact,
|
||||||
@@ -407,6 +447,7 @@ export function useGroupReportGeneration({
|
|||||||
})
|
})
|
||||||
|
|
||||||
setPhase('requestingModel')
|
setPhase('requestingModel')
|
||||||
|
failedAt = '调用模型生成内容'
|
||||||
const aiMessages = [
|
const aiMessages = [
|
||||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||||
{ role: 'user', content: input.prompt }
|
{ role: 'user', content: input.prompt }
|
||||||
@@ -423,13 +464,19 @@ export function useGroupReportGeneration({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||||
|
writeReportLog('info', '模型响应完成', {
|
||||||
|
outputLength: result.data.length,
|
||||||
|
usage: result.usage
|
||||||
|
})
|
||||||
|
|
||||||
const tokenUsage =
|
const tokenUsage =
|
||||||
result.usage && result.usage.total
|
result.usage && result.usage.total
|
||||||
? result.usage
|
? result.usage
|
||||||
: estimateTokenUsage(aiMessages, result.data)
|
: estimateTokenUsage(aiMessages, result.data)
|
||||||
|
|
||||||
const report = parseGroupDailyReport(
|
let report
|
||||||
|
try {
|
||||||
|
report = parseGroupDailyReport(
|
||||||
result.data,
|
result.data,
|
||||||
input.topSpeakers,
|
input.topSpeakers,
|
||||||
input.activeTimeline,
|
input.activeTimeline,
|
||||||
@@ -437,8 +484,13 @@ export function useGroupReportGeneration({
|
|||||||
input.metadata,
|
input.metadata,
|
||||||
input.media
|
input.media
|
||||||
)
|
)
|
||||||
|
} catch (parseError) {
|
||||||
|
writeReportLog('error', '日报 JSON 解析失败', jsonErrorContext(result.data, parseError))
|
||||||
|
throw parseError
|
||||||
|
}
|
||||||
|
|
||||||
setPhase('exportingReport')
|
setPhase('exportingReport')
|
||||||
|
failedAt = '导出 HTML 与 PNG'
|
||||||
const exported = await withTimeout(
|
const exported = await withTimeout(
|
||||||
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
|
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
|
||||||
'日报图片导出'
|
'日报图片导出'
|
||||||
@@ -474,8 +526,19 @@ export function useGroupReportGeneration({
|
|||||||
generationLogs: [...logs]
|
generationLogs: [...logs]
|
||||||
})
|
})
|
||||||
setPhase('success')
|
setPhase('success')
|
||||||
|
writeReportLog('info', '群聊日报生成成功', {
|
||||||
|
durationMs: Date.now() - startGenerateTime,
|
||||||
|
htmlPath: exported.htmlPath,
|
||||||
|
pngPath: exported.pngPath
|
||||||
|
})
|
||||||
} catch (generateError) {
|
} catch (generateError) {
|
||||||
setError(errorMessage(generateError))
|
const message = errorMessage(generateError)
|
||||||
|
writeReportLog('error', '群聊日报生成失败', {
|
||||||
|
error: message,
|
||||||
|
failedAt,
|
||||||
|
durationMs: Date.now() - startGenerateTime
|
||||||
|
})
|
||||||
|
setError(message)
|
||||||
setPhase('error')
|
setPhase('error')
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -485,6 +548,7 @@ export function useGroupReportGeneration({
|
|||||||
modelConfig,
|
modelConfig,
|
||||||
reportTimeoutSeconds,
|
reportTimeoutSeconds,
|
||||||
sourceContact,
|
sourceContact,
|
||||||
|
summaryDateRange,
|
||||||
summaryMessageTypes,
|
summaryMessageTypes,
|
||||||
templateId
|
templateId
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -4,6 +4,37 @@ import App from './App'
|
|||||||
import './styles/tokens.css'
|
import './styles/tokens.css'
|
||||||
import './assets/main.css'
|
import './assets/main.css'
|
||||||
|
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
void window.api
|
||||||
|
.writeAppLog({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'renderer',
|
||||||
|
message: event.message || 'Renderer 未捕获错误',
|
||||||
|
details: {
|
||||||
|
filename: event.filename,
|
||||||
|
line: event.lineno,
|
||||||
|
column: event.colno,
|
||||||
|
stack: event.error instanceof Error ? event.error.stack : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
const reason = event.reason
|
||||||
|
void window.api
|
||||||
|
.writeAppLog({
|
||||||
|
level: 'error',
|
||||||
|
scope: 'renderer',
|
||||||
|
message: reason instanceof Error ? reason.message : 'Renderer Promise 未处理拒绝',
|
||||||
|
details: {
|
||||||
|
stack: reason instanceof Error ? reason.stack : undefined,
|
||||||
|
reason: reason instanceof Error ? undefined : String(reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export type AppLogLevel = 'info' | 'warn' | 'error'
|
||||||
|
|
||||||
|
export interface AppLogEntry {
|
||||||
|
level: AppLogLevel
|
||||||
|
scope: string
|
||||||
|
message: string
|
||||||
|
details?: Record<string, unknown>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user