diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4588d03..ca5770d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,16 @@ jobs: username: ${{ vars.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + # 解析烤进面板镜像的版本号:打 tag / 发 Release 时用 tag 名(vX.Y.Z),手动触发则用 dev-<短SHA>。 + - name: Resolve build version + id: ver + run: | + if [ "${{ github.ref_type }}" = "tag" ]; then + echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" + else + echo "version=dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" + fi + - name: Docker metadata (tags + labels) id: meta uses: docker/metadata-action@v5 @@ -85,6 +95,8 @@ jobs: context: ${{ matrix.context }} file: ${{ matrix.dockerfile }} platforms: linux/amd64,linux/arm64 + # 仅面板镜像消费 WOC_VERSION(实例镜像不展示版本号),避免「未使用的 build-arg」告警。 + build-args: ${{ matrix.name == 'panel' && format('WOC_VERSION={0}', steps.ver.outputs.version) || '' }} push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} diff --git a/panel/Dockerfile b/panel/Dockerfile index b7c4439..4d6b262 100644 --- a/panel/Dockerfile +++ b/panel/Dockerfile @@ -17,7 +17,11 @@ RUN npm install COPY server/ ./ COPY --from=web /web/dist ./web-dist -ENV STATIC_DIR=/app/web-dist \ +# 构建版本号:CI 用 git tag 注入(vX.Y.Z),本地构建默认 dev。烤进镜像 → 面板运行时显示真实版本并据此检测更新。 +# 放在末尾:改版本号不会破坏上面的依赖安装缓存。 +ARG WOC_VERSION=dev +ENV WOC_VERSION=${WOC_VERSION} \ + STATIC_DIR=/app/web-dist \ PORT=8080 EXPOSE 8080 CMD ["npm", "run", "start"] diff --git a/panel/server/src/index.ts b/panel/server/src/index.ts index 651ac6d..ebbdca8 100644 --- a/panel/server/src/index.ts +++ b/panel/server/src/index.ts @@ -73,6 +73,7 @@ import { } from './docker.js'; import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js'; import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js'; +import { CURRENT_VERSION, versionInfo, ensureChecked, checkForUpdate, startUpdateChecker } from './version.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -172,6 +173,19 @@ app.get('/api/auth/me', async (req, reply) => { return { user: publicUser(u) }; }); +// ---------- 版本与更新检测 ---------- +// 当前构建版本 + 缓存的「最新版」检测结果(后台每 6h 查一次 Docker Hub/GHCR)。任何登录用户可读。 +app.get('/api/version', async (req, reply) => { + if (!requireAuth(req, reply)) return; + ensureChecked(); // 刚启动还没首检时,触发一次后台检查(不阻塞本次响应) + return versionInfo(); +}); +// 立即重新检查(管理员,用于「检查更新」按钮)。 +app.post('/api/admin/version/check', async (req, reply) => { + if (!requireAdmin(req, reply)) return; + return await checkForUpdate(); +}); + // ---------- 自助改密 ---------- app.post('/api/account/password', async (req, reply) => { const u = requireAuth(req, reply); @@ -1083,4 +1097,5 @@ if (WATCHDOG_ENABLED) { } await app.listen({ port: PORT, host: HOST }); -console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)`); +console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)· 版本 ${CURRENT_VERSION}`); +startUpdateChecker(); // 后台检测新版(best-effort,失败静默) diff --git a/panel/server/src/version.ts b/panel/server/src/version.ts new file mode 100644 index 0000000..1ad7c45 --- /dev/null +++ b/panel/server/src/version.ts @@ -0,0 +1,118 @@ +// 面板版本与更新检测。 +// 构建时把版本号烤进镜像(Dockerfile: ARG/ENV WOC_VERSION,由 CI 用 git tag 注入,本地构建为 dev); +// 运行时查询 Docker Hub 与 GHCR 上 woc-panel 的最新语义化标签,比对后给前端「有新版」红点。 +// 全程 best-effort:离线 / 被墙 / 私有源拉取失败时不报错、不打扰,仅不显示红点(记 error 供「上次检查」提示)。 + +export const CURRENT_VERSION = (process.env.WOC_VERSION || 'dev').trim(); + +export interface VersionInfo { + current: string; // 当前构建版本(如 v1.2.0 / dev) + latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null + hasUpdate: boolean; // 当前能解析为语义化版本且 latest > current 时为 true + checkedAt: number; // 上次检查时间戳(ms);0 = 尚未检查 + source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr + error: string | null; // 检查失败原因(两个源都拉不到时) +} + +// 镜像命名空间:从 WOC_WECHAT_IMAGE 推断(面板与实例镜像同账号)。 +// 例 docker.io/gloridust/wechat-on-cloud:latest → gloridust;ghcr.io/gloridust/... 同理。 +function imageOwner(): string { + const img = (process.env.WOC_WECHAT_IMAGE || 'docker.io/gloridust/wechat-on-cloud').split('@')[0]; + const segs = img.split('/'); // [registry?, owner, image[:tag]] + return segs.length >= 2 ? segs[segs.length - 2] : 'gloridust'; +} +const PANEL_REPO = process.env.WOC_PANEL_REPO || 'woc-panel'; + +function parseSemver(s: string): [number, number, number] | null { + const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(s.trim()); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} +function cmpSemver(a: [number, number, number], b: [number, number, number]): number { + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} +// 从一堆标签里挑出最大的 x.y.z(忽略 latest / x / x.y 等非完整语义化标签)。 +function maxSemver(tags: string[]): string | null { + let best: [number, number, number] | null = null; + for (const t of tags) { + const v = parseSemver(t); + if (v && (!best || cmpSemver(v, best) > 0)) best = v; + } + return best ? `${best[0]}.${best[1]}.${best[2]}` : null; +} + +async function getJson(url: string, headers: Record, timeoutMs: number): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(url, { headers: { 'user-agent': 'woc-panel-update-check', ...headers }, signal: ctrl.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return await res.json(); + } finally { + clearTimeout(t); + } +} + +// Docker Hub 公共 API:免鉴权列标签。 +async function dockerHubTags(owner: string): Promise { + const d = await getJson(`https://hub.docker.com/v2/repositories/${owner}/${PANEL_REPO}/tags?page_size=100`, {}, 8000); + return Array.isArray(d?.results) ? d.results.map((r: any) => String(r?.name || '')) : []; +} +// GHCR 公共镜像:先取匿名 pull token,再走 registry v2 tags/list。 +async function ghcrTags(owner: string): Promise { + const tok = await getJson(`https://ghcr.io/token?scope=repository:${owner}/${PANEL_REPO}:pull&service=ghcr.io`, {}, 8000); + const d = await getJson(`https://ghcr.io/v2/${owner}/${PANEL_REPO}/tags/list`, { authorization: `Bearer ${tok?.token || ''}` }, 8000); + return Array.isArray(d?.tags) ? d.tags.map((t: any) => String(t)) : []; +} + +let cache: VersionInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: 0, source: null, error: null }; +let inflight: Promise | null = null; + +export function versionInfo(): VersionInfo { + return cache; +} + +// 查询两个仓库(并行、互不阻塞),取全局最大语义化版本与当前版本比对。失败静默写入 error。 +export function checkForUpdate(): Promise { + if (inflight) return inflight; // 合并并发请求,避免重复外呼 + inflight = (async () => { + const owner = imageOwner(); + const sources: string[] = []; + const tags: string[] = []; + const [hub, ghcr] = await Promise.allSettled([dockerHubTags(owner), ghcrTags(owner)]); + if (hub.status === 'fulfilled' && hub.value.length) { + tags.push(...hub.value); + sources.push('dockerhub'); + } + if (ghcr.status === 'fulfilled' && ghcr.value.length) { + tags.push(...ghcr.value); + sources.push('ghcr'); + } + const latestBare = maxSemver(tags); + const cur = parseSemver(CURRENT_VERSION); + const latestV = latestBare ? parseSemver(latestBare) : null; + const hasUpdate = !!(latestV && cur && cmpSemver(latestV, cur) > 0); + cache = { + current: CURRENT_VERSION, + latest: latestBare ? `v${latestBare}` : null, + hasUpdate, + checkedAt: Date.now(), + source: sources.join('+') || null, + error: tags.length ? null : '无法连接镜像仓库(Docker Hub / GHCR)', + }; + return cache; + })().finally(() => { + inflight = null; + }); + return inflight; +} + +// 尚未检查过则触发一次后台检查(不等待)。GET /api/version 调用,保证刚启动也能尽快填上缓存。 +export function ensureChecked(): void { + if (!cache.checkedAt && !inflight) void checkForUpdate().catch(() => {}); +} + +// 启动后延迟首检(让监听就绪)+ 每 6 小时复检;定时器 unref,不阻止进程退出。 +export function startUpdateChecker(): void { + setTimeout(() => void checkForUpdate().catch(() => {}), 4_000).unref(); + setInterval(() => void checkForUpdate().catch(() => {}), 6 * 60 * 60 * 1000).unref(); +} diff --git a/panel/web/src/AppShell.tsx b/panel/web/src/AppShell.tsx index 48a66cf..959523a 100644 --- a/panel/web/src/AppShell.tsx +++ b/panel/web/src/AppShell.tsx @@ -157,6 +157,17 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl const isAdmin = user?.role === 'admin'; const go = (p: string) => nav(p); + // 有新版时在「管理」入口点个红点(仅管理员,因为升级面板需管理员在宿主操作)。 + // 依赖 loc.pathname:导航时复查一次(服务端有缓存、开销极小),保证刚启动时首检完成后红点能及时出现。 + const [hasUpdate, setHasUpdate] = useState(false); + useEffect(() => { + if (!isAdmin) return; + api + .getVersion() + .then((v) => setHasUpdate(!!v.hasUpdate)) + .catch(() => {}); + }, [isAdmin, loc.pathname]); + return (