feat: complete Oracle and MariaDB WebView data layer
This commit is contained in:
421
Web/app.js
421
Web/app.js
@@ -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 없이 브라우저 모드로 실행");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<div class="sidebar-status">
|
||||
<div class="status-row"><span class="status-dot ok"></span><span>Native Bridge</span><strong id="bridgeState">연결 중</strong></div>
|
||||
<div class="status-row"><span class="status-dot warn"></span><span>Database</span><strong id="databaseState">미설정</strong></div>
|
||||
<div class="status-row"><span id="databaseStateDot" class="status-dot pending"></span><span>Database</span><strong id="databaseState">확인 중</strong></div>
|
||||
<div class="runtime" id="runtimeLabel">.NET 8 · WinUI 3 · x64</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -54,7 +54,7 @@
|
||||
<section class="summary-strip" aria-label="마이그레이션 상태">
|
||||
<article><span class="metric-icon mint">✓</span><div><strong>WebView UI</strong><small>FarPoint 대체 기반</small></div><b>READY</b></article>
|
||||
<article><span class="metric-icon blue">71</span><div><strong>데이터 요청</strong><small>기존 SQL 계층 이관</small></div><b>MIGRATED</b></article>
|
||||
<article><span class="metric-icon amber">DB</span><div><strong>Oracle · MariaDB</strong><small>안전한 어댑터 대기</small></div><b>PENDING</b></article>
|
||||
<article id="databaseSummary"><span id="databaseSummaryIcon" class="metric-icon amber">DB</span><div><strong>Oracle · MariaDB</strong><small id="databaseSummaryDetail">연결 상태 확인 중</small></div><b id="databaseSummaryBadge">CHECKING</b></article>
|
||||
<article><span class="metric-icon violet">T2</span><div><strong>Tornado Engine</strong><small>x64 COM 검증 대기</small></div><b>PENDING</b></article>
|
||||
</section>
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<section class="panel catalog-panel">
|
||||
<div class="panel-header">
|
||||
<div><p class="panel-kicker">CUT CATALOG</p><h2>그래픽 항목</h2></div>
|
||||
<span class="badge">Web UI</span>
|
||||
<span id="catalogModeBadge" class="badge">Web UI</span>
|
||||
</div>
|
||||
<label class="search-box">
|
||||
<span>⌕</span>
|
||||
@@ -75,6 +75,18 @@
|
||||
<button type="button" data-category="chart">차트</button>
|
||||
<button type="button" data-category="market">시장</button>
|
||||
</div>
|
||||
<section id="liveDataPanel" class="live-data-panel" aria-labelledby="liveDataTitle" hidden>
|
||||
<div class="live-data-header">
|
||||
<div>
|
||||
<p class="panel-kicker">LIVE DATABASE</p>
|
||||
<h3 id="liveDataTitle">실시간 시장 데이터</h3>
|
||||
</div>
|
||||
<button id="retryLiveDataButton" class="live-retry" type="button">다시 조회</button>
|
||||
</div>
|
||||
<div id="databaseSources" class="database-sources" aria-label="데이터베이스 연결 상태"></div>
|
||||
<div id="liveDataState" class="live-data-state" role="status" aria-live="polite"></div>
|
||||
<div id="liveDataTables" class="live-data-tables"></div>
|
||||
</section>
|
||||
<div id="catalogList" class="catalog-list" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ button { color: inherit; }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.status-dot.ok { background: var(--mint); box-shadow: 0 0 0 3px rgba(50,213,164,.1); }
|
||||
.status-dot.warn { background: var(--amber); box-shadow: 0 0 0 3px rgba(255,186,85,.1); }
|
||||
.status-dot.pending, .status-dot.unconfigured { background: var(--muted-2); box-shadow: 0 0 0 3px rgba(94,114,139,.1); }
|
||||
.status-dot.healthy { background: var(--mint); box-shadow: 0 0 0 3px rgba(50,213,164,.1); }
|
||||
.status-dot.partial { background: var(--amber); box-shadow: 0 0 0 3px rgba(255,186,85,.1); }
|
||||
.status-dot.error { background: var(--red); box-shadow: 0 0 0 3px rgba(255,102,128,.1); }
|
||||
.runtime { margin-top: 12px; padding-top: 11px; border-top: 1px solid var(--border-soft); color: var(--muted-2); font: 9px/1.4 Consolas, monospace; }
|
||||
|
||||
.workspace { min-width: 0; min-height: 0; overflow: auto; padding: 0 24px 22px; background: radial-gradient(circle at 76% -10%, rgba(44,104,132,.13), transparent 35%), var(--bg); }
|
||||
@@ -119,6 +123,16 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
|
||||
.metric-icon.blue { background: rgba(86,168,255,.12); color: var(--blue); }
|
||||
.metric-icon.amber { background: rgba(255,186,85,.12); color: var(--amber); }
|
||||
.metric-icon.violet { background: rgba(155,140,255,.12); color: var(--violet); }
|
||||
.metric-icon.database-icon.pending, .metric-icon.database-icon.unconfigured { background: rgba(94,114,139,.12); color: var(--muted); }
|
||||
.metric-icon.database-icon.healthy { background: var(--mint-soft); color: var(--mint); }
|
||||
.metric-icon.database-icon.partial { background: rgba(255,186,85,.12); color: var(--amber); }
|
||||
.metric-icon.database-icon.error { background: rgba(255,102,128,.1); color: var(--red); }
|
||||
.database-summary.healthy { border-color: rgba(50,213,164,.22); }
|
||||
.database-summary.partial { border-color: rgba(255,186,85,.2); }
|
||||
.database-summary.error { border-color: rgba(255,102,128,.24); }
|
||||
.database-summary.healthy > b { color: var(--mint); }
|
||||
.database-summary.partial > b { color: var(--amber); }
|
||||
.database-summary.error > b { color: var(--red); }
|
||||
|
||||
.console-grid { display: grid; grid-template-columns: minmax(250px, .73fr) minmax(410px, 1.2fr) minmax(330px, 1fr); gap: 12px; min-height: 680px; height: calc(100vh - 188px); }
|
||||
|
||||
@@ -127,6 +141,7 @@ h1 { margin: 0; font-size: 22px; font-weight: 700; letter-spacing: -.035em; }
|
||||
.panel-header h2 { margin: 0; font-size: 15px; font-weight: 650; }
|
||||
.badge { padding: 4px 7px; border: 1px solid rgba(50,213,164,.22); border-radius: 10px; background: var(--mint-soft); color: var(--mint); font: 8px Consolas, monospace; letter-spacing: .05em; }
|
||||
.badge.neutral { border-color: var(--border); background: var(--surface-2); color: var(--muted); }
|
||||
.badge.live { border-color: rgba(50,213,164,.36); background: rgba(50,213,164,.16); box-shadow: 0 0 0 3px rgba(50,213,164,.04); }
|
||||
|
||||
.search-box { display: grid; grid-template-columns: 20px 1fr auto; align-items: center; gap: 6px; height: 40px; margin: 13px 13px 10px; padding: 0 10px; border: 1px solid var(--border); border-radius: 8px; background: #091522; }
|
||||
.search-box > span { color: var(--muted); font-size: 17px; }
|
||||
@@ -137,6 +152,52 @@ kbd { padding: 3px 5px; border: 1px solid #283c55; border-bottom-width: 2px; bor
|
||||
.catalog-tabs { display: flex; gap: 5px; padding: 0 13px 11px; border-bottom: 1px solid var(--border-soft); }
|
||||
.catalog-tabs button { padding: 5px 9px; border: 1px solid transparent; border-radius: 6px; background: transparent; color: var(--muted); font-size: 10px; cursor: pointer; }
|
||||
.catalog-tabs button.active { border-color: var(--border); background: var(--surface-3); color: white; }
|
||||
.live-data-panel { display: flex; min-height: 235px; max-height: 54%; flex: 0 1 365px; flex-direction: column; border-bottom: 1px solid var(--border); background: #091522; overflow: hidden; }
|
||||
.live-data-header { display: flex; min-height: 51px; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px 7px; border-bottom: 1px solid var(--border-soft); }
|
||||
.live-data-header h3 { margin: 0; color: #dfe9f5; font-size: 11px; font-weight: 650; }
|
||||
.live-data-header .panel-kicker { margin-bottom: 3px; }
|
||||
.live-retry { min-height: 27px; padding: 0 9px; border: 1px solid #29415b; border-radius: 6px; background: var(--surface-2); color: #aebed0; font-size: 9px; cursor: pointer; }
|
||||
.live-retry:hover { border-color: var(--mint); color: var(--mint); }
|
||||
.database-sources { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; padding: 8px 10px 0; }
|
||||
.database-source { display: grid; grid-template-columns: 7px auto minmax(0, 1fr); align-items: center; min-width: 0; gap: 5px; min-height: 28px; padding: 0 7px; border: 1px solid var(--border-soft); border-radius: 6px; background: rgba(17,31,50,.72); }
|
||||
.database-source .source-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--muted-2); }
|
||||
.database-source strong { color: #aebed0; font-size: 8px; }
|
||||
.database-source small { overflow: hidden; color: var(--muted-2); font-size: 8px; text-align: right; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.database-source.healthy { border-color: rgba(50,213,164,.17); }
|
||||
.database-source.healthy .source-dot { background: var(--mint); box-shadow: 0 0 0 2px rgba(50,213,164,.09); }
|
||||
.database-source.healthy small { color: #79d9bd; }
|
||||
.database-source.error { border-color: rgba(255,102,128,.2); }
|
||||
.database-source.error .source-dot { background: var(--red); box-shadow: 0 0 0 2px rgba(255,102,128,.08); }
|
||||
.database-source.error small { color: #dc8796; }
|
||||
.database-source.pending .source-dot { animation: live-pulse 1.1s ease-in-out infinite; background: var(--blue); }
|
||||
.database-source.unconfigured small { color: #657991; }
|
||||
.live-data-state { display: flex; min-height: 42px; align-items: center; gap: 9px; margin: 7px 10px 0; padding: 7px 9px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(13,25,41,.76); color: #b7c6d7; }
|
||||
.live-data-state:empty { display: none; }
|
||||
.live-data-state strong, .live-data-state small { display: block; }
|
||||
.live-data-state strong { margin-bottom: 2px; font-size: 9px; }
|
||||
.live-data-state small { overflow: hidden; color: var(--muted-2); font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.live-data-state.error { border-color: rgba(255,102,128,.22); background: rgba(255,102,128,.055); }
|
||||
.live-data-state.error strong { color: #ff9bad; }
|
||||
.live-data-state.ready { border-color: rgba(50,213,164,.18); background: rgba(50,213,164,.055); }
|
||||
.live-data-state.ready strong { color: #8bdcc4; }
|
||||
.live-spinner { width: 16px; height: 16px; flex: 0 0 auto; border: 2px solid rgba(86,168,255,.2); border-top-color: var(--blue); border-radius: 50%; animation: live-spin .8s linear infinite; }
|
||||
.live-error-icon { display: grid; width: 17px; height: 17px; flex: 0 0 auto; place-items: center; border-radius: 50%; background: rgba(255,102,128,.13); color: var(--red); font: 700 10px Consolas, monospace; }
|
||||
.live-data-tables { min-height: 0; flex: 1; overflow-y: auto; padding: 7px 10px 10px; }
|
||||
.live-table-card { margin-bottom: 7px; border: 1px solid var(--border-soft); border-radius: 7px; background: rgba(13,25,41,.7); overflow: hidden; }
|
||||
.live-table-card:last-child { margin-bottom: 0; }
|
||||
.live-table-card > header { display: flex; min-height: 36px; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.live-table-card h4 { margin: 0 0 2px; color: #cbd8e7; font-size: 9px; }
|
||||
.live-table-card header small { display: block; color: var(--muted-2); font-size: 7px; }
|
||||
.live-source-badge { padding: 3px 5px; border: 1px solid rgba(86,168,255,.2); border-radius: 5px; background: rgba(86,168,255,.08); color: #79b7f8; font: 7px Consolas, monospace; }
|
||||
.live-table-scroll { max-height: 145px; overflow: auto; }
|
||||
.live-table { width: max-content; min-width: 100%; table-layout: auto; }
|
||||
.live-table th { height: 27px; min-width: 76px; padding: 0 7px; background: #0c1a29; color: #70849c; font-size: 7px; white-space: nowrap; }
|
||||
.live-table td { max-width: 210px; height: 27px; padding: 0 7px; overflow: hidden; color: #adbdcf; font: 8px Consolas, "Malgun Gothic", sans-serif; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.live-table tbody tr { cursor: default; }
|
||||
.live-table tbody tr:hover { background: rgba(86,168,255,.035); }
|
||||
.live-table-empty { margin: 0; padding: 15px 8px; color: var(--muted-2); font-size: 8px; text-align: center; }
|
||||
@keyframes live-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes live-pulse { 50% { opacity: .35; } }
|
||||
.catalog-list { min-height: 0; flex: 1; overflow-y: auto; padding: 8px; }
|
||||
.catalog-item { display: grid; grid-template-columns: 34px 1fr 27px; align-items: center; min-height: 50px; gap: 9px; padding: 6px 7px; border: 1px solid transparent; border-radius: 8px; }
|
||||
.catalog-item:hover { border-color: var(--border); background: rgba(255,255,255,.025); }
|
||||
|
||||
Reference in New Issue
Block a user