273 lines
9.4 KiB
JavaScript
273 lines
9.4 KiB
JavaScript
/**
|
||
* Verdaccio 缓存可视化管理面板 — 后端服务
|
||
* Express + better-sqlite3,§4.2 REST API
|
||
*
|
||
* 环境变量:
|
||
* VERDACCIO_STORAGE Verdaccio 缓存目录(优先于 UI 设置)
|
||
* PORT 监听端口(默认 3000)
|
||
* DATA_DIR SQLite 数据目录(默认 backend/data)
|
||
*/
|
||
import express from 'express'
|
||
import path from 'node:path'
|
||
import fs from 'node:fs'
|
||
import { fileURLToPath } from 'node:url'
|
||
import {
|
||
getSetting, setSetting, getIgnored, getIgnoredRows, addIgnored, removeIgnored,
|
||
addLog, getLogs,
|
||
} from './db.js'
|
||
import {
|
||
resolveStorageDir, isValidStorage, validateStorage, scanStorage,
|
||
demoPackages, demoSummary,
|
||
} from './scanner.js'
|
||
import { cleanPackage, deleteSingleVersion } from './clean.js'
|
||
|
||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||
const app = express()
|
||
app.use(express.json({ limit: '1mb' }))
|
||
|
||
const PORT = Number(process.env.PORT || 3000)
|
||
const DEFAULT_VERSION_THRESHOLD = 15
|
||
const DEFAULT_SIZE_THRESHOLD = 500
|
||
|
||
// —— 当前生效的 storage 目录(每次请求动态解析,UI 保存后立即生效) ——
|
||
function currentStorage() {
|
||
return resolveStorageDir()
|
||
}
|
||
|
||
function isDemoMode() {
|
||
return process.env.VCM_DEMO === '1' || !isValidStorage(currentStorage())
|
||
}
|
||
|
||
// 构建包列表(统一状态计算 + 排序 + 忽略标记)
|
||
function buildPackages(keyword = '') {
|
||
const threshold = Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD))
|
||
const ignoredRows = getIgnoredRows()
|
||
const ignoredSet = new Set(ignoredRows.map(r => r.pkg_name))
|
||
const ignoredAtMap = Object.fromEntries(ignoredRows.map(r => [r.pkg_name, (r.ignored_at || '').slice(0, 10)]))
|
||
const demo = isDemoMode()
|
||
const raw = demo ? demoPackages() : scanStorage(currentStorage())
|
||
|
||
let list = raw.map(p => {
|
||
let status
|
||
if (p.status === 'broken') status = 'broken'
|
||
else if (ignoredSet.has(p.name)) status = 'ignored'
|
||
else status = p.versions > threshold ? 'warn' : 'ok'
|
||
return {
|
||
name: p.name,
|
||
versions: p.versions,
|
||
size: p.size,
|
||
lastUpdated: p.lastUpdated,
|
||
visits: p.visits || 0,
|
||
distTags: p.distTags || {},
|
||
status,
|
||
ignoredAt: ignoredAtMap[p.name] || null,
|
||
versionsDetail: p.versionsDetail || [],
|
||
}
|
||
})
|
||
|
||
// 检索:不区分大小写子串,匹配包名 / 版本号 / 文件大小文本
|
||
const kw = keyword.trim().toLowerCase()
|
||
if (kw) {
|
||
list = list.filter(p =>
|
||
p.name.toLowerCase().includes(kw) ||
|
||
(p.versionsDetail || []).some(v => v.version.toLowerCase().includes(kw)) ||
|
||
String(p.size).includes(kw),
|
||
)
|
||
}
|
||
|
||
// 排序:预警置顶 → 正常 → 忽略 → 异常;同状态按版本数降序
|
||
const rank = { warn: 0, ok: 1, ignored: 2, broken: 3 }
|
||
list.sort((a, b) => {
|
||
if (rank[a.status] !== rank[b.status]) return rank[a.status] - rank[b.status]
|
||
return b.versions - a.versions
|
||
})
|
||
return list
|
||
}
|
||
|
||
// —— API:阈值 ——
|
||
// spec §4.2:GET/POST /api/threshold(版本阈值)
|
||
app.get('/api/threshold', (req, res) => {
|
||
res.json({ versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)) })
|
||
})
|
||
app.post('/api/threshold', (req, res) => {
|
||
// 兼容 versionThreshold / value 两种字段名
|
||
const v = clampInt(req.body.versionThreshold ?? req.body.value, 1, 100)
|
||
setSetting('version_threshold', v)
|
||
res.json({ ok: true, versionThreshold: v })
|
||
})
|
||
|
||
// 扩展:GET/POST /api/thresholds(版本 + 大小 + 单包例外)
|
||
app.get('/api/thresholds', (req, res) => {
|
||
let exceptions = []
|
||
try { exceptions = JSON.parse(getSetting('exceptions', '[]')) } catch { exceptions = [] }
|
||
res.json({
|
||
versionThreshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
|
||
sizeThreshold: Number(getSetting('size_threshold', DEFAULT_SIZE_THRESHOLD)),
|
||
exceptions,
|
||
})
|
||
})
|
||
app.post('/api/thresholds', (req, res) => {
|
||
const { versionThreshold, sizeThreshold, exceptions } = req.body || {}
|
||
setSetting('version_threshold', clampInt(versionThreshold, 1, 100))
|
||
setSetting('size_threshold', clampInt(sizeThreshold, 1, 10000))
|
||
const ex = Array.isArray(exceptions)
|
||
? exceptions.map(e => ({
|
||
pkgName: String(e.pkgName || '').trim(),
|
||
versionThreshold: clampInt(e.versionThreshold, 1, 100),
|
||
sizeThreshold: clampInt(e.sizeThreshold, 1, 10000),
|
||
})).filter(e => e.pkgName)
|
||
: []
|
||
setSetting('exceptions', JSON.stringify(ex))
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// —— API:忽略 ——
|
||
app.get('/api/ignored', (req, res) => {
|
||
res.json(getIgnored())
|
||
})
|
||
app.post('/api/ignore', (req, res) => {
|
||
const { pkgName } = req.body || {}
|
||
if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' })
|
||
addIgnored(String(pkgName))
|
||
res.json({ ok: true })
|
||
})
|
||
app.delete('/api/ignore/:pkgName', (req, res) => {
|
||
removeIgnored(req.params.pkgName)
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// —— API:包列表(扫描) ——
|
||
app.get('/api/packages', (req, res) => {
|
||
const demo = isDemoMode()
|
||
const storage = currentStorage()
|
||
const validation = demo ? { ok: true, message: 'demo' } : validateStorage(storage)
|
||
res.json({
|
||
demo,
|
||
storageDir: storage || null,
|
||
storageValid: validation.ok,
|
||
storageMessage: validation.message,
|
||
threshold: Number(getSetting('version_threshold', DEFAULT_VERSION_THRESHOLD)),
|
||
packages: buildPackages(req.query.keyword || ''),
|
||
...(demo ? demoSummary() : {}),
|
||
})
|
||
})
|
||
|
||
// —— API:清理 ——
|
||
app.post('/api/clean', (req, res) => {
|
||
const { pkgName, keep } = req.body || {}
|
||
if (!pkgName) return res.status(400).json({ ok: false, message: '缺少 pkgName' })
|
||
if (isDemoMode()) {
|
||
return demoClean(res, pkgName, clampInt(keep, 1, 999))
|
||
}
|
||
const storage = currentStorage()
|
||
const result = cleanPackage(storage, pkgName, clampInt(keep, 1, 999))
|
||
if (result.ok) {
|
||
addLog({
|
||
pkgName,
|
||
deletedVersions: result.deletedVersions,
|
||
deletedCount: result.deletedCount,
|
||
freedSpace: result.freedSpace,
|
||
})
|
||
}
|
||
res.json(result)
|
||
})
|
||
|
||
// —— API:单版本删除 ——
|
||
app.delete('/api/packages/:pkgName/versions/:version', (req, res) => {
|
||
const { pkgName, version } = req.params
|
||
if (isDemoMode()) {
|
||
return demoDeleteVersion(res, pkgName, version)
|
||
}
|
||
const storage = currentStorage()
|
||
const result = deleteSingleVersion(storage, pkgName, version)
|
||
if (result.ok) {
|
||
addLog({
|
||
pkgName,
|
||
deletedVersions: version,
|
||
deletedCount: 1,
|
||
freedSpace: result.freedSpace,
|
||
})
|
||
}
|
||
res.json(result)
|
||
})
|
||
|
||
// —— API:日志 ——
|
||
app.get('/api/logs', (req, res) => {
|
||
res.json(getLogs(50))
|
||
})
|
||
|
||
// —— API:storage 配置(UI 设置页) ——
|
||
app.get('/api/storage', (req, res) => {
|
||
const storage = currentStorage()
|
||
const demo = isDemoMode()
|
||
res.json({
|
||
storageDir: storage,
|
||
envDir: process.env.VERDACCIO_STORAGE || null,
|
||
demo,
|
||
validation: demo ? { ok: true, message: 'demo' } : validateStorage(storage),
|
||
})
|
||
})
|
||
app.post('/api/storage', (req, res) => {
|
||
const { storageDir } = req.body || {}
|
||
if (!storageDir) return res.status(400).json({ ok: false, message: '缺少 storageDir' })
|
||
const v = validateStorage(String(storageDir))
|
||
if (!v.ok) return res.status(400).json(v)
|
||
setSetting('storage_dir', String(storageDir))
|
||
res.json({ ok: true })
|
||
})
|
||
|
||
// —— 生产:托管前端 dist ——
|
||
const distDir = path.join(__dirname, '..', 'frontend', 'dist')
|
||
if (fs.existsSync(distDir)) {
|
||
app.use(express.static(distDir))
|
||
app.get('*', (req, res) => {
|
||
if (req.path.startsWith('/api/')) return res.status(404).json({ ok: false, message: 'not found' })
|
||
res.sendFile(path.join(distDir, 'index.html'))
|
||
})
|
||
}
|
||
|
||
app.listen(PORT, () => {
|
||
const storage = resolveStorageDir()
|
||
const mode = isDemoMode() ? 'demo' : 'storage'
|
||
console.log(`[verdaccio-manager] listening on :${PORT} (mode=${mode})`)
|
||
if (mode === 'storage') console.log(`[verdaccio-manager] storage: ${storage}`)
|
||
else console.log('[verdaccio-manager] 未配置 VERDACCIO_STORAGE,运行于演示模式(设置 VCM_DEMO=0 + 真实目录可关闭)')
|
||
})
|
||
|
||
function clampInt(v, min, max) {
|
||
const n = Number(v)
|
||
if (Number.isNaN(n)) return min
|
||
return Math.min(max, Math.max(min, Math.round(n)))
|
||
}
|
||
|
||
// —— demo 模式下的清理/删除(内存态) ——
|
||
const demoMemory = { logs: [] }
|
||
function demoClean(res, pkgName, keep) {
|
||
const p = demoPackages().find(x => x.name === pkgName)
|
||
if (!p) return res.json({ ok: false, message: '包不存在' })
|
||
const sorted = [...p.versionsDetail].sort((a, b) =>
|
||
a.version.localeCompare(b.version, undefined, { numeric: true }),
|
||
)
|
||
const toDel = sorted.slice(0, Math.max(0, sorted.length - keep))
|
||
if (!toDel.length) return res.json({ ok: false, message: '无需清理' })
|
||
const freed = toDel.reduce((s, v) => s + v.size, 0)
|
||
const result = {
|
||
ok: true,
|
||
pkgName,
|
||
deletedVersions: toDel.map(v => v.version).join(', '),
|
||
deletedCount: toDel.length,
|
||
freedSpace: `${freed.toFixed(1)} MB`,
|
||
}
|
||
// demo 日志并入 logs 表,保持一致
|
||
addLog({ pkgName, deletedVersions: result.deletedVersions, deletedCount: result.deletedCount, freedSpace: result.freedSpace })
|
||
res.json(result)
|
||
}
|
||
|
||
function demoDeleteVersion(res, pkgName, version) {
|
||
const p = demoPackages().find(x => x.name === pkgName)
|
||
const v = p?.versionsDetail?.find(x => x.version === version)
|
||
if (!v) return res.json({ ok: false, message: '版本不存在' })
|
||
addLog({ pkgName, deletedVersions: version, deletedCount: 1, freedSpace: `${v.size.toFixed(1)} MB` })
|
||
res.json({ ok: true, freedSpace: `${v.size.toFixed(1)} MB` })
|
||
}
|