修改 ota cicd
All checks were successful
Docker Build and Push / build-image (push) Successful in 6m37s

This commit is contained in:
cheney 2026-08-07 17:10:07 +08:00
parent 22a36fb213
commit add3ff8831
9 changed files with 141 additions and 15 deletions

View File

@ -1,6 +1,8 @@
name: Docker Build and Push name: Docker Build and Push
# 仅 main 分支 push 触发, 其他分支不触发 # 仅 main 分支 push 触发, 其他分支不触发
# 每次主分支提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 部署
# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/
on: on:
push: push:
branches: branches:
@ -13,6 +15,35 @@ jobs:
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@v4
- name: Setup Node
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 22
- name: Install deps
run: npm ci --no-audit --no-fund
- name: Build frontend
# dist/ 供 OTA 打包用 (Dockerfile 内也会构建一份)
run: npm run build
- name: Install minisign
run: sudo apt-get update && sudo apt-get install -y minisign
- name: Build & sign OTA package
# 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增)
# 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像
env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
OTA_NOTES: ${{ github.sha }}
run: |
set -euo pipefail
node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES}
echo "===== latest.json ====="
cat ota-package/latest.json
echo "===== bundle sha256 ====="
sha256sum ota-package/iboard-ota.tar.gz
- name: Build Docker image - name: Build Docker image
# DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串 # DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串
run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build . run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build .
@ -28,6 +59,7 @@ jobs:
- name: Run container - name: Run container
# DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层 # DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层
# OTA_PACKAGE_DIR 已在 Dockerfile 设为 /app/ota-package
run: | run: |
docker run -d --name iboard -p 3001:3001 \ docker run -d --name iboard -p 3001:3001 \
-e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \

View File

@ -1,8 +1,9 @@
name: OTA Frontend Build & Sign name: OTA Frontend Build & Sign
# 前端 OTA 升级包构建 + 签名 + 上传 artifact # 前端 OTA 升级包构建 + 签名 + 上传 artifact
# 触发: 手动 (workflow_dispatch), 由"我"页/发版前点一下 # 触发: 仅手动 (workflow_dispatch), 发版前用
# 输入: sequence / notes (可覆盖默认) # - 主分支 push 的自动 OTA 生成已并入 docker-build.yml (随镜像发布), 本 workflow 不再监听 push 避免重复构建
# 输入: sequence / notes / min_binary (可覆盖默认)
# Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment) # Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment)
# 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey, # 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey,
# 换密钥请手动改 conf 并 commit. # 换密钥请手动改 conf 并 commit.
@ -50,12 +51,14 @@ jobs:
- name: Build & sign OTA package - name: Build & sign OTA package
env: env:
MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }}
OTA_NOTES: ${{ inputs.notes }}
run: | run: |
set -euo pipefail set -euo pipefail
OUT="ota-package" OUT="ota-package"
ARGS="--out ${OUT}" ARGS="--out ${OUT}"
# inputs 留空时 → 不传 --sequence/--min-binary, 脚本默认 sequence=时间戳, min-binary=tauri.conf version
if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi
if [ -n "${{ inputs.notes }}" ]; then ARGS="${ARGS} --notes ${{ inputs.notes }}"; fi if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi
if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi
node scripts/build-ota.mjs ${ARGS} node scripts/build-ota.mjs ${ARGS}
echo "===== latest.json =====" echo "===== latest.json ====="

3
.gitignore vendored
View File

