563 lines
15 KiB
JavaScript
563 lines
15 KiB
JavaScript
"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,
|
|
};
|