Files
Gloridust 9929a72be1 feat(panel): 全局日志系统 + 一键诊断包导出
单实例「日志」只记录该实例日志,无从排查跨实例/容器层面的问题(首个实例创建卡死、
打开实例黑屏不可用、升级失败等)。新增面板级全局日志 + 一键诊断包:

- logs.ts(新):统一持久化日志(面板数据卷,跨重建保留)。实例日志原语从 docker.ts
  迁来;新增 appendPanelLog/readPanelLog、按时间裁剪 filterSince、一年保留 pruneOldLogs、
  时间范围 24h/7d/30d/1y。无 docker 依赖避免循环。
- 仪表化:实例创建(含镜像拉取前后,定位首次拉取卡死)、删除、启停、重启、升级、
  应用安装/更新、看门狗自愈 均写入面板全局日志(同时回显 stdout)。
- docker.ts buildDiagnostics:打包 system.txt(Docker/镜像/系统)+ panel.log +
  instances/<id>.log(容器状态 inspect + 持久日志 + 实时日志)+ containers.txt(全部
  woc-* 容器清单,含残留),手搓多文件 tar.gz(沿用既有无依赖 tar 风格)。
- 路由:GET /api/admin/diagnostics?range=(导出 tar.gz)、GET /api/admin/panel-log?range=;
  启动 + 每 24h 跑 pruneOldLogs。
- 前端:管理页「诊断与日志」区——时间范围(24h默认/7d/30d/1y) + 导出诊断包 + 查看面板日志。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:48:36 +08:00

