mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
9929a72be1
单实例「日志」只记录该实例日志,无从排查跨实例/容器层面的问题(首个实例创建卡死、 打开实例黑屏不可用、升级失败等)。新增面板级全局日志 + 一键诊断包: - 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>
232 lines
12 KiB
TypeScript
232 lines
12 KiB
TypeScript
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 }) }),
|
||
};
|