Files
verdaccio-cache-manager/backend/server.js
T

273 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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` })
}