Initial commit: TaskPool React panel
- React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 本地后端端到端联调:通知 / SSE / 互联
|
||||
* 用法: node scripts/e2e-check.mjs [baseUrl] [user] [pass]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const base = process.argv[2] || 'http://127.0.0.1:8052'
|
||||
const username = process.argv[3] || 'admin'
|
||||
const password = process.argv[4] || 'admin123'
|
||||
const cookieJar = path.join(process.cwd(), 'cookies-e2e.txt')
|
||||
|
||||
const results = []
|
||||
|
||||
function log(name, ok, detail = '') {
|
||||
results.push({ name, ok, detail })
|
||||
const mark = ok ? 'PASS' : 'FAIL'
|
||||
console.log(`[${mark}] ${name}${detail ? ' - ' + detail : ''}`)
|
||||
}
|
||||
|
||||
function parseSetCookie(res) {
|
||||
// undici/node fetch: getSetCookie if available
|
||||
const list =
|
||||
typeof res.headers.getSetCookie === 'function'
|
||||
? res.headers.getSetCookie()
|
||||
: (() => {
|
||||
const single = res.headers.get('set-cookie')
|
||||
return single ? [single] : []
|
||||
})()
|
||||
const map = new Map()
|
||||
if (fs.existsSync(cookieJar)) {
|
||||
for (const line of fs.readFileSync(cookieJar, 'utf8').split(/\r?\n/)) {
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const parts = line.split('\t')
|
||||
if (parts.length >= 7) map.set(parts[5], parts[6])
|
||||
}
|
||||
}
|
||||
for (const c of list) {
|
||||
const [pair] = c.split(';')
|
||||
const eq = pair.indexOf('=')
|
||||
if (eq > 0) map.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim())
|
||||
}
|
||||
const lines = ['# Netscape HTTP Cookie File', '']
|
||||
for (const [k, v] of map) {
|
||||
lines.push(`127.0.0.1\tFALSE\t/\tFALSE\t0\t${k}\t${v}`)
|
||||
}
|
||||
fs.writeFileSync(cookieJar, lines.join('\n'))
|
||||
return [...map.entries()].map(([k, v]) => `${k}=${v}`).join('; ')
|
||||
}
|
||||
|
||||
function loadCookieHeader() {
|
||||
if (!fs.existsSync(cookieJar)) return ''
|
||||
const pairs = []
|
||||
for (const line of fs.readFileSync(cookieJar, 'utf8').split(/\r?\n/)) {
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const parts = line.split('\t')
|
||||
if (parts.length >= 7) pairs.push(`${parts[5]}=${parts[6]}`)
|
||||
}
|
||||
return pairs.join('; ')
|
||||
}
|
||||
|
||||
async function api(method, p, body) {
|
||||
const headers = { Accept: 'application/json' }
|
||||
const cookie = loadCookieHeader()
|
||||
if (cookie) headers.Cookie = cookie
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
const res = await fetch(`${base}${p}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
parseSetCookie(res)
|
||||
const text = await res.text()
|
||||
let json
|
||||
try {
|
||||
json = JSON.parse(text)
|
||||
} catch {
|
||||
json = { raw: text }
|
||||
}
|
||||
return { status: res.status, json, headers: res.headers }
|
||||
}
|
||||
|
||||
async function readSSE(p, ms = 2000) {
|
||||
const cookie = loadCookieHeader()
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), ms)
|
||||
try {
|
||||
const res = await fetch(`${base}${p}`, {
|
||||
headers: { Accept: 'text/event-stream', Cookie: cookie },
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
const reader = res.body?.getReader()
|
||||
if (!reader) return { status: res.status, text: '' }
|
||||
const dec = new TextDecoder()
|
||||
let text = ''
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
text += dec.decode(value, { stream: true })
|
||||
if (text.includes('data:')) break
|
||||
}
|
||||
reader.cancel().catch(() => {})
|
||||
return { status: res.status, text }
|
||||
} catch (e) {
|
||||
return { status: 0, text: String(e?.message || e) }
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`E2E base=${base}`)
|
||||
|
||||
// login
|
||||
const login = await api('POST', '/api/v1/auth/login', { username, password })
|
||||
log('auth.login', login.status === 200 && login.json?.code === 200, JSON.stringify(login.json?.msg || login.json))
|
||||
|
||||
const me = await api('GET', '/api/v1/auth/me')
|
||||
log('auth.me', me.status === 200 && me.json?.code === 200, JSON.stringify(me.json?.data || me.json?.msg))
|
||||
|
||||
// notify
|
||||
const types = await api('GET', '/api/v1/notify/types')
|
||||
const typeList = types.json?.data?.channel_types || []
|
||||
log('notify.types', types.status === 200 && typeList.length > 0, `count=${typeList.length}`)
|
||||
|
||||
const channelBody = {
|
||||
name: 'e2e-custom',
|
||||
type: 'Custom',
|
||||
enabled: true,
|
||||
config: {
|
||||
webhook: 'http://127.0.0.1:9/hook',
|
||||
body: '{"text":"TEXT"}',
|
||||
},
|
||||
}
|
||||
const saveCh = await api('POST', '/api/v1/notify/channels', channelBody)
|
||||
log('notify.saveChannel', saveCh.status === 200 && saveCh.json?.code === 200, JSON.stringify(saveCh.json?.msg || saveCh.json))
|
||||
|
||||
const channels = await api('GET', '/api/v1/notify/channels')
|
||||
const chList = Array.isArray(channels.json?.data) ? channels.json.data : []
|
||||
log('notify.listChannels', channels.status === 200 && channels.json?.code === 200, `count=${chList.length}`)
|
||||
|
||||
const testCh = await api('POST', '/api/v1/notify/channels/test', channelBody)
|
||||
// 预期连接失败但接口应返回 200 + result 结构
|
||||
log(
|
||||
'notify.testChannel',
|
||||
testCh.status === 200 && testCh.json?.code === 200,
|
||||
JSON.stringify(testCh.json?.data || testCh.json?.msg),
|
||||
)
|
||||
|
||||
const settingsNotify = await api('GET', '/api/v1/settings/notify')
|
||||
const token = settingsNotify.json?.data?.token || settingsNotify.json?.data?.notify_token || ''
|
||||
log('settings.notify', settingsNotify.status === 200 && settingsNotify.json?.code === 200, token ? `token=${token.slice(0, 8)}...` : 'no token')
|
||||
|
||||
// interconnect
|
||||
const nodeBody = {
|
||||
name: `e2e-node-${Date.now()}`,
|
||||
url: 'http://127.0.0.1:18052',
|
||||
token: 'test-token',
|
||||
remark: 'e2e',
|
||||
}
|
||||
const createNode = await api('POST', '/api/v1/interconnect/nodes', nodeBody)
|
||||
const nodeId = createNode.json?.data?.id
|
||||
log('interconnect.createNode', createNode.status === 200 && !!nodeId, nodeId || JSON.stringify(createNode.json))
|
||||
|
||||
const nodes = await api('GET', '/api/v1/interconnect/nodes')
|
||||
const nodeList = Array.isArray(nodes.json?.data) ? nodes.json.data : []
|
||||
log('interconnect.listNodes', nodes.status === 200 && nodeList.length > 0, `count=${nodeList.length}`)
|
||||
|
||||
const child = await api('GET', '/api/v1/interconnect/child/status')
|
||||
log('interconnect.childStatus', child.status === 200 && child.json?.code === 200, JSON.stringify(child.json?.data))
|
||||
|
||||
if (nodeId) {
|
||||
const st = await api('GET', `/api/v1/interconnect/nodes/${nodeId}/status`)
|
||||
log('interconnect.nodeStatus', st.status === 200, JSON.stringify(st.json?.data || st.json?.msg))
|
||||
}
|
||||
|
||||
// monitor SSE
|
||||
const mon = await readSSE('/api/v1/monitor/sse', 2500)
|
||||
log('monitor.sse', mon.text.includes('data:') || mon.text.includes('cpu') || mon.text.includes('host'), mon.text.slice(0, 120).replace(/\s+/g, ' '))
|
||||
|
||||
// logs SSE param validation
|
||||
const bad = await api('GET', '/api/v1/logs/sse?id=test')
|
||||
log('logs.sse.paramGuard', bad.status === 400 || bad.json?.error || bad.json?.msg, JSON.stringify(bad.json))
|
||||
|
||||
// stats
|
||||
const stats = await api('GET', '/api/v1/stats')
|
||||
log('dashboard.stats', stats.status === 200 && stats.json?.code === 200, JSON.stringify(stats.json?.data))
|
||||
|
||||
const failed = results.filter((r) => !r.ok)
|
||||
console.log('\n==== summary ====')
|
||||
console.log(`total=${results.length} pass=${results.length - failed.length} fail=${failed.length}`)
|
||||
if (failed.length) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
// 将本地 sqlite 库从 baihu 前缀迁移为 taskpool 前缀,并更新触发类型/站点标题。
|
||||
//
|
||||
// 用法(项目根目录):
|
||||
//
|
||||
// go run ./scripts/migrate-db-brand.go
|
||||
// go run ./scripts/migrate-db-brand.go -db data/baihu.db -from baihu_ -to taskpool_
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/glebarez/sqlite"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dbPath := flag.String("db", "data/baihu.db", "sqlite database path")
|
||||
fromPrefix := flag.String("from", "baihu_", "old table prefix")
|
||||
toPrefix := flag.String("to", "taskpool_", "new table prefix")
|
||||
outPath := flag.String("out", "data/taskpool.db", "output database path (copy then migrate)")
|
||||
dryRun := flag.Bool("dry-run", false, "only print actions")
|
||||
flag.Parse()
|
||||
|
||||
if err := run(*dbPath, *outPath, *fromPrefix, *toPrefix, *dryRun); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "migrate failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(srcDB, outDB, fromPrefix, toPrefix string, dryRun bool) error {
|
||||
if _, err := os.Stat(srcDB); err != nil {
|
||||
return fmt.Errorf("source db not found: %s", srcDB)
|
||||
}
|
||||
|
||||
// 先 checkpoint wal,确保主库完整
|
||||
if err := checkpoint(srcDB); err != nil {
|
||||
fmt.Printf("warn: checkpoint: %v\n", err)
|
||||
}
|
||||
|
||||
absSrc, _ := filepath.Abs(srcDB)
|
||||
absOut, _ := filepath.Abs(outDB)
|
||||
fmt.Printf("source: %s\noutput: %s\nprefix: %s -> %s\n", absSrc, absOut, fromPrefix, toPrefix)
|
||||
|
||||
if dryRun {
|
||||
db, err := sql.Open("sqlite", srcDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
tables, err := listTables(db, fromPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, t := range tables {
|
||||
fmt.Printf("would rename %s -> %s\n", t, strings.Replace(t, fromPrefix, toPrefix, 1))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(outDB), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 备份源库
|
||||
bak := srcDB + ".bak-" + time.Now().Format("20060102-150405")
|
||||
if err := copyFile(srcDB, bak); err != nil {
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
fmt.Printf("backup: %s\n", bak)
|
||||
|
||||
// 复制到目标(若目标就是源,则原地改)
|
||||
same := filepath.Clean(absSrc) == filepath.Clean(absOut)
|
||||
work := outDB
|
||||
if !same {
|
||||
// 清理目标旁路文件
|
||||
_ = os.Remove(outDB)
|
||||
_ = os.Remove(outDB + "-wal")
|
||||
_ = os.Remove(outDB + "-shm")
|
||||
if err := copyFile(srcDB, outDB); err != nil {
|
||||
return fmt.Errorf("copy to output failed: %w", err)
|
||||
}
|
||||
// 若源有 wal,已 checkpoint,通常足够
|
||||
} else {
|
||||
work = srcDB
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", work)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.Exec(`PRAGMA foreign_keys = OFF`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tables, err := listTables(db, fromPrefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
// 也许已经迁过
|
||||
tables2, _ := listTables(db, toPrefix)
|
||||
if len(tables2) > 0 {
|
||||
fmt.Println("tables already use new prefix; continue data fixes")
|
||||
} else {
|
||||
return fmt.Errorf("no tables with prefix %q found", fromPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, oldName := range tables {
|
||||
newName := strings.Replace(oldName, fromPrefix, toPrefix, 1)
|
||||
if newName == oldName {
|
||||
continue
|
||||
}
|
||||
// 若新表已存在则跳过
|
||||
var exists int
|
||||
_ = tx.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?`, newName).Scan(&exists)
|
||||
if exists > 0 {
|
||||
fmt.Printf("skip (exists): %s\n", newName)
|
||||
continue
|
||||
}
|
||||
sqlStmt := fmt.Sprintf(`ALTER TABLE "%s" RENAME TO "%s"`, oldName, newName)
|
||||
if _, err := tx.Exec(sqlStmt); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("rename %s -> %s: %w", oldName, newName, err)
|
||||
}
|
||||
fmt.Printf("renamed: %s -> %s\n", oldName, newName)
|
||||
}
|
||||
|
||||
// 索引名里若带旧前缀,SQLite 一般会随表走;对显式旧索引再尝试重命名
|
||||
indexes, err := listIndexes(tx, fromPrefix)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
for _, oldIdx := range indexes {
|
||||
newIdx := strings.Replace(oldIdx, fromPrefix, toPrefix, 1)
|
||||
if newIdx == oldIdx {
|
||||
continue
|
||||
}
|
||||
var exists int
|
||||
_ = tx.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='index' AND name=?`, newIdx).Scan(&exists)
|
||||
if exists > 0 {
|
||||
continue
|
||||
}
|
||||
// SQLite 3.26+ supports ALTER INDEX RENAME; if fails, ignore
|
||||
stmt := fmt.Sprintf(`ALTER INDEX "%s" RENAME TO "%s"`, oldIdx, newIdx)
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
fmt.Printf("warn: index rename %s: %v\n", oldIdx, err)
|
||||
} else {
|
||||
fmt.Printf("renamed index: %s -> %s\n", oldIdx, newIdx)
|
||||
}
|
||||
}
|
||||
|
||||
settingsTable := toPrefix + "settings"
|
||||
tasksTable := toPrefix + "tasks"
|
||||
|
||||
// 站点标题
|
||||
if hasTable(tx, settingsTable) {
|
||||
res, err := tx.Exec(`UPDATE "`+settingsTable+`" SET value = ? WHERE section = 'site' AND key = 'title' AND (value = '白虎面板' OR value = 'Baihu Panel' OR value LIKE '%白虎%')`, "任务池")
|
||||
if err != nil {
|
||||
fmt.Printf("warn: update site title: %v\n", err)
|
||||
} else if n, _ := res.RowsAffected(); n > 0 {
|
||||
fmt.Printf("updated site title rows: %d\n", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 触发类型
|
||||
if hasTable(tx, tasksTable) {
|
||||
res, err := tx.Exec(`UPDATE "`+tasksTable+`" SET trigger_type = 'taskpool_startup' WHERE trigger_type = 'baihu_startup'`)
|
||||
if err != nil {
|
||||
fmt.Printf("warn: update trigger_type: %v\n", err)
|
||||
} else if n, _ := res.RowsAffected(); n > 0 {
|
||||
fmt.Printf("updated trigger_type rows: %d\n", n)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := db.Exec(`VACUUM`); err != nil {
|
||||
fmt.Printf("warn: vacuum: %v\n", err)
|
||||
}
|
||||
if _, err := db.Exec(`PRAGMA wal_checkpoint(FULL)`); err != nil {
|
||||
fmt.Printf("warn: final checkpoint: %v\n", err)
|
||||
}
|
||||
|
||||
fmt.Println("migrate done")
|
||||
fmt.Println("next:")
|
||||
fmt.Println(" 1) update configs/config.ini:")
|
||||
fmt.Println(" path = data/taskpool.db")
|
||||
fmt.Println(" table_prefix = taskpool_")
|
||||
fmt.Println(" dbname = taskpool")
|
||||
fmt.Println(" 2) restart: bin\\taskpool.exe server")
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkpoint(dbPath string) error {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = db.Exec(`PRAGMA wal_checkpoint(FULL)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func listTables(db *sql.DB, prefix string) ([]string, error) {
|
||||
rows, err := db.Query(`SELECT name FROM sqlite_master WHERE type='table' AND name LIKE ? AND name NOT LIKE 'sqlite_%' ORDER BY name`, prefix+"%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func listIndexes(tx *sql.Tx, prefix string) ([]string, error) {
|
||||
rows, err := tx.Query(`SELECT name FROM sqlite_master WHERE type='index' AND name LIKE ? AND name NOT LIKE 'sqlite_%' ORDER BY name`, prefix+"%")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func hasTable(tx *sql.Tx, name string) bool {
|
||||
var n int
|
||||
_ = tx.QueryRow(`SELECT COUNT(1) FROM sqlite_master WHERE type='table' AND name=?`, name).Scan(&n)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(dst, in, 0o644)
|
||||
}
|
||||
Reference in New Issue
Block a user