@ -36,7 +36,8 @@ dist/*
/.env /.env
.reasonix/* .reasonix/*
# OTA 升级: 产物 (运行时生成, 部署时单独管理) + 私钥 (绝不能提交) # OTA 升级: 产物 (运行时生成, 部署时单独管理) + 私钥 (绝不能提交)
ota-package/ ota-package/*
!ota-package/README.md
# apizero 文件缓存 (运行时按需生成) # apizero 文件缓存 (运行时按需生成)
cache/apizero/ cache/apizero/

View File

@ -51,6 +51,11 @@ COPY --from=builder /app/prisma.config.ts ./prisma.config.ts
COPY --from=builder /app/content ./content COPY --from=builder /app/content ./content
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
# OTA 升级包: CI (docker-build.yml) 生成的 ota-package/, 随镜像发布
# 本地构建时只有占位文件 (ota-package/README.md), server 找不到 latest.json 则 OTA 返回"无更新"
COPY ota-package ./ota-package
ENV OTA_PACKAGE_DIR=/app/ota-package
# 健康检查 # 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1 CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1

8
ota-package/README.md Normal file
View File

@ -0,0 +1,8 @@
# ota-package/ 目录占位文件
本目录是 OTA 升级包输出目录git 忽略):
- **CIdocker-build.yml**:每次主分支提交运行 `scripts/build-ota.mjs` 生成 `iboard-ota.tar.gz` + `latest.json` + `*.minisig`,随 Docker 镜像发布到容器内 `OTA_PACKAGE_DIR=/app/ota-package`
- **本地手动构建**只有本占位文件Dockerfile 的 `COPY ota-package ./ota-package` 不会失败;容器内 `readLatest()` 找不到 `latest.json` 时 OTA 检查返回"无更新包"。
本文件保留在 git 中是为了让 `COPY` 在本地构建时不报错;真实 OTA 产物(含私钥 hotswap.key永不提交。

View File

@ -0,0 +1,33 @@
import { useEffect, useRef } from 'react'
// 页面从后台恢复到前台时调用 refetch (Tauri 移动端/桌面通用, 不引库)
// - visibilitychange: Tauri 移动端 (iOS WKWebView / Android WebView) 唯一可靠的前后台机制,
// 回前台时 document.visibilityState 必然变为 'visible'
// - window focus: 桌面端 (WebView2) 兜底, 部分场景不派发 DOM focus 但原生窗口焦点事件可靠
// 用法: const load = useCallback(...); useRefreshOnVisible(load)
// 回调通过 ref 持有, effect 只绑定一次, 不随每次渲染重新订阅
export default function useRefreshOnVisible(refetch) {
const fnRef = useRef(refetch)
fnRef.current = refetch
useEffect(() => {
// 回前台时 visibilitychange 与 focus 会接连触发, 用 300ms 窗口节流避免重复请求
let lastTs = 0
const fire = () => {
const now = Date.now()
if (now - lastTs < 300) return
lastTs = now
fnRef.current?.()
}
const onVisibility = () => {
if (document.visibilityState === 'visible') fire()
}
const onFocus = () => fire()
document.addEventListener('visibilitychange', onVisibility)
window.addEventListener('focus', onFocus)
return () => {
document.removeEventListener('visibilitychange', onVisibility)
window.removeEventListener('focus', onFocus)
}
}, [])
}

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { NavBar, Card, Empty, DotLoading, Badge } from 'antd-mobile' import { NavBar, Card, Empty, DotLoading, Badge } from 'antd-mobile'
import { api } from '../api/client.js' import { api } from '../api/client.js'
import SwipeTabs from '../components/SwipeTabs.jsx' import SwipeTabs from '../components/SwipeTabs.jsx'
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
// : + // : +
// - : World Bank MCP (, ) () // - : World Bank MCP (, ) ()
@ -82,6 +83,26 @@ function AlertSection({ onCount }) {
}, [done, onCount]) }, [done, onCount])
useEffect(() => { fetchPage() }, []) useEffect(() => { fetchPage() }, [])
// : ,
const reload = useCallback(async () => {
if (inFlightRef.current) return
inFlightRef.current = true
setLoading(true)
try {
const data = await api('/alerts?limit=10')
setItems(data.items || [])
onCount?.(data.total ?? (data.items?.length || 0))
cursorRef.current = data.nextCursor
setDone(!data.nextCursor)
} catch (e) {
console.error('alerts reload error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [onCount])
useRefreshOnVisible(reload)
useEffect(() => { useEffect(() => {
if (done || items.length === 0) return if (done || items.length === 0) return
const el = sentinelRef.current const el = sentinelRef.current
@ -152,6 +173,25 @@ function NewsSection() {
}, [done]) }, [done])
useEffect(() => { fetchPage() }, []) useEffect(() => { fetchPage() }, [])
// : ,
const reload = useCallback(async () => {
if (inFlightRef.current) return
inFlightRef.current = true
setLoading(true)
try {
const data = await api('/news?limit=10')
setItems(data.items || [])
cursorRef.current = data.nextCursor
setDone(!data.nextCursor)
} catch (e) {
console.error('news reload error', e)
setDone(true)
} finally {
inFlightRef.current = false
setLoading(false)
}
}, [])
useRefreshOnVisible(reload)
useEffect(() => { useEffect(() => {
if (done || items.length === 0) return if (done || items.length === 0) return
const el = sentinelRef.current const el = sentinelRef.current

View File

@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'
import { NavBar, Card, Empty, DotLoading } from 'antd-mobile' import { NavBar, Card, Empty, DotLoading } from 'antd-mobile'
import { api } from '../api/client.js' import { api } from '../api/client.js'
import SwipeTabs from '../components/SwipeTabs.jsx' import SwipeTabs from '../components/SwipeTabs.jsx'
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js'
// : // :
// - : komari (, ), 24h // - : komari (, ), 24h
@ -74,6 +75,9 @@ function ServerView() {
return () => clearInterval(timer) return () => clearInterval(timer)
}, [load]) }, [load])
// ( loading), 30s
useRefreshOnVisible(() => load(true))
return ( return (
<div className="server-list"> <div className="server-list">
{items === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>} {items === null && <div className="monitor-loading"><DotLoading /> 加载中...</div>}

View File

@ -1,9 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom' import { useParams, useNavigate } from 'react-router-dom'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
import { Card, List, NavBar } from 'antd-mobile' import { Card, List, NavBar } from 'antd-mobile'
import { api } from '../../api/client.js' import { api } from '../../api/client.js'
import TrendBadge, { calcTrend, lastPoint } from '../../components/TrendBadge.jsx' import TrendBadge, { calcTrend, lastPoint } from '../../components/TrendBadge.jsx'
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
// //
// (): , + , // (): , + ,
@ -24,19 +25,18 @@ export default function DataView() {
const { name } = useParams() const { name } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
useEffect(() => { const load = useCallback(() => {
let cancelled = false
setLoading(true) setLoading(true)
api('/indicators') api('/indicators')
.then((data) => { .then((data) => { setErr(''); setIndicators(data.indicators || []) })
if (cancelled) return .catch((e) => setErr(e.message))
setIndicators(data.indicators || []) .finally(() => setLoading(false))
})
.catch((e) => !cancelled && setErr(e.message))
.finally(() => !cancelled && setLoading(false))
return () => { cancelled = true }
}, []) }, [])
useEffect(() => { load() }, [load])
//
useRefreshOnVisible(load)
if (loading) return <div className="loading">加载中...</div> if (loading) return <div className="loading">加载中...</div>
if (err) return <div className="error-banner">{err}</div> if (err) return <div className="error-banner">{err}</div>
if (!indicators.length) return <div className="empty">暂无指标数据, 请先执行 npm run db:seed</div> if (!indicators.length) return <div className="empty">暂无指标数据, 请先执行 npm run db:seed</div>