mirror of
https://github.com/ZeroCatDev/ClassworksKV.git
synced 2025-12-08 22:53:10 +00:00
Compare commits
4 Commits
df6ea1b3cd
...
68cf10f040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68cf10f040 | ||
|
|
1d7078874b | ||
|
|
114069a999 | ||
|
|
87a408d904 |
23
app.js
23
app.js
@ -15,6 +15,7 @@ import deviceRouter from "./routes/device.js";
|
|||||||
import deviceAuthRouter from "./routes/device-auth.js";
|
import deviceAuthRouter from "./routes/device-auth.js";
|
||||||
import accountsRouter from "./routes/accounts.js";
|
import accountsRouter from "./routes/accounts.js";
|
||||||
import autoAuthRouter from "./routes/auto-auth.js";
|
import autoAuthRouter from "./routes/auto-auth.js";
|
||||||
|
import { register } from "./utils/metrics.js";
|
||||||
|
|
||||||
var app = express();
|
var app = express();
|
||||||
|
|
||||||
@ -76,6 +77,28 @@ app.get("/check", (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Prometheus metrics endpoint with token auth
|
||||||
|
app.get("/metrics", async (req, res) => {
|
||||||
|
try {
|
||||||
|
// 检查 token 验证
|
||||||
|
const metricsToken = process.env.METRICS_TOKEN;
|
||||||
|
if (metricsToken) {
|
||||||
|
const providedToken = req.headers.authorization?.replace('Bearer ', '') || req.query.token;
|
||||||
|
if (!providedToken || providedToken !== metricsToken) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: "Unauthorized",
|
||||||
|
message: "Valid metrics token required"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set("Content-Type", register.contentType);
|
||||||
|
res.end(await register.metrics());
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).end(err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Mount the Apps router with API rate limiting
|
// Mount the Apps router with API rate limiting
|
||||||
app.use("/apps", appsRouter);
|
app.use("/apps", appsRouter);
|
||||||
|
|
||||||
|
|||||||
4
bin/www
4
bin/www
@ -7,6 +7,7 @@
|
|||||||
import app from '../app.js';
|
import app from '../app.js';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import { initSocket } from '../utils/socket.js';
|
import { initSocket } from '../utils/socket.js';
|
||||||
|
import { initializeMetrics } from '../utils/metrics.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get port from environment and store in Express.
|
* Get port from environment and store in Express.
|
||||||
@ -24,6 +25,9 @@ var server = createServer(app);
|
|||||||
// 初始化 Socket.IO 并绑定到 HTTP Server
|
// 初始化 Socket.IO 并绑定到 HTTP Server
|
||||||
initSocket(server);
|
initSocket(server);
|
||||||
|
|
||||||
|
// 初始化 Prometheus 指标
|
||||||
|
initializeMetrics();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Listen on provided port, on all network interfaces.
|
* Listen on provided port, on all network interfaces.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ClassworksKV",
|
"name": "ClassworksKV",
|
||||||
"version": "1.2.0",
|
"version": "1.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
@ -30,8 +30,9 @@
|
|||||||
"js-base64": "^3.7.7",
|
"js-base64": "^3.7.7",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"morgan": "~1.10.0",
|
"morgan": "~1.10.0",
|
||||||
|
"prom-client": "^15.1.3",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
"uuid": "^11.1.0"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prisma": "^6.18.0"
|
"prisma": "^6.18.0"
|
||||||
|
|||||||
1944
pnpm-lock.yaml
generated
1944
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
119
routes/device.js
119
routes/device.js
@ -6,6 +6,7 @@ import crypto from "crypto";
|
|||||||
import errors from "../utils/errors.js";
|
import errors from "../utils/errors.js";
|
||||||
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
|
import { hashPassword, verifyDevicePassword } from "../utils/crypto.js";
|
||||||
import { getOnlineDevices } from "../utils/socket.js";
|
import { getOnlineDevices } from "../utils/socket.js";
|
||||||
|
import { registeredDevicesTotal } from "../utils/metrics.js";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
@ -47,49 +48,57 @@ router.post(
|
|||||||
return next(errors.createError(400, "设备名称是必需的"));
|
return next(errors.createError(400, "设备名称是必需的"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查UUID是否已存在
|
try {
|
||||||
const existingDevice = await prisma.device.findUnique({
|
// 检查UUID是否已存在
|
||||||
where: { uuid },
|
const existingDevice = await prisma.device.findUnique({
|
||||||
});
|
where: { uuid },
|
||||||
|
});
|
||||||
|
|
||||||
if (existingDevice) {
|
if (existingDevice) {
|
||||||
return next(errors.createError(409, "设备UUID已存在"));
|
return next(errors.createError(409, "设备UUID已存在"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 namespace:如果没有提供,则使用 uuid
|
||||||
|
const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid;
|
||||||
|
|
||||||
|
// 检查 namespace 是否已被使用
|
||||||
|
const existingNamespace = await prisma.device.findUnique({
|
||||||
|
where: { namespace: deviceNamespace },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingNamespace) {
|
||||||
|
return next(errors.createError(409, "该 namespace 已被使用"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建设备
|
||||||
|
const device = await prisma.device.create({
|
||||||
|
data: {
|
||||||
|
uuid,
|
||||||
|
name: deviceName,
|
||||||
|
namespace: deviceNamespace,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 为新设备创建默认的自动登录配置
|
||||||
|
await createDefaultAutoAuth(device.id);
|
||||||
|
|
||||||
|
// 更新注册设备总数指标
|
||||||
|
const totalDevices = await prisma.device.count();
|
||||||
|
registeredDevicesTotal.set(totalDevices);
|
||||||
|
|
||||||
|
return res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
device: {
|
||||||
|
id: device.id,
|
||||||
|
uuid: device.uuid,
|
||||||
|
name: device.name,
|
||||||
|
namespace: device.namespace,
|
||||||
|
createdAt: device.createdAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理 namespace:如果没有提供,则使用 uuid
|
|
||||||
const deviceNamespace = namespace && namespace.trim() ? namespace.trim() : uuid;
|
|
||||||
|
|
||||||
// 检查 namespace 是否已被使用
|
|
||||||
const existingNamespace = await prisma.device.findUnique({
|
|
||||||
where: { namespace: deviceNamespace },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingNamespace) {
|
|
||||||
return next(errors.createError(409, "该 namespace 已被使用"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建设备
|
|
||||||
const device = await prisma.device.create({
|
|
||||||
data: {
|
|
||||||
uuid,
|
|
||||||
name: deviceName,
|
|
||||||
namespace: deviceNamespace,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// 为新设备创建默认的自动登录配置
|
|
||||||
await createDefaultAutoAuth(device.id);
|
|
||||||
|
|
||||||
return res.status(201).json({
|
|
||||||
success: true,
|
|
||||||
device: {
|
|
||||||
id: device.id,
|
|
||||||
uuid: device.uuid,
|
|
||||||
name: device.name,
|
|
||||||
namespace: device.namespace,
|
|
||||||
createdAt: device.createdAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -347,18 +356,22 @@ router.delete(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.device.update({
|
try {
|
||||||
where: { id: device.id },
|
await prisma.device.update({
|
||||||
data: {
|
where: { id: device.id },
|
||||||
password: null,
|
data: {
|
||||||
passwordHint: null,
|
password: null,
|
||||||
},
|
passwordHint: null,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: "密码删除成功",
|
message: "密码删除成功",
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -394,4 +407,4 @@ router.get(
|
|||||||
|
|
||||||
res.json({ success: true, devices });
|
res.json({ success: true, devices });
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@ -28,7 +28,7 @@ router.use(prepareTokenForRateLimit);
|
|||||||
router.get(
|
router.get(
|
||||||
"/_info",
|
"/_info",
|
||||||
tokenReadLimiter,
|
tokenReadLimiter,
|
||||||
errors.catchAsync(async (req, res) => {
|
errors.catchAsync(async (req, res, next) => {
|
||||||
const deviceId = res.locals.deviceId;
|
const deviceId = res.locals.deviceId;
|
||||||
|
|
||||||
// 获取设备信息,包含关联的账号
|
// 获取设备信息,包含关联的账号
|
||||||
@ -43,17 +43,24 @@ router.get(
|
|||||||
return next(errors.createError(404, "设备不存在"));
|
return next(errors.createError(404, "设备不存在"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构建响应对象
|
// 构建响应对象:当设备没有关联账号时返回 uuid;若已关联账号则不返回 uuid
|
||||||
const response = {
|
const response = {
|
||||||
device: {
|
device: {
|
||||||
id: device.id,
|
id: device.id,
|
||||||
uuid: device.uuid,
|
|
||||||
name: device.name,
|
name: device.name,
|
||||||
createdAt: device.createdAt,
|
createdAt: device.createdAt,
|
||||||
updatedAt: device.updatedAt,
|
updatedAt: device.updatedAt,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 仅当设备未绑定账号时,包含 uuid 字段
|
||||||
|
if (!device.account) {
|
||||||
|
response.device.uuid = device.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标识是否已绑定账号
|
||||||
|
response.hasAccount = !!device.account;
|
||||||
|
|
||||||
// 如果关联了账号,添加账号信息
|
// 如果关联了账号,添加账号信息
|
||||||
if (device.account) {
|
if (device.account) {
|
||||||
response.account = {
|
response.account = {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { keysTotal } from "./metrics.js";
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
class KVStore {
|
class KVStore {
|
||||||
/**
|
/**
|
||||||
@ -84,6 +86,10 @@ class KVStore {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 更新键总数指标
|
||||||
|
const totalKeys = await prisma.kVStore.count();
|
||||||
|
keysTotal.set(totalKeys);
|
||||||
|
|
||||||
// 返回带有设备ID和原始键的结果
|
// 返回带有设备ID和原始键的结果
|
||||||
return {
|
return {
|
||||||
deviceId,
|
deviceId,
|
||||||
@ -111,10 +117,17 @@ class KVStore {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 更新键总数指标
|
||||||
|
const totalKeys = await prisma.kVStore.count();
|
||||||
|
keysTotal.set(totalKeys);
|
||||||
|
|
||||||
return item ? { ...item, deviceId, key } : null;
|
return item ? { ...item, deviceId, key } : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 忽略记录不存在的错误
|
// 忽略记录不存在的错误
|
||||||
if (error.code === "P2025") return null;
|
if (error.code === "P2025") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
utils/metrics.js
Normal file
49
utils/metrics.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import client from 'prom-client';
|
||||||
|
|
||||||
|
// 创建自定义注册表(不包含默认指标)
|
||||||
|
const register = new client.Registry();
|
||||||
|
|
||||||
|
// 当前在线设备数(连接了 SocketIO 的设备)
|
||||||
|
export const onlineDevicesGauge = new client.Gauge({
|
||||||
|
name: 'classworks_online_devices_total',
|
||||||
|
help: 'Total number of online devices (connected via SocketIO)',
|
||||||
|
registers: [register],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 已注册设备总数
|
||||||
|
export const registeredDevicesTotal = new client.Gauge({
|
||||||
|
name: 'classworks_registered_devices_total',
|
||||||
|
help: 'Total number of registered devices',
|
||||||
|
registers: [register],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 已创建键总数(不区分设备)
|
||||||
|
export const keysTotal = new client.Gauge({
|
||||||
|
name: 'classworks_keys_total',
|
||||||
|
help: 'Total number of keys across all devices',
|
||||||
|
registers: [register],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化指标数据
|
||||||
|
export async function initializeMetrics() {
|
||||||
|
try {
|
||||||
|
const { PrismaClient } = await import('@prisma/client');
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// 获取已注册设备总数
|
||||||
|
const deviceCount = await prisma.device.count();
|
||||||
|
registeredDevicesTotal.set(deviceCount);
|
||||||
|
|
||||||
|
// 获取已创建键总数
|
||||||
|
const keyCount = await prisma.kVStore.count();
|
||||||
|
keysTotal.set(keyCount);
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log('Prometheus metrics initialized - Devices:', deviceCount, 'Keys:', keyCount);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize metrics:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出注册表用于 /metrics 端点
|
||||||
|
export { register };
|
||||||
@ -11,12 +11,15 @@
|
|||||||
|
|
||||||
import { Server } from "socket.io";
|
import { Server } from "socket.io";
|
||||||
import { PrismaClient } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { onlineDevicesGauge } from "./metrics.js";
|
||||||
|
|
||||||
// Socket.IO 单例实例
|
// Socket.IO 单例实例
|
||||||
let io = null;
|
let io = null;
|
||||||
|
|
||||||
// 在线设备映射:uuid -> Set<socketId>
|
// 在线设备映射:uuid -> Set<socketId>
|
||||||
const onlineMap = new Map();
|
const onlineMap = new Map();
|
||||||
|
// 在线 token 映射:token -> Set<socketId> (用于指标统计)
|
||||||
|
const onlineTokens = new Map();
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -64,7 +67,12 @@ export function initSocket(server) {
|
|||||||
include: { device: { select: { uuid: true } } },
|
include: { device: { select: { uuid: true } } },
|
||||||
});
|
});
|
||||||
const uuid = appInstall?.device?.uuid;
|
const uuid = appInstall?.device?.uuid;
|
||||||
if (uuid) leaveDeviceRoom(socket, uuid);
|
if (uuid) {
|
||||||
|
leaveDeviceRoom(socket, uuid);
|
||||||
|
// 移除 token 连接跟踪
|
||||||
|
removeTokenConnection(token, socket.id);
|
||||||
|
if (socket.data.tokens) socket.data.tokens.delete(token);
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -105,6 +113,10 @@ export function initSocket(server) {
|
|||||||
socket.on("disconnect", () => {
|
socket.on("disconnect", () => {
|
||||||
const uuids = Array.from(socket.data.deviceUuids || []);
|
const uuids = Array.from(socket.data.deviceUuids || []);
|
||||||
uuids.forEach((u) => removeOnline(u, socket.id));
|
uuids.forEach((u) => removeOnline(u, socket.id));
|
||||||
|
|
||||||
|
// 清理 token 连接跟踪
|
||||||
|
const tokens = Array.from(socket.data.tokens || []);
|
||||||
|
tokens.forEach((token) => removeTokenConnection(token, socket.id));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -133,6 +145,24 @@ function joinDeviceRoom(socket, uuid) {
|
|||||||
io.to(uuid).emit("device-joined", { uuid, connections: set.size });
|
io.to(uuid).emit("device-joined", { uuid, connections: set.size });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跟踪 token 连接用于指标统计
|
||||||
|
* @param {import('socket.io').Socket} socket
|
||||||
|
* @param {string} token
|
||||||
|
*/
|
||||||
|
function trackTokenConnection(socket, token) {
|
||||||
|
if (!socket.data.tokens) socket.data.tokens = new Set();
|
||||||
|
socket.data.tokens.add(token);
|
||||||
|
|
||||||
|
// 记录 token 连接
|
||||||
|
const set = onlineTokens.get(token) || new Set();
|
||||||
|
set.add(socket.id);
|
||||||
|
onlineTokens.set(token, set);
|
||||||
|
|
||||||
|
// 更新在线设备数指标(基于不同的 token 数量)
|
||||||
|
onlineDevicesGauge.set(onlineTokens.size);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 让 socket 离开设备房间并更新在线表
|
* 让 socket 离开设备房间并更新在线表
|
||||||
* @param {import('socket.io').Socket} socket
|
* @param {import('socket.io').Socket} socket
|
||||||
@ -155,6 +185,24 @@ function removeOnline(uuid, socketId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除 token 连接跟踪
|
||||||
|
* @param {string} token
|
||||||
|
* @param {string} socketId
|
||||||
|
*/
|
||||||
|
function removeTokenConnection(token, socketId) {
|
||||||
|
const set = onlineTokens.get(token);
|
||||||
|
if (!set) return;
|
||||||
|
set.delete(socketId);
|
||||||
|
if (set.size === 0) {
|
||||||
|
onlineTokens.delete(token);
|
||||||
|
} else {
|
||||||
|
onlineTokens.set(token, set);
|
||||||
|
}
|
||||||
|
// 更新在线设备数指标(基于不同的 token 数量)
|
||||||
|
onlineDevicesGauge.set(onlineTokens.size);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 广播某设备下 KV 键已变更
|
* 广播某设备下 KV 键已变更
|
||||||
* @param {string} uuid 设备 uuid
|
* @param {string} uuid 设备 uuid
|
||||||
@ -167,12 +215,12 @@ export function broadcastKeyChanged(uuid, payload) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取在线设备列表
|
* 获取在线设备列表
|
||||||
* @returns {Array<{uuid:string, connections:number}>}
|
* @returns {Array<{token:string, connections:number}>}
|
||||||
*/
|
*/
|
||||||
export function getOnlineDevices() {
|
export function getOnlineDevices() {
|
||||||
const list = [];
|
const list = [];
|
||||||
for (const [uuid, set] of onlineMap.entries()) {
|
for (const [token, set] of onlineTokens.entries()) {
|
||||||
list.push({ uuid, connections: set.size });
|
list.push({ token, connections: set.size });
|
||||||
}
|
}
|
||||||
// 默认按连接数降序
|
// 默认按连接数降序
|
||||||
return list.sort((a, b) => b.connections - a.connections);
|
return list.sort((a, b) => b.connections - a.connections);
|
||||||
@ -198,8 +246,10 @@ async function joinByToken(socket, token) {
|
|||||||
const uuid = appInstall?.device?.uuid;
|
const uuid = appInstall?.device?.uuid;
|
||||||
if (uuid) {
|
if (uuid) {
|
||||||
joinDeviceRoom(socket, uuid);
|
joinDeviceRoom(socket, uuid);
|
||||||
|
// 跟踪 token 连接用于指标统计
|
||||||
|
trackTokenConnection(socket, token);
|
||||||
// 可选:回执
|
// 可选:回执
|
||||||
socket.emit("joined", { by: "token", uuid });
|
socket.emit("joined", { by: "token", uuid, token });
|
||||||
} else {
|
} else {
|
||||||
socket.emit("join-error", { by: "token", reason: "invalid_token" });
|
socket.emit("join-error", { by: "token", reason: "invalid_token" });
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user