initial commit
This commit is contained in:
562
src/dashboardStore.js
Normal file
562
src/dashboardStore.js
Normal file
@@ -0,0 +1,562 @@
|
||||
"use strict";
|
||||
|
||||
function cloneJson(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function toBoolean(value, fallback = true) {
|
||||
if (value === undefined || value === null) return fallback;
|
||||
return value !== false && value !== 0 && value !== "0";
|
||||
}
|
||||
|
||||
function toSqlDateTime(isoString) {
|
||||
const date = isoString ? new Date(isoString) : new Date();
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error(`Invalid date: ${isoString}`);
|
||||
}
|
||||
|
||||
const yyyy = String(date.getUTCFullYear());
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
const hh = String(date.getUTCHours()).padStart(2, "0");
|
||||
const mi = String(date.getUTCMinutes()).padStart(2, "0");
|
||||
const ss = String(date.getUTCSeconds()).padStart(2, "0");
|
||||
const mmm = String(date.getUTCMilliseconds()).padStart(3, "0");
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}.${mmm}`;
|
||||
}
|
||||
|
||||
function fromSqlDateTime(value) {
|
||||
if (!value) return null;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
function parseJsonColumn(value, fallback) {
|
||||
if (value === null || value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
class InMemoryDashboardStore {
|
||||
constructor() {
|
||||
this.watches = new Map();
|
||||
this.events = [];
|
||||
this.globalControls = {
|
||||
crawlingEnabled: true,
|
||||
alertsEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
async init() {}
|
||||
|
||||
async close() {}
|
||||
|
||||
async listWatches() {
|
||||
return Array.from(this.watches.values())
|
||||
.map((watch) => cloneJson(watch))
|
||||
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
||||
}
|
||||
|
||||
async getWatch(watchId) {
|
||||
const watch = this.watches.get(watchId);
|
||||
return watch ? cloneJson(watch) : null;
|
||||
}
|
||||
|
||||
async saveWatch(watch) {
|
||||
const next = cloneJson(watch);
|
||||
this.watches.set(next.id, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
async deleteWatch(watchId) {
|
||||
return this.watches.delete(watchId);
|
||||
}
|
||||
|
||||
async savePollResult(watchId, pollResult) {
|
||||
const watch = this.watches.get(watchId);
|
||||
if (!watch) return;
|
||||
|
||||
let touched = false;
|
||||
|
||||
if (pollResult && pollResult.snapshot) {
|
||||
watch.lastSnapshot = cloneJson(pollResult.snapshot);
|
||||
watch.lastError = null;
|
||||
touched = true;
|
||||
}
|
||||
|
||||
if (pollResult && pollResult.error) {
|
||||
watch.lastError = cloneJson(pollResult.error);
|
||||
touched = true;
|
||||
}
|
||||
|
||||
if (touched) {
|
||||
watch.updatedAt = new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
async saveEvent(event) {
|
||||
const stored = {
|
||||
id: `${Date.now()}-${Math.floor(Math.random() * 100000)}`,
|
||||
watchId: event.watchId,
|
||||
eventType: event.eventType,
|
||||
payload: cloneJson(event.payload),
|
||||
observedAt: event.observedAt,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
this.events.unshift(stored);
|
||||
if (this.events.length > 1000) {
|
||||
this.events.length = 1000;
|
||||
}
|
||||
return cloneJson(stored);
|
||||
}
|
||||
|
||||
async listEvents(limit = 50) {
|
||||
const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
|
||||
return this.events.slice(0, safeLimit).map((event) => cloneJson(event));
|
||||
}
|
||||
|
||||
async getGlobalControls() {
|
||||
return cloneJson(this.globalControls);
|
||||
}
|
||||
|
||||
async setGlobalControls(patch = {}) {
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "crawlingEnabled")) {
|
||||
this.globalControls.crawlingEnabled = toBoolean(
|
||||
patch.crawlingEnabled,
|
||||
this.globalControls.crawlingEnabled
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) {
|
||||
this.globalControls.alertsEnabled = toBoolean(
|
||||
patch.alertsEnabled,
|
||||
this.globalControls.alertsEnabled
|
||||
);
|
||||
}
|
||||
|
||||
return cloneJson(this.globalControls);
|
||||
}
|
||||
}
|
||||
|
||||
class MySqlDashboardStore {
|
||||
constructor(pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
static async create(options = {}) {
|
||||
let mysql;
|
||||
try {
|
||||
// Optional dependency: only loaded when MySQL mode is used.
|
||||
mysql = require("mysql2/promise");
|
||||
} catch (_error) {
|
||||
throw new Error(
|
||||
"mysql2 패키지가 필요합니다. `npm install mysql2` 후 다시 실행하세요."
|
||||
);
|
||||
}
|
||||
|
||||
const pool = mysql.createPool({
|
||||
uri: options.uri,
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
user: options.user,
|
||||
password: options.password,
|
||||
database: options.database,
|
||||
waitForConnections: true,
|
||||
connectionLimit: Number(options.connectionLimit) || 5,
|
||||
queueLimit: 0,
|
||||
charset: "utf8mb4",
|
||||
timezone: "Z",
|
||||
});
|
||||
|
||||
const store = new MySqlDashboardStore(pool);
|
||||
await store.init();
|
||||
return store;
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS watches (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
raw_input TEXT NOT NULL,
|
||||
parsed_params JSON NOT NULL,
|
||||
alert_rules JSON NOT NULL,
|
||||
polling_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
alerts_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
last_snapshot JSON NULL,
|
||||
last_error JSON NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await this.pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS watch_events (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
watch_id VARCHAR(64) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
payload JSON NOT NULL,
|
||||
observed_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_watch_events_watch_id (watch_id, observed_at DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await this.pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
setting_key VARCHAR(64) NOT NULL,
|
||||
setting_value JSON NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL,
|
||||
PRIMARY KEY (setting_key)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.pool.end();
|
||||
}
|
||||
|
||||
async listWatches() {
|
||||
const [rows] = await this.pool.query(`
|
||||
SELECT
|
||||
id,
|
||||
raw_input,
|
||||
parsed_params,
|
||||
alert_rules,
|
||||
polling_enabled,
|
||||
alerts_enabled,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_snapshot,
|
||||
last_error
|
||||
FROM watches
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
rawInput: row.raw_input,
|
||||
searchParams: parseJsonColumn(row.parsed_params, {}),
|
||||
alertRules: parseJsonColumn(row.alert_rules, {}),
|
||||
pollingEnabled: toBoolean(row.polling_enabled, true),
|
||||
alertsEnabled: toBoolean(row.alerts_enabled, true),
|
||||
createdAt: fromSqlDateTime(row.created_at),
|
||||
updatedAt: fromSqlDateTime(row.updated_at),
|
||||
lastSnapshot: parseJsonColumn(row.last_snapshot, null),
|
||||
lastError: parseJsonColumn(row.last_error, null),
|
||||
}));
|
||||
}
|
||||
|
||||
async getWatch(watchId) {
|
||||
const [rows] = await this.pool.query(
|
||||
`
|
||||
SELECT
|
||||
id,
|
||||
raw_input,
|
||||
parsed_params,
|
||||
alert_rules,
|
||||
polling_enabled,
|
||||
alerts_enabled,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_snapshot,
|
||||
last_error
|
||||
FROM watches
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[watchId]
|
||||
);
|
||||
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0];
|
||||
return {
|
||||
id: row.id,
|
||||
rawInput: row.raw_input,
|
||||
searchParams: parseJsonColumn(row.parsed_params, {}),
|
||||
alertRules: parseJsonColumn(row.alert_rules, {}),
|
||||
pollingEnabled: toBoolean(row.polling_enabled, true),
|
||||
alertsEnabled: toBoolean(row.alerts_enabled, true),
|
||||
createdAt: fromSqlDateTime(row.created_at),
|
||||
updatedAt: fromSqlDateTime(row.updated_at),
|
||||
lastSnapshot: parseJsonColumn(row.last_snapshot, null),
|
||||
lastError: parseJsonColumn(row.last_error, null),
|
||||
};
|
||||
}
|
||||
|
||||
async saveWatch(watch) {
|
||||
const createdAt = watch.createdAt || new Date().toISOString();
|
||||
const updatedAt = watch.updatedAt || createdAt;
|
||||
|
||||
await this.pool.query(
|
||||
`
|
||||
INSERT INTO watches (
|
||||
id,
|
||||
raw_input,
|
||||
parsed_params,
|
||||
alert_rules,
|
||||
polling_enabled,
|
||||
alerts_enabled,
|
||||
created_at,
|
||||
updated_at,
|
||||
last_snapshot,
|
||||
last_error
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
raw_input = VALUES(raw_input),
|
||||
parsed_params = VALUES(parsed_params),
|
||||
alert_rules = VALUES(alert_rules),
|
||||
polling_enabled = VALUES(polling_enabled),
|
||||
alerts_enabled = VALUES(alerts_enabled),
|
||||
updated_at = VALUES(updated_at),
|
||||
last_snapshot = VALUES(last_snapshot),
|
||||
last_error = VALUES(last_error)
|
||||
`,
|
||||
[
|
||||
watch.id,
|
||||
watch.rawInput || "",
|
||||
JSON.stringify(watch.searchParams || {}),
|
||||
JSON.stringify(watch.alertRules || {}),
|
||||
watch.pollingEnabled === false ? 0 : 1,
|
||||
watch.alertsEnabled === false ? 0 : 1,
|
||||
toSqlDateTime(createdAt),
|
||||
toSqlDateTime(updatedAt),
|
||||
watch.lastSnapshot ? JSON.stringify(watch.lastSnapshot) : null,
|
||||
watch.lastError ? JSON.stringify(watch.lastError) : null,
|
||||
]
|
||||
);
|
||||
|
||||
return this.getWatch(watch.id);
|
||||
}
|
||||
|
||||
async deleteWatch(watchId) {
|
||||
const [result] = await this.pool.query(`DELETE FROM watches WHERE id = ?`, [watchId]);
|
||||
return result.affectedRows > 0;
|
||||
}
|
||||
|
||||
async savePollResult(watchId, pollResult) {
|
||||
const updates = [];
|
||||
const params = [];
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
if (pollResult && pollResult.snapshot) {
|
||||
updates.push("last_snapshot = ?");
|
||||
params.push(JSON.stringify(pollResult.snapshot));
|
||||
updates.push("last_error = NULL");
|
||||
}
|
||||
|
||||
if (pollResult && pollResult.error) {
|
||||
updates.push("last_error = ?");
|
||||
params.push(JSON.stringify(pollResult.error));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?");
|
||||
params.push(toSqlDateTime(nowIso));
|
||||
params.push(watchId);
|
||||
|
||||
await this.pool.query(
|
||||
`
|
||||
UPDATE watches
|
||||
SET ${updates.join(", ")}
|
||||
WHERE id = ?
|
||||
`,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
async saveEvent(event) {
|
||||
const createdAt = new Date().toISOString();
|
||||
const observedAt = event.observedAt || createdAt;
|
||||
|
||||
const [result] = await this.pool.query(
|
||||
`
|
||||
INSERT INTO watch_events (watch_id, event_type, payload, observed_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`,
|
||||
[
|
||||
event.watchId,
|
||||
event.eventType || "unknown",
|
||||
JSON.stringify(event.payload || {}),
|
||||
toSqlDateTime(observedAt),
|
||||
toSqlDateTime(createdAt),
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
id: String(result.insertId),
|
||||
watchId: event.watchId,
|
||||
eventType: event.eventType,
|
||||
payload: cloneJson(event.payload || {}),
|
||||
observedAt,
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async listEvents(limit = 50) {
|
||||
const safeLimit = Math.max(1, Math.min(Number(limit) || 50, 200));
|
||||
const [rows] = await this.pool.query(
|
||||
`
|
||||
SELECT id, watch_id, event_type, payload, observed_at, created_at
|
||||
FROM watch_events
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`,
|
||||
[safeLimit]
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: String(row.id),
|
||||
watchId: row.watch_id,
|
||||
eventType: row.event_type,
|
||||
payload: parseJsonColumn(row.payload, {}),
|
||||
observedAt: fromSqlDateTime(row.observed_at),
|
||||
createdAt: fromSqlDateTime(row.created_at),
|
||||
}));
|
||||
}
|
||||
|
||||
async getGlobalControls() {
|
||||
const [rows] = await this.pool.query(
|
||||
`
|
||||
SELECT setting_value
|
||||
FROM app_settings
|
||||
WHERE setting_key = 'global_controls'
|
||||
LIMIT 1
|
||||
`
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
crawlingEnabled: true,
|
||||
alertsEnabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
const setting = parseJsonColumn(rows[0].setting_value, {});
|
||||
return {
|
||||
crawlingEnabled: toBoolean(setting.crawlingEnabled, true),
|
||||
alertsEnabled: toBoolean(setting.alertsEnabled, true),
|
||||
};
|
||||
}
|
||||
|
||||
async setGlobalControls(patch = {}) {
|
||||
const next = {
|
||||
...(await this.getGlobalControls()),
|
||||
};
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "crawlingEnabled")) {
|
||||
next.crawlingEnabled = toBoolean(patch.crawlingEnabled, next.crawlingEnabled);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(patch, "alertsEnabled")) {
|
||||
next.alertsEnabled = toBoolean(patch.alertsEnabled, next.alertsEnabled);
|
||||
}
|
||||
|
||||
await this.pool.query(
|
||||
`
|
||||
INSERT INTO app_settings (setting_key, setting_value, updated_at)
|
||||
VALUES ('global_controls', ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
setting_value = VALUES(setting_value),
|
||||
updated_at = VALUES(updated_at)
|
||||
`,
|
||||
[JSON.stringify(next), toSqlDateTime(new Date().toISOString())]
|
||||
);
|
||||
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
function parsePort(rawPort, fallback) {
|
||||
const n = Number(rawPort);
|
||||
if (!Number.isInteger(n) || n <= 0) return fallback;
|
||||
return n;
|
||||
}
|
||||
|
||||
async function createDashboardStore(options = {}) {
|
||||
const modeRaw = options.mode || process.env.DASHBOARD_DB || "";
|
||||
const mode = typeof modeRaw === "string" ? modeRaw.trim().toLowerCase() : "";
|
||||
const isStrictMySqlMode = mode === "mysql";
|
||||
const prefersMySql =
|
||||
isStrictMySqlMode ||
|
||||
Boolean(process.env.MYSQL_URL) ||
|
||||
Boolean(process.env.MYSQL_HOST && process.env.MYSQL_USER && process.env.MYSQL_DATABASE);
|
||||
|
||||
if (!prefersMySql || mode === "memory") {
|
||||
const store = new InMemoryDashboardStore();
|
||||
await store.init();
|
||||
return {
|
||||
engine: "memory",
|
||||
store,
|
||||
warning: null,
|
||||
};
|
||||
}
|
||||
|
||||
const mysqlOptions = {
|
||||
uri: options.mysqlUrl || process.env.MYSQL_URL,
|
||||
host: options.mysqlHost || process.env.MYSQL_HOST,
|
||||
port: parsePort(options.mysqlPort || process.env.MYSQL_PORT, 3306),
|
||||
user: options.mysqlUser || process.env.MYSQL_USER,
|
||||
password: options.mysqlPassword || process.env.MYSQL_PASSWORD,
|
||||
database: options.mysqlDatabase || process.env.MYSQL_DATABASE,
|
||||
connectionLimit: options.mysqlConnectionLimit || process.env.MYSQL_CONNECTION_LIMIT,
|
||||
};
|
||||
|
||||
if (!mysqlOptions.uri && (!mysqlOptions.host || !mysqlOptions.user || !mysqlOptions.database)) {
|
||||
if (isStrictMySqlMode) {
|
||||
throw new Error(
|
||||
"MySQL 연결 정보가 필요합니다. MYSQL_URL 또는 MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하세요."
|
||||
);
|
||||
}
|
||||
|
||||
const fallbackStore = new InMemoryDashboardStore();
|
||||
await fallbackStore.init();
|
||||
return {
|
||||
engine: "memory",
|
||||
store: fallbackStore,
|
||||
warning:
|
||||
"MySQL 환경변수가 없어 메모리 저장소를 사용합니다. MYSQL_HOST/MYSQL_USER/MYSQL_DATABASE를 설정하면 MySQL로 전환됩니다.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const store = await MySqlDashboardStore.create(mysqlOptions);
|
||||
return {
|
||||
engine: "mysql",
|
||||
store,
|
||||
warning: null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isStrictMySqlMode) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const fallbackStore = new InMemoryDashboardStore();
|
||||
await fallbackStore.init();
|
||||
return {
|
||||
engine: "memory",
|
||||
store: fallbackStore,
|
||||
warning: `MySQL 초기화 실패로 메모리 저장소를 사용합니다: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
InMemoryDashboardStore,
|
||||
MySqlDashboardStore,
|
||||
createDashboardStore,
|
||||
};
|
||||
Reference in New Issue
Block a user