feat: add native runtime settings

This commit is contained in:
2026-07-21 12:07:18 +09:00
parent b6208c0971
commit fc4007d676
25 changed files with 3543 additions and 63 deletions

View File

@@ -20,6 +20,39 @@
const movingAverage5 = document.getElementById("moving-average-5");
const movingAverage20 = document.getElementById("moving-average-20");
const status = document.getElementById("operator-status");
const operatorSettingsMessage = document.getElementById("operator-settings-message");
const operatorSettingsRestart = document.getElementById("operator-settings-restart");
const operatorNavigationExpanded = document.getElementById("operator-navigation-expanded");
const operatorSettingsAppVersion = document.getElementById("operator-settings-app-version");
const operatorSettingsPlayoutMode = document.getElementById("operator-settings-playout-mode");
const operatorSettingsPlayoutConnection = document.getElementById(
"operator-settings-playout-connection");
const operatorSettingsOracleState = document.getElementById("operator-settings-oracle-state");
const operatorSettingsMariaState = document.getElementById("operator-settings-maria-state");
const operatorSettingsDbMonitor = document.getElementById("operator-settings-db-monitor");
const operatorFolderControls = Object.freeze({
design: Object.freeze({
name: document.getElementById("operator-design-folder-name"),
status: document.getElementById("operator-design-folder-status"),
source: document.getElementById("operator-design-folder-source"),
select: document.getElementById("operator-design-folder-select"),
reset: document.getElementById("operator-design-folder-reset")
}),
resource: Object.freeze({
name: document.getElementById("operator-resource-folder-name"),
status: document.getElementById("operator-resource-folder-status"),
source: document.getElementById("operator-resource-folder-source"),
select: document.getElementById("operator-resource-folder-select"),
reset: document.getElementById("operator-resource-folder-reset")
}),
background: Object.freeze({
name: document.getElementById("operator-background-folder-name"),
status: document.getElementById("operator-background-folder-status"),
source: document.getElementById("operator-background-folder-source"),
select: document.getElementById("operator-background-folder-select"),
reset: document.getElementById("operator-background-folder-reset")
})
});
const dialog = document.getElementById("legacy-dialog");
const dialogCaption = document.getElementById("legacy-dialog-caption");
const dialogMessage = document.getElementById("legacy-dialog-message");
@@ -156,6 +189,11 @@
let manualFinancialRenderStateKey = "";
let manualListRenderStateKey = "";
let operatorCatalogRenderStateKey = "";
let operatorSettingsRenderRevision = null;
let operatorSettingsRevisionInitialized = false;
let pendingOperatorSettingsFocusId = null;
let pendingOperatorSettingsFallbackFocusId = null;
let operatorSettingsWasBusy = false;
let renderedTreeTabId = null;
let treeInteractionLocked = false;
let treeFocusOwnedBeforeRender = false;
@@ -3681,6 +3719,167 @@
}
}
function operatorSettingsText(value, fallback) {
if (typeof value === "string" && value.trim()) return value.trim();
if (value && typeof value === "object") {
const candidate = value.displayName || value.label || value.status || value.message;
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
}
return fallback;
}
function operatorDiagnosticTone(value, label) {
if (value && typeof value === "object") {
if (value.isConnected === true || value.isHealthy === true) return "good";
if (value.isConnected === false || value.isHealthy === false) return "bad";
}
const normalized = String(label || "").toLowerCase();
if (/연결됨|정상|사용 가능|ready|connected|healthy/.test(normalized)) return "good";
if (/확인 중|재연결|대기|dry run|감지됨|unknown|waiting/.test(normalized)) {
return "warning";
}
if (/끊김|미연결|오류|실패|불명확|disconnected|fault|error|failed/.test(normalized)) {
return "bad";
}
return "neutral";
}
function setOperatorDiagnostic(element, value, fallback) {
const label = operatorSettingsText(value, fallback);
if (element.textContent !== label) element.textContent = label;
const tone = operatorDiagnosticTone(value, label);
if (tone === "neutral") element.removeAttribute("data-state");
else element.dataset.state = tone;
}
function operatorPlayoutModeLabel(playout) {
if (!playout) return "확인 중";
if (playout.mode === "live") return "LIVE";
if (playout.mode === "dryRun") return "DRY RUN";
if (playout.mode === "disabled") return "송출 비활성";
return operatorSettingsText(playout.mode, "확인 중");
}
function operatorPlayoutConnectionLabel(playout) {
if (!playout) return "확인 중";
if (playout.outcomeUnknown === true) return "결과 불명확";
if (playout.mode === "disabled") return "송출 비활성";
if (playout.mode === "dryRun") return "DRY RUN 준비됨";
if (playout.isConnected === true) return "Tornado2 연결됨";
if (playout.isProcessRunning === true) return "Tornado2 감지됨";
return "미연결";
}
function renderOperatorFolder(kind, folder, isBusy) {
const controls = operatorFolderControls[kind];
const current = folder && typeof folder === "object" ? folder : {};
const defaultNames = {
design: "앱 기본 디자인 폴더",
resource: "앱 기본 설정 폴더",
background: "앱 기본 배경 폴더"
};
const displayName = operatorSettingsText(current.displayName, defaultNames[kind]);
if (controls.name.textContent !== displayName) controls.name.textContent = displayName;
controls.name.title = displayName;
const valid = current.isValid === true;
const invalid = current.isValid === false;
const statusLabel = operatorSettingsText(
current.status,
valid ? "사용 가능" : (invalid ? "확인 필요" : "확인 중"));
if (controls.status.textContent !== statusLabel) controls.status.textContent = statusLabel;
controls.status.classList.toggle("valid", valid);
controls.status.classList.toggle("invalid", invalid);
controls.status.classList.toggle("neutral", !valid && !invalid);
controls.source.textContent = current.isCustom === true ? "사용자 지정" : "앱 기본값";
controls.select.disabled = isBusy;
controls.reset.disabled = isBusy || current.isCustom !== true;
}
function renderOperatorSettings(state) {
const settings = state && state.operatorSettings;
const isBusy = !settings || state.isBusy === true;
const wasBusy = operatorSettingsWasBusy;
let revisionChanged = false;
renderOperatorFolder("design", settings && settings.design, isBusy);
renderOperatorFolder("resource", settings && settings.resource, isBusy);
renderOperatorFolder("background", settings && settings.background, isBusy);
operatorNavigationExpanded.disabled = isBusy;
if (settings) {
const revision = settings.revision === undefined ? null : settings.revision;
revisionChanged = !operatorSettingsRevisionInitialized ||
revision !== operatorSettingsRenderRevision;
if (revisionChanged) {
const expanded = settings.navigationExpanded !== false;
operatorNavigationExpanded.checked = expanded;
if (window.LegacyWorkspaceNavigation &&
window.LegacyWorkspaceNavigation.expanded() !== expanded) {
window.LegacyWorkspaceNavigation.setExpanded(expanded);
}
operatorSettingsRenderRevision = revision;
operatorSettingsRevisionInitialized = true;
}
}
const message = operatorSettingsText(settings && settings.message, "");
operatorSettingsMessage.hidden = !message;
if (operatorSettingsMessage.textContent !== message) {
operatorSettingsMessage.textContent = message;
}
const messageKind = settings && typeof settings.messageKind === "string"
? settings.messageKind.toLowerCase()
: "";
operatorSettingsMessage.className = "settings-message" +
(["information", "success", "warning", "error"].includes(messageKind)
? " " + messageKind
: "");
operatorSettingsRestart.hidden = !(settings && settings.restartRequired === true);
setOperatorDiagnostic(
operatorSettingsAppVersion,
settings && settings.appVersion,
"확인 중");
const playout = state && state.playout;
setOperatorDiagnostic(operatorSettingsPlayoutMode, operatorPlayoutModeLabel(playout), "확인 중");
setOperatorDiagnostic(
operatorSettingsPlayoutConnection,
operatorPlayoutConnectionLabel(playout),
"확인 중");
setOperatorDiagnostic(
operatorSettingsOracleState,
settings && settings.oracleState,
"확인 중");
setOperatorDiagnostic(
operatorSettingsMariaState,
settings && settings.mariaState,
"확인 중");
const pollingSeconds = Number(settings && settings.databasePollingSeconds);
operatorSettingsDbMonitor.textContent = Number.isFinite(pollingSeconds) && pollingSeconds > 0
? String(pollingSeconds) + "초마다"
: "10초마다";
operatorSettingsWasBusy = isBusy;
if (pendingOperatorSettingsFocusId && !isBusy && (wasBusy || revisionChanged)) {
const focusId = pendingOperatorSettingsFocusId;
const fallbackFocusId = pendingOperatorSettingsFallbackFocusId;
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
window.requestAnimationFrame(function () {
const primary = document.getElementById(focusId);
const fallback = fallbackFocusId
? document.getElementById(fallbackFocusId)
: null;
const target = primary && !primary.disabled ? primary : fallback;
const active = document.activeElement;
if (target && !target.disabled && !target.closest("[inert]") &&
(active === document.body || active === null)) {
target.focus({ preventScroll: true });
}
});
}
}
function render(state) {
const isBusy = state.isBusy === true;
const treeTabId = beginTreeRender(state);
@@ -3711,6 +3910,7 @@
renderManualFinancial(state);
renderManualLists(state);
renderOperatorCatalog(state);
renderOperatorSettings(state);
renderPlaylist(state);
renderNamedPlaylist(state);
renderDialog(state);
@@ -3718,6 +3918,42 @@
status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || "");
}
Object.keys(operatorFolderControls).forEach(function (kind) {
const controls = operatorFolderControls[kind];
controls.select.addEventListener("click", function () {
pendingOperatorSettingsFocusId = controls.select.id;
pendingOperatorSettingsFallbackFocusId = null;
if (!send("choose-operator-folder", { kind: kind })) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
}
});
controls.reset.addEventListener("click", function () {
pendingOperatorSettingsFocusId = controls.reset.id;
pendingOperatorSettingsFallbackFocusId = controls.select.id;
if (!send("reset-operator-folder", { kind: kind })) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
}
});
});
operatorNavigationExpanded.addEventListener("change", function () {
const expanded = operatorNavigationExpanded.checked === true;
pendingOperatorSettingsFocusId = operatorNavigationExpanded.id;
pendingOperatorSettingsFallbackFocusId = null;
if (!send("set-operator-navigation-expanded", { expanded: expanded })) {
pendingOperatorSettingsFocusId = null;
pendingOperatorSettingsFallbackFocusId = null;
operatorNavigationExpanded.checked = !expanded;
return;
}
if (window.LegacyWorkspaceNavigation &&
window.LegacyWorkspaceNavigation.expanded() !== expanded) {
window.LegacyWorkspaceNavigation.setExpanded(expanded);
}
});
searchButton.addEventListener("click", function () {
send("search-stocks", { text: searchInput.value, trigger: "button" });
});