feat: complete Oracle and MariaDB WebView data layer

This commit is contained in:
2026-07-10 05:33:19 +09:00
parent 5aa90e4aaa
commit 39c4504b87
44 changed files with 3956 additions and 46 deletions

View File

@@ -24,6 +24,8 @@
index: "지수 그래픽", kospi: "코스피 그래픽", kosdaq: "코스닥 그래픽",
compare: "종목 비교", theme: "테마 관리", expert: "전문가 추천", halted: "거래 정지"
};
const liveDataViews = new Set(["kospi", "kosdaq", "index", "overseas"]);
const sourceLabels = { oracle: "Oracle", mariaDb: "MariaDB" };
const storageKey = "mbn-stock-webview.playlist.v1";
const state = {
@@ -34,15 +36,34 @@
selectedRows: new Set(),
currentIndex: -1,
dragIndex: -1,
playout: "IDLE"
playout: "IDLE",
database: { sources: [], loading: true, lastSignature: "" },
liveData: {
requestId: null,
view: null,
status: "idle",
tables: [],
retrievedAt: null,
error: "",
timeoutId: null
}
};
const elements = {
bridgeState: document.querySelector("#bridgeState"), databaseState: document.querySelector("#databaseState"),
databaseStateDot: document.querySelector("#databaseStateDot"),
databaseSummary: document.querySelector("#databaseSummary"),
databaseSummaryIcon: document.querySelector("#databaseSummaryIcon"),
databaseSummaryDetail: document.querySelector("#databaseSummaryDetail"),
databaseSummaryBadge: document.querySelector("#databaseSummaryBadge"),
runtimeLabel: document.querySelector("#runtimeLabel"), packageLabel: document.querySelector("#packageLabel"),
workspaceTitle: document.querySelector("#workspaceTitle"), clock: document.querySelector("#clock"),
catalogSearch: document.querySelector("#catalogSearch"), catalogTabs: document.querySelector("#catalogTabs"),
catalogList: document.querySelector("#catalogList"), playlistBody: document.querySelector("#playlistBody"),
catalogList: document.querySelector("#catalogList"), catalogModeBadge: document.querySelector("#catalogModeBadge"),
liveDataPanel: document.querySelector("#liveDataPanel"), liveDataTitle: document.querySelector("#liveDataTitle"),
liveDataState: document.querySelector("#liveDataState"), liveDataTables: document.querySelector("#liveDataTables"),
databaseSources: document.querySelector("#databaseSources"),
playlistBody: document.querySelector("#playlistBody"),
playlistCount: document.querySelector("#playlistCount"), playlistEmpty: document.querySelector("#playlistEmpty"),
previewTitle: document.querySelector("#previewTitle"), previewSubtitle: document.querySelector("#previewSubtitle"),
playoutState: document.querySelector("#playoutState"), eventLog: document.querySelector("#eventLog"),
@@ -324,15 +345,377 @@
nativeBridge?.postMessage({ type, payload });
}
function normalizeSourceName(source) {
const value = String(source || "").toLocaleLowerCase("en-US");
if (value === "oracle") return "oracle";
if (value === "mariadb" || value === "maria_db" || value === "maria-db") return "mariaDb";
return String(source || "unknown");
}
function normalizedDatabaseSources(sources) {
const received = new Map((Array.isArray(sources) ? sources : []).map(source => [normalizeSourceName(source?.source), source]));
return ["oracle", "mariaDb"].map(name => {
const source = received.get(name);
return {
source: name,
configured: Boolean(source?.configured),
healthy: Boolean(source?.healthy),
message: typeof source?.message === "string" ? source.message : "",
checkedAt: source?.checkedAt || null,
latencyMs: source?.latencyMs !== null && source?.latencyMs !== undefined && Number.isFinite(Number(source.latencyMs))
? Number(source.latencyMs)
: null
};
});
}
function sourceHealthClass(source) {
if (state.database.loading && !state.database.sources.length) return "pending";
if (!source.configured) return "unconfigured";
return source.healthy ? "healthy" : "error";
}
function sourceHealthText(source) {
if (state.database.loading && !state.database.sources.length) return "확인 중";
if (!source.configured) return "미설정";
if (!source.healthy) return "연결 오류";
return source.latencyMs === null ? "정상" : `정상 · ${Math.round(source.latencyMs)}ms`;
}
function renderDatabaseSources() {
elements.databaseSources.replaceChildren();
const sources = state.database.sources.length
? state.database.sources
: normalizedDatabaseSources([]);
for (const source of sources) {
const chip = document.createElement("div");
const healthClass = sourceHealthClass(source);
chip.className = `database-source ${healthClass}`;
if (source.message) chip.title = source.message;
const dot = document.createElement("span");
dot.className = "source-dot";
const label = document.createElement("strong");
label.textContent = sourceLabels[source.source] || source.source;
const status = document.createElement("small");
status.textContent = sourceHealthText(source);
chip.append(dot, label, status);
elements.databaseSources.append(chip);
}
}
function renderDatabaseStatus() {
const sources = state.database.sources;
const configured = sources.filter(source => source.configured);
const healthy = configured.filter(source => source.healthy);
let stateClass = "pending";
let stateText = "확인 중";
let detail = "연결 상태 확인 중";
let badge = "CHECKING";
if (!state.database.loading || sources.length) {
if (!configured.length) {
stateClass = "unconfigured";
stateText = "미설정";
detail = "Oracle 미설정 · MariaDB 미설정";
badge = "SETUP";
} else if (healthy.length === sources.length && sources.length === 2) {
stateClass = "healthy";
stateText = "정상 (2/2)";
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
badge = "CONNECTED";
} else if (configured.some(source => !source.healthy)) {
stateClass = "error";
stateText = `오류 (${healthy.length}/${configured.length})`;
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
badge = "ERROR";
} else {
stateClass = "partial";
stateText = `일부 연결 (${healthy.length}/2)`;
detail = sources.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`).join(" · ");
badge = "PARTIAL";
}
}
elements.databaseState.textContent = stateText;
elements.databaseStateDot.className = `status-dot ${stateClass}`;
elements.databaseSummary.className = `database-summary ${stateClass}`;
elements.databaseSummaryIcon.className = `metric-icon database-icon ${stateClass}`;
elements.databaseSummaryDetail.textContent = detail;
elements.databaseSummaryBadge.textContent = badge;
renderDatabaseSources();
}
function requestDatabaseStatus() {
if (!nativeBridge) return;
state.database.loading = true;
renderDatabaseStatus();
postNative("request-database-status", { requestId: createId() });
}
function handleDatabaseStatus(payload) {
state.database.sources = normalizedDatabaseSources(payload?.sources);
state.database.loading = false;
renderDatabaseStatus();
const signature = state.database.sources.map(source => `${source.source}:${source.configured}:${source.healthy}`).join("|");
if (signature !== state.database.lastSignature) {
const message = state.database.sources
.map(source => `${sourceLabels[source.source]} ${sourceHealthText(source)}`)
.join(" · ");
addLog(`DB 상태 · ${message}`);
state.database.lastSignature = signature;
}
}
function clearLiveDataTimeout() {
if (state.liveData.timeoutId !== null) clearTimeout(state.liveData.timeoutId);
state.liveData.timeoutId = null;
}
function formatDateTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value);
return new Intl.DateTimeFormat("ko-KR", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false
}).format(date);
}
function normalizeColumns(table) {
let columns = Array.isArray(table?.columns) ? table.columns : [];
const firstRow = Array.isArray(table?.rows) ? table.rows[0] : null;
if (!columns.length && firstRow && !Array.isArray(firstRow) && typeof firstRow === "object") {
columns = Object.keys(firstRow);
}
if (!columns.length && Array.isArray(firstRow)) {
columns = firstRow.map((_, index) => `${index + 1}`);
}
return columns.map((column, index) => {
if (typeof column === "string") return { key: column, label: column };
const key = column?.key ?? column?.name ?? column?.field ?? column?.dataPropertyName ?? `column-${index}`;
const label = column?.label ?? column?.displayName ?? column?.title ?? column?.name ?? key;
return { key: String(key), label: String(label) };
});
}
function cellValue(row, column, index) {
if (Array.isArray(row)) return row[index];
if (row && typeof row === "object") return row[column.key];
return index === 0 ? row : null;
}
function formatCellValue(value) {
if (value === null || value === undefined || value === "") return "—";
if (typeof value === "number") return value.toLocaleString("ko-KR");
if (typeof value === "boolean") return value ? "예" : "아니요";
if (typeof value === "object") {
try { return JSON.stringify(value); } catch { return String(value); }
}
return String(value);
}
function renderLiveTables() {
elements.liveDataTables.replaceChildren();
for (const table of state.liveData.tables) {
const section = document.createElement("section");
section.className = "live-table-card";
const header = document.createElement("header");
const heading = document.createElement("div");
const name = document.createElement("h4");
name.textContent = table?.name || "시장 데이터";
const meta = document.createElement("small");
const rows = Array.isArray(table?.rows) ? table.rows : [];
const total = Number.isFinite(Number(table?.totalRowCount)) ? Number(table.totalRowCount) : rows.length;
meta.textContent = `전체 ${total.toLocaleString("ko-KR")}건 · 표시 ${rows.length.toLocaleString("ko-KR")}`;
heading.append(name, meta);
const source = document.createElement("span");
source.className = "live-source-badge";
const sourceName = normalizeSourceName(table?.source);
source.textContent = sourceLabels[sourceName] || table?.source || "DB";
header.append(heading, source);
section.append(header);
if (!rows.length) {
const empty = document.createElement("p");
empty.className = "live-table-empty";
empty.textContent = "조회된 행이 없습니다.";
section.append(empty);
elements.liveDataTables.append(section);
continue;
}
const columns = normalizeColumns(table);
const scroll = document.createElement("div");
scroll.className = "live-table-scroll";
const grid = document.createElement("table");
grid.className = "live-table";
const head = document.createElement("thead");
const headRow = document.createElement("tr");
for (const column of columns) {
const th = document.createElement("th");
th.textContent = column.label;
headRow.append(th);
}
head.append(headRow);
const body = document.createElement("tbody");
for (const row of rows) {
const tr = document.createElement("tr");
columns.forEach((column, index) => {
const td = document.createElement("td");
td.textContent = formatCellValue(cellValue(row, column, index));
tr.append(td);
});
body.append(tr);
}
grid.append(head, body);
scroll.append(grid);
section.append(scroll);
elements.liveDataTables.append(section);
}
}
function renderLiveData() {
const visible = liveDataViews.has(state.activeMarket);
elements.liveDataPanel.hidden = !visible;
elements.catalogModeBadge.textContent = visible ? "DB LIVE" : "Web UI";
elements.catalogModeBadge.classList.toggle("live", visible);
if (!visible) return;
elements.liveDataTitle.textContent = `${marketTitles[state.activeMarket]} · DB 조회`;
elements.liveDataState.replaceChildren();
elements.liveDataState.className = `live-data-state ${state.liveData.status}`;
if (state.liveData.status === "loading") {
const spinner = document.createElement("span");
spinner.className = "live-spinner";
const copy = document.createElement("div");
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = "데이터베이스에서 조회 중입니다.";
small.textContent = "연결 상태와 시장 데이터를 확인하고 있습니다.";
copy.append(strong, small);
elements.liveDataState.append(spinner, copy);
} else if (state.liveData.status === "error") {
const icon = document.createElement("span");
icon.className = "live-error-icon";
icon.textContent = "!";
const copy = document.createElement("div");
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = "시장 데이터를 조회하지 못했습니다.";
small.textContent = state.liveData.error || "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
copy.append(strong, small);
elements.liveDataState.append(icon, copy);
} else if (state.liveData.status === "ready") {
const totalRows = state.liveData.tables.reduce((sum, table) => {
const count = Number(table?.totalRowCount);
return sum + (Number.isFinite(count) ? count : (Array.isArray(table?.rows) ? table.rows.length : 0));
}, 0);
const strong = document.createElement("strong");
const small = document.createElement("small");
strong.textContent = state.liveData.tables.length
? `${state.liveData.tables.length}개 데이터셋 · 전체 ${totalRows.toLocaleString("ko-KR")}`
: "조회 결과가 없습니다.";
small.textContent = state.liveData.retrievedAt ? `조회 시각 ${formatDateTime(state.liveData.retrievedAt)}` : "조회 완료";
elements.liveDataState.append(strong, small);
}
renderDatabaseSources();
renderLiveTables();
}
function requestMarketData(view = state.activeMarket) {
clearLiveDataTimeout();
if (!liveDataViews.has(view)) {
state.liveData.requestId = null;
state.liveData.view = null;
state.liveData.status = "idle";
state.liveData.tables = [];
renderLiveData();
return;
}
const requestId = createId();
state.liveData.requestId = requestId;
state.liveData.view = view;
state.liveData.status = "loading";
state.liveData.tables = [];
state.liveData.retrievedAt = null;
state.liveData.error = "";
renderLiveData();
if (!nativeBridge) {
state.liveData.status = "error";
state.liveData.error = "브라우저 미리보기에서는 실제 DB 데이터를 조회할 수 없습니다.";
renderLiveData();
return;
}
postNative("request-market-data", { requestId, view, maximumRowsPerTable: 250 });
state.liveData.timeoutId = setTimeout(() => {
if (state.liveData.requestId !== requestId || state.liveData.status !== "loading") return;
state.liveData.status = "error";
state.liveData.error = "DB 응답 시간이 초과되었습니다. 연결 상태를 확인하고 다시 조회하세요.";
renderLiveData();
addLog(`${marketTitles[view]} DB 조회 시간 초과`);
}, 30000);
}
function handleMarketData(payload) {
if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
clearLiveDataTimeout();
state.liveData.status = "ready";
state.liveData.tables = Array.isArray(payload.tables) ? payload.tables : [];
state.liveData.retrievedAt = payload.retrievedAt || new Date().toISOString();
state.liveData.error = "";
renderLiveData();
const shownRows = state.liveData.tables.reduce((sum, table) => sum + (Array.isArray(table?.rows) ? table.rows.length : 0), 0);
addLog(`${marketTitles[payload.view]} DB 조회 완료 · ${shownRows.toLocaleString("ko-KR")}행 표시`);
}
function handleMarketDataError(payload) {
if (!payload || payload.requestId !== state.liveData.requestId || payload.view !== state.liveData.view) return;
clearLiveDataTimeout();
state.liveData.status = "error";
state.liveData.tables = [];
state.liveData.error = typeof payload.message === "string" && payload.message.trim()
? payload.message.trim()
: "데이터베이스 연결을 확인한 뒤 다시 조회하세요.";
renderLiveData();
addLog(`${marketTitles[payload.view]} DB 조회 실패 · ${state.liveData.error}`);
}
function handleNativeMessage(event) {
const message = event.data;
if (!message || message.type !== "app-info") return;
const info = message.payload;
elements.bridgeState.textContent = "연결됨";
elements.databaseState.textContent = info.databaseConfigured ? "연결됨" : "미설정";
elements.runtimeLabel.textContent = `${info.framework} · ${info.architecture}`;
elements.packageLabel.textContent = `${info.package} · v${info.version}`;
addLog(`Native Bridge 연결 · Windows App SDK ${info.windowsAppSdk}`);
let message = event.data;
if (typeof message === "string") {
try { message = JSON.parse(message); } catch { return; }
}
if (!message || typeof message.type !== "string") return;
switch (message.type) {
case "app-info": {
const info = message.payload || {};
elements.bridgeState.textContent = "연결됨";
elements.runtimeLabel.textContent = `${info.framework || ".NET 8"} · ${info.architecture || "x64"}`;
elements.packageLabel.textContent = `${info.package || "MSIX"}${info.version ? ` · v${info.version}` : ""}`;
addLog(`Native Bridge 연결${info.windowsAppSdk ? ` · Windows App SDK ${info.windowsAppSdk}` : ""}`);
break;
}
case "database-status":
handleDatabaseStatus(message.payload);
break;
case "market-data":
handleMarketData(message.payload);
break;
case "market-data-error":
handleMarketDataError(message.payload);
break;
}
}
function bindEvents() {
@@ -343,6 +726,8 @@
state.activeMarket = button.dataset.market;
elements.workspaceTitle.textContent = marketTitles[state.activeMarket];
renderCatalog();
requestMarketData(state.activeMarket);
if (liveDataViews.has(state.activeMarket)) requestDatabaseStatus();
});
elements.catalogTabs.addEventListener("click", event => {
@@ -365,7 +750,15 @@
document.querySelector("#nextButton").addEventListener("click", next);
document.querySelector("#takeOutButton").addEventListener("click", takeOut);
document.querySelector("#clearLogButton").addEventListener("click", () => elements.eventLog.replaceChildren());
document.querySelector("#requestInfoButton").addEventListener("click", () => postNative("request-app-info"));
document.querySelector("#requestInfoButton").addEventListener("click", () => {
postNative("request-app-info");
requestDatabaseStatus();
if (liveDataViews.has(state.activeMarket)) requestMarketData(state.activeMarket);
});
document.querySelector("#retryLiveDataButton").addEventListener("click", () => {
requestDatabaseStatus();
requestMarketData(state.activeMarket);
});
document.addEventListener("keydown", event => {
if (event.ctrlKey && event.key.toLowerCase() === "k") {
@@ -381,6 +774,8 @@
bindEvents();
renderCatalog();
renderPlaylist();
renderDatabaseStatus();
renderLiveData();
updateClock();
setInterval(updateClock, 1000);
addLog("WebView 운영 화면 시작");
@@ -388,8 +783,12 @@
if (nativeBridge) {
nativeBridge.addEventListener("message", handleNativeMessage);
postNative("ready");
postNative("request-app-info");
requestDatabaseStatus();
} else {
elements.bridgeState.textContent = "브라우저 미리보기";
state.database.loading = false;
renderDatabaseStatus();
addLog("Native Bridge 없이 브라우저 모드로 실행");
}
}