232 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export interface PanelUser {
id: string;
username: string;
role: 'admin' | 'sub';
disabled: boolean;
createdAt: string;
allowedInstances: string[]; // admin 为空数组(隐式全部)
mustChangePassword?: boolean; // 仍在用默认密码时为 true
}
export type WechatPhase = 'idle' | 'downloading' | 'extracting' | 'installing' | 'done' | 'error';
export interface WechatStatus {
phase: WechatPhase;
percent: number; // -1 表示进度不确定
installed: boolean;
version: string;
message: string;
updatedAt: number;
}
export type RuntimeState = 'running' | 'stopped' | 'missing';
export type AppType = 'wechat' | 'telegram' | 'chromium' | 'custom';
export const APP_LABELS: Record<AppType, string> = {
wechat: '微信',
telegram: 'Telegram',
chromium: 'Chromium',
custom: '自定义应用',
};
// 各应用的 UI 画像,供卡片/桌面页按类型显示正确文案(避免到处写死「微信」)。
// needsInstall: 是否需要运行时下载安装(微信/Telegram 是;Chromium 已烤进镜像、即创建即就绪)。
// enterHint: 首次进入实例的提示。
// updateLabel: 「管理」菜单里的更新按钮文案(needsInstall=false 时不显示)。
export interface AppProfile {
label: string;
needsInstall: boolean;
enterHint: string;
updateLabel: string;
}
export const APP_PROFILES: Record<AppType, AppProfile> = {
wechat: { label: '微信', needsInstall: true, enterHint: '首次进入请扫码登录微信', updateLabel: '更新微信' },
telegram: { label: 'Telegram', needsInstall: true, enterHint: '首次进入请登录 Telegram', updateLabel: '更新 Telegram' },
chromium: { label: 'Chromium', needsInstall: false, enterHint: '浏览器已就绪,直接使用即可', updateLabel: '' },
custom: { label: '自定义应用', needsInstall: true, enterHint: '', updateLabel: '更新' },
};
export const appProfile = (t?: AppType): AppProfile => APP_PROFILES[t ?? 'wechat'] ?? APP_PROFILES.wechat;
export interface PanelInstance {
id: string;
name: string;
appType?: AppType; // 缺省(老实例)= wechat
icon?: string; // 自定义图标:data: 图片 / builtin:<key>;缺省按 appType 取默认图标
createdAt: string;
createdBy: string;
memSoftLimitMB?: number;
memHardLimitMB?: number;
}
export interface MemLimits {
soft: number | null;
hard: number | null;
defaultSoft: number;
defaultHard: number;
currentMB: number;
watchdogEnabled: boolean;
intervalSec: number;
}
export interface InstanceWithStatus extends PanelInstance {
runtime: RuntimeState;
wechat: WechatStatus;
}
export interface VolEntry {
name: string;
type: 'dir' | 'file' | 'link' | 'other';
size: number;
mtime: number; // epoch ms
}
export interface VersionInfo {
current: string; // 当前构建版本(如 v1.2.0 / dev
latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null
hasUpdate: boolean; // 有更高的语义化版本可用
checkedAt: number; // 上次检查时间戳(ms);0=尚未检查
source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr
error: string | null; // 检查失败原因
}
// 原始二进制上传(File 直传 application/octet-stream),用于数据卷上传/解压/恢复
async function rawUpload(url: string, file: File): Promise<any> {
const res = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' },
body: file,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data as any).error || `请求失败 (${res.status})`);
return data;
}
async function req<T = any>(path: string, opts: RequestInit = {}): Promise<T> {
// 仅在有 body 时声明 JSON content-type:否则 Fastify 对「空 body + application/json」会报 400
const headers = opts.body ? { 'content-type': 'application/json', ...opts.headers } : opts.headers;
const res = await fetch(path, {
credentials: 'same-origin',
...opts,
headers,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
// 会话过期:除登录/探测接口外,任意接口收到 401 都说明 cookie 失效,直接回登录页(避免页面卡在错误态)
const isAuthProbe = path.includes('/api/auth/login') || path.includes('/api/auth/me');
if (res.status === 401 && !isAuthProbe && location.pathname !== '/login') {
location.assign('/login');
}
throw new Error((data as any).error || `请求失败 (${res.status})`);
}
return data as T;
}
export const api = {
me: () => req<{ user: PanelUser }>('/api/auth/me'),
login: (username: string, password: string) =>
req<{ user: PanelUser }>('/api/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }),
logout: () => req('/api/auth/logout', { method: 'POST' }),
changePassword: (oldPassword: string, newPassword: string) =>
req('/api/account/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) }),
// 版本与更新检测
getVersion: () => req<VersionInfo>('/api/version'),
checkUpdate: () => req<VersionInfo>('/api/admin/version/check', { method: 'POST' }),
// 子账号
listUsers: () => req<{ users: PanelUser[] }>('/api/admin/users'),
createUser: (username: string, password: string, allowedInstances: string[] = []) =>
req<{ user: PanelUser }>('/api/admin/users', {
method: 'POST',
body: JSON.stringify({ username, password, allowedInstances }),
}),
setDisabled: (id: string, disabled: boolean) =>
req<{ user: PanelUser }>(`/api/admin/users/${id}/disable`, { method: 'POST', body: JSON.stringify({ disabled }) }),
resetUser: (id: string, newPassword: string) =>
req<{ user: PanelUser }>(`/api/admin/users/${id}/reset`, { method: 'POST', body: JSON.stringify({ newPassword }) }),
deleteUser: (id: string) => req(`/api/admin/users/${id}`, { method: 'DELETE' }),
setUserInstances: (id: string, instanceIds: string[]) =>
req<{ user: PanelUser }>(`/api/admin/users/${id}/instances`, { method: 'POST', body: JSON.stringify({ instanceIds }) }),
// 微信实例
listInstances: () => req<{ instances: InstanceWithStatus[] }>('/api/instances'),
createInstance: (name: string, allowedUserIds: string[] = [], reuseVolume?: string, appType: AppType = 'wechat') =>
req<{ instance: PanelInstance }>('/api/admin/instances', {
method: 'POST',
body: JSON.stringify({ name, allowedUserIds, reuseVolume: reuseVolume || undefined, appType }),
}),
regenMachineId: (id: string) =>
req(`/api/admin/instances/${id}/regen-machine-id`, { method: 'POST' }),
getInstanceMemLimits: (id: string) =>
req<MemLimits>(`/api/admin/instances/${id}/mem-limits`),
setInstanceMemLimits: (id: string, soft: number | null | undefined, hard: number | null | undefined) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/mem-limits`, {
method: 'PUT',
body: JSON.stringify({ soft, hard }),
}),
listOrphanVolumes: () =>
req<{ volumes: { name: string; createdAt?: string; sizeBytes?: number }[] }>('/api/admin/orphan-volumes'),
deleteOrphanVolume: (name: string) =>
req(`/api/admin/orphan-volumes/${encodeURIComponent(name)}`, { method: 'DELETE' }),
listOrphanContainers: () =>
req<{ containers: { id: string; name: string; status: string; volumeName?: string }[] }>('/api/admin/orphan-containers'),
deleteOrphanContainer: (idOrName: string) =>
req(`/api/admin/orphan-containers/${encodeURIComponent(idOrName)}`, { method: 'DELETE' }),
setInstanceIcon: (id: string, icon: string | null) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/icon`, { method: 'POST', body: JSON.stringify({ icon }) }),
renameInstance: (id: string, name: string) =>
req<{ instance: PanelInstance }>(`/api/admin/instances/${id}/rename`, { method: 'POST', body: JSON.stringify({ name }) }),
deleteInstance: (id: string, purge = false) =>
req(`/api/admin/instances/${id}${purge ? '?purge=1' : ''}`, { method: 'DELETE' }),
setInstanceUsers: (id: string, userIds: string[]) =>
req(`/api/admin/instances/${id}/users`, { method: 'POST', body: JSON.stringify({ userIds }) }),
instanceWechatStatus: (id: string) => req<{ status: WechatStatus }>(`/api/instances/${id}/wechat/status`),
instanceWechatInstall: (id: string) => req(`/api/admin/instances/${id}/wechat/install`, { method: 'POST' }),
instanceWechatUpdate: (id: string) => req(`/api/admin/instances/${id}/wechat/update`, { method: 'POST' }),
instanceStart: (id: string) => req(`/api/admin/instances/${id}/start`, { method: 'POST' }),
instanceStop: (id: string) => req(`/api/admin/instances/${id}/stop`, { method: 'POST' }),
instanceRestart: (id: string) => req(`/api/admin/instances/${id}/restart`, { method: 'POST' }),
instanceUpgrade: (id: string) => req(`/api/admin/instances/${id}/upgrade`, { method: 'POST' }),
instanceLogsUrl: (id: string) => `/api/admin/instances/${id}/logs`,
// 全局日志 / 诊断包(范围 24h/7d/30d/1y
diagnosticsUrl: (range: string) => `/api/admin/diagnostics?range=${encodeURIComponent(range)}`,
panelLogUrl: (range: string) => `/api/admin/panel-log?range=${encodeURIComponent(range)}`,
// 文件中转
listFiles: (id: string) => req<{ files: { name: string; size: number }[] }>(`/api/instances/${id}/files`),
uploadFile: async (id: string, file: File) => {
const res = await fetch(`/api/instances/${id}/upload?name=${encodeURIComponent(file.name)}`, {
method: 'POST',
credentials: 'same-origin',
headers: { 'content-type': 'application/octet-stream' },
body: file,
});
if (!res.ok) throw new Error(((await res.json().catch(() => ({}))) as any).error || '上传失败');
return res.json();
},
downloadFileUrl: (id: string, name: string) => `/api/instances/${id}/download?name=${encodeURIComponent(name)}`,
deleteFile: (id: string, name: string) => req(`/api/instances/${id}/files?name=${encodeURIComponent(name)}`, { method: 'DELETE' }),
// 数据卷管理(仅管理员)
volumeList: (id: string, path = '') =>
req<{ path: string; entries: VolEntry[] }>(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`),
volumeMkdir: (id: string, path: string) =>
req(`/api/admin/instances/${id}/volume/mkdir`, { method: 'POST', body: JSON.stringify({ path }) }),
volumeMove: (id: string, from: string, to: string) =>
req(`/api/admin/instances/${id}/volume/move`, { method: 'POST', body: JSON.stringify({ from, to }) }),
volumeDelete: (id: string, path: string) =>
req(`/api/admin/instances/${id}/volume?path=${encodeURIComponent(path)}`, { method: 'DELETE' }),
volumeDownloadUrl: (id: string, path: string) =>
`/api/admin/instances/${id}/volume/download?path=${encodeURIComponent(path)}`,
volumeBackupUrl: (id: string) => `/api/admin/instances/${id}/volume/backup`,
volumeUpload: (id: string, path: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/upload?path=${encodeURIComponent(path)}&name=${encodeURIComponent(file.name)}`, file),
volumeExtract: (id: string, path: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/extract?path=${encodeURIComponent(path)}`, file),
volumeRestore: (id: string, file: File) =>
rawUpload(`/api/admin/instances/${id}/volume/restore`, file),
// 多端协作:操作控制权
controlStatus: (id: string) => req<{ free: boolean; mine: boolean; holder: string | null }>(`/api/instances/${id}/control`),
controlBeat: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/beat`, { method: 'POST' }),
controlTake: (id: string) => req<{ mine: boolean; holder: string }>(`/api/instances/${id}/control/take`, { method: 'POST' }),
typeInInstance: (id: string, text: string) => req(`/api/instances/${id}/type`, { method: 'POST', body: JSON.stringify({ text }) }),
keyInInstance: (id: string, key: string) => req(`/api/instances/${id}/key`, { method: 'POST', body: JSON.stringify({ key }) }),
};