Files
air-watcher/src/dashboardStore.js
2026-02-19 17:28:58 +09:00

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,
};