diff --git a/.github/workflows/safety-regression.yml b/.github/workflows/safety-regression.yml
index 9f7c7fd7..e4764d60 100644
--- a/.github/workflows/safety-regression.yml
+++ b/.github/workflows/safety-regression.yml
@@ -20,10 +20,10 @@ jobs:
steps:
- name: Check out repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v7.0.1
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v7.0.0
with:
python-version: "3.12"
cache: pip
@@ -38,6 +38,15 @@ jobs:
run: |
python -m py_compile main.py api/websocket_handler.py core/data_collector.py core/order_executor.py core/paper_preflight.py core/scheduler.py core/live_gate.py core/target_weight_rotation.py monitoring/liquidate_trigger.py tools/target_weight_rotation_pilot.py tools/paper_pilot_control.py
+ - name: Set up Node for dashboard tests
+ uses: actions/setup-node@v7.0.0
+ with:
+ node-version: "22"
+ package-manager-cache: false
+
+ - name: Check dashboard date calculations and refresh behavior
+ run: node --test tests/js/dashboard.test.cjs
+
- name: Run safety regression tests
env:
TMPDIR: ${{ runner.temp }}
diff --git a/docs/DASHBOARD_REVIEW_20260922.md b/docs/DASHBOARD_REVIEW_20260922.md
new file mode 100644
index 00000000..3d8d12aa
--- /dev/null
+++ b/docs/DASHBOARD_REVIEW_20260922.md
@@ -0,0 +1,63 @@
+# 계좌 화면 오류·성능 점검
+
+2026년 9월 22일, 날짜와 월별 수익률 표시, 새로고침, 많은 기록을 조회할 때의 속도를 점검했습니다. 기존 HTML·CSS·JavaScript와 Python 서버를 유지했습니다.
+
+## 날짜와 월별 수익률
+
+브라우저 시간대를 미국으로 바꾸자 9월 22일 기록이 21일로 표시됐습니다. 월초 기록이 전월에 포함되면서 같은 계좌의 8월 수익률도 서울에서는 `+0.4%`, 미국에서는 `+1.4%`로 달라졌습니다.
+
+화면의 날짜, 조회 시작일, 월별 집계, 이번 달 적립 여부, 오늘 생성된 신호를 한국 시간으로 맞췄습니다. 장부에 시간대 없이 저장된 시각은 한국 시간으로 읽고, `Z`나 UTC 오프셋이 있는 시각은 해당 시각을 기준으로 변환합니다. 과거 기록을 나중에 복원했을 때 이후 입금이 과거 원금에 포함되지 않는지도 확인했습니다.
+
+서울·로스앤젤레스·런던 시간대에서 같은 계좌를 열어 날짜와 월별 수익률이 일치하는 것을 확인했습니다.
+
+## 새로고침과 조회 실패
+
+- 새로고침 버튼과 자동 갱신이 겹치면 진행 중인 요청을 취소하던 동작을 고쳤습니다. 같은 조회가 끝날 때까지 결과를 함께 기다립니다. 실제 화면에서 버튼을 세 번 연달아 눌러도 계좌 요청은 한 번만 전송됐습니다.
+- 적립금을 기록한 뒤에는 이전 조회가 끝난 다음 계좌를 다시 읽습니다. 적립 직전 금액으로 화면이 되돌아가지 않도록 했습니다.
+- 차트 조회에 실패하면 이전 계좌의 표와 낙폭도 함께 숨깁니다. 내려받기와 날짜 선택 버튼도 비활성화합니다.
+
+## 기록이 많을 때
+
+일별 표는 펼쳤을 때 만들고, 100개씩 넘겨 볼 수 있게 바꿨습니다. 표를 스크롤해도 열 제목은 위에 남습니다. 날짜 슬라이더와 CSV에는 조회 기간의 전체 기록을 유지합니다.
+
+아래는 합성 일별 기록 2,500개로 비교한 결과입니다. 창 크기 1280×900, Chromium CPU 4배 감속 조건에서 수정 전후를 번갈아 세 번 실행한 중앙값입니다. 수정 전 기준은 `3be97d0`입니다.
+
+| 항목 | 수정 전 | 수정 후 |
+| --- | ---: | ---: |
+| 표를 접은 첫 화면의 DOM 요소 | 15,665개 | 669개 |
+| 접힌 표를 만드는 데 쓴 시간 | 112.2ms | 0.4ms |
+| 화면에 준비하는 표 행 수 | 2,500개 | 펼친 페이지의 최대 100개 |
+| 표 한 번 생성하는 시간 | 112.2ms | 7.3ms |
+| 차트 데이터 반영 함수 실행 시간 | 235.4ms | 170.1ms |
+
+수정 전에는 표를 미리 만들었고, 수정 후에는 펼친 시점에 만듭니다. 위 시간은 해당 JavaScript 함수의 실행 시간이며, 전체 페이지 로딩 시간이나 실사용자의 INP를 뜻하지 않습니다. 합성 데이터와 감속 환경에서 측정한 값이라 기기·기록 수에 따라 달라집니다. 측정 중 긴 작업이 남아 있어 모든 상황에서 지연이 없다는 의미도 아닙니다.
+
+[측정값과 모바일 확인 결과](../reports/research/dashboard_review_20260922.json)
+
+## 서버 처리
+
+계좌·성과·적립 기록을 읽는 동안 다른 요청이 기다리지 않도록 DB 조회를 별도 스레드에서 처리합니다. 운영 상태와 검증 자료는 서버 앱별로 캐시하고, 동시에 요청한 경우 한 번만 수집합니다. 동시 요청 6개를 보냈을 때 수집 횟수가 6회에서 1회로 줄었습니다.
+
+캐시는 수집을 마친 뒤 60초 동안 유지합니다. 모의투자·실전 모드가 바뀌면 다시 수집하고, 실패한 조회는 다음 요청에서 재시도합니다. 거래 중지 여부는 캐시와 별개로 매번 확인합니다.
+
+## 확인한 사용 흐름
+
+- 320px·390px·1280px 화면에서 가로 넘침과 JavaScript 오류 없음
+- 모션 줄이기 설정, 입체·평면 전환, 날짜 슬라이더의 Home·End 키 조작
+- 2,500개 기록에서 이전·다음 페이지 이동, 화면에는 100행, CSV에는 2,500행 유지
+- 조회 실패 시 이전 표 숨김, 버튼 연속 클릭 시 중복 요청 방지
+- 표의 페이지 버튼 높이 40px, 스크롤 중 열 제목 고정
+
+날짜·화면 로직은 `node --test tests/js/dashboard.test.cjs`, 서버와 매매 관련 회귀 테스트는 `python -m pytest tests -q`로 확인합니다. Node.js는 화면 테스트에만 사용하며 프로그램 실행에는 필요하지 않습니다.
+
+로컬 전체 테스트는 Python 2,091개와 JavaScript 19개가 통과했습니다. Python 테스트에서 나온 경고 2개는 기존 라이브러리의 지원 종료 예정 API에 관한 내용입니다.
+
+GitHub 자동 검사에서 지원이 끝난 Node 20 기반 액션 경고도 확인했습니다. 공식 릴리스를 확인해 checkout은 7.0.1, setup-python과 setup-node는 7.0.0으로 업데이트했습니다. 프로그램의 Python 버전이나 설치 방법은 바꾸지 않았습니다.
+
+## 참고 자료
+
+- [MDN Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date): 기기 시간대에 따라 달라지는 날짜 필드와 UTC 필드의 차이
+- [Python asyncio.to_thread](https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread): 동기 I/O가 이벤트 루프를 막지 않도록 실행하는 방법
+- [aiohttp의 앱별 상태 관리](https://docs.aiohttp.org/en/stable/web_advanced.html#data-sharing-aka-no-singletons-please): 전역 상태 대신 앱 안에서 조회 결과 관리
+- [Web Vitals](https://web.dev/articles/vitals): 실험실 측정과 실제 사용자 지표의 구분
+- [checkout 7.0.1](https://github.com/actions/checkout/releases/tag/v7.0.1), [setup-python 7.0.0](https://github.com/actions/setup-python/releases/tag/v7.0.0), [setup-node 7.0.0](https://github.com/actions/setup-node/releases/tag/v7.0.0): 자동 검사 도구의 공식 릴리스
diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md
index 2eae33b7..4e44e364 100644
--- a/docs/USER_GUIDE.md
+++ b/docs/USER_GUIDE.md
@@ -20,6 +20,12 @@
**입체**와 **평면**은 같은 수익률을 다른 모양으로 보여 줍니다. 입체 차트의 띠 너비가 별도의 투자 지표를 뜻하지는 않습니다.
+날짜와 시각은 모두 한국 시간 기준입니다. 해외에서 접속해도 거래 날짜와 월별 수익률은 같습니다.
+
+**낙폭과 일별 기록 자세히 보기**를 열면 날짜별 금액이 표로 나옵니다. 기록이 많으면 100개씩 나눠 보여 줍니다. **이전 기록**, **다음 기록**으로 넘길 수 있고, CSV에는 조회 기간의 모든 기록이 담깁니다.
+
+
+
[계좌 화면 크게 보기](images/readme-account-20260922.png)
## 내역 저장
diff --git a/docs/images/history-table-20260922.png b/docs/images/history-table-20260922.png
new file mode 100644
index 00000000..1997de3a
Binary files /dev/null and b/docs/images/history-table-20260922.png differ
diff --git a/monitoring/static/dashboard.css b/monitoring/static/dashboard.css
index 09c1422b..43ab9e46 100644
--- a/monitoring/static/dashboard.css
+++ b/monitoring/static/dashboard.css
@@ -762,6 +762,39 @@ h1 {
.history-details > summary {
color: var(--ink-soft);
}
+.history-pagination {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin: 16px 0 8px;
+ font-size: 12px;
+ font-variant-numeric: tabular-nums;
+}
+.history-pagination button {
+ min-height: 40px;
+ padding: 0 12px;
+ border: 1px solid var(--rule-strong);
+ color: var(--ink);
+ background: transparent;
+ cursor: pointer;
+}
+.history-pagination button:disabled {
+ opacity: 0.4;
+ cursor: default;
+}
+.history-pagination button:not(:disabled):hover {
+ background: var(--surface);
+}
+#historyTableScroll {
+ max-height: min(560px, 65vh);
+ scrollbar-gutter: stable;
+}
+.history-details th {
+ position: sticky;
+ top: 0;
+ background: var(--surface);
+}
.chart-title {
display: flex;
justify-content: space-between;
diff --git a/monitoring/static/dashboard.js b/monitoring/static/dashboard.js
index f1121391..1fed31ae 100644
--- a/monitoring/static/dashboard.js
+++ b/monitoring/static/dashboard.js
@@ -46,34 +46,48 @@
}
function parseDate(value) {
if (!value) return null;
- const text = String(value);
+ if (value instanceof Date)
+ return Number.isNaN(value.getTime()) ? null : new Date(value.getTime());
+ const text = String(value).trim().replace(" ", "T");
+ // 장부의 시간대 없는 시각은 한국 시간이다. Z·UTC 오프셋이 있으면 그대로 읽는다.
const normalized = /^\d{4}-\d{2}-\d{2}$/.test(text)
? `${text}T00:00:00+09:00`
- : /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(text)
- ? `${text.replace(" ", "T")}+09:00`
+ : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(text)
+ ? `${text}+09:00`
: text;
const parsed = new Date(normalized);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
const pad2 = (n) => String(n).padStart(2, "0");
+ const DAY_MS = 86_400_000;
+ const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
+ // 표시용 날짜만 +9시간 옮겨 UTC 필드로 읽는다. 시간 비교에는 원래 시각을 쓴다.
+ function koreaClock(value) {
+ const instant = parseDate(value);
+ return instant ? new Date(instant.getTime() + KST_OFFSET_MS) : null;
+ }
+ function koreaDate(value) {
+ return koreaClock(value)?.toISOString().slice(0, 10) || "";
+ }
function fmtLong(value) {
- const d = parseDate(value);
+ const d = koreaClock(value);
return d
- ? `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일`
+ ? `${d.getUTCFullYear()}년 ${d.getUTCMonth() + 1}월 ${d.getUTCDate()}일`
: "—";
}
function fmtMD(value) {
- const d = parseDate(value);
- return d ? `${d.getMonth() + 1}월 ${d.getDate()}일` : "—";
+ const d = koreaClock(value);
+ return d ? `${d.getUTCMonth() + 1}월 ${d.getUTCDate()}일` : "—";
}
function fmtDT(value) {
- const d = parseDate(value);
+ const d = koreaClock(value);
return d
- ? `${d.getMonth() + 1}월 ${d.getDate()}일 ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
+ ? `${d.getUTCMonth() + 1}월 ${d.getUTCDate()}일 ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`
: "—";
}
- function fmtHM(d) {
- return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
+ function fmtHM(value) {
+ const d = koreaClock(value);
+ return d ? `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}` : "—";
}
function formatAge(minutes) {
if (minutes == null) return "기록 없음";
@@ -82,20 +96,13 @@
if (minutes < 1440) return `${Math.round(minutes / 60)}시간 전`;
return `${Math.round(minutes / 1440)}일 전`;
}
- function localIsoDate(value = new Date()) {
- return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}`;
- }
function calendarAgeDays(value) {
- const parsed = parseDate(value);
- if (!parsed) return null;
- const now = new Date();
- const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
- const target = new Date(
- parsed.getFullYear(),
- parsed.getMonth(),
- parsed.getDate(),
+ const date = koreaDate(value);
+ if (!date) return null;
+ return Math.max(
+ 0,
+ Math.round((parseDate(koreaDate(new Date())) - parseDate(date)) / DAY_MS),
);
- return Math.max(0, Math.floor((today - target) / 86_400_000));
}
function escapeHtml(value) {
return String(value == null ? "" : value)
@@ -317,9 +324,8 @@
if (!d) return null;
const created = parseDate(row.created_at);
// 과거 날짜를 나중에 복원한 행의 저장 시각으로 미래 입금을 당겨 넣지 않는다.
- if (created && localIsoDate(created) === localIsoDate(d)) return created;
- d.setHours(23, 59, 59, 999);
- return d;
+ if (created && koreaDate(created) === koreaDate(d)) return created;
+ return new Date(`${koreaDate(d)}T23:59:59.999+09:00`);
}
/** 스냅샷·입금 기록으로 평가금액, 투자원금(계단), 시간가중 자산, 낙폭 시계열을 만든다. */
@@ -412,13 +418,14 @@
const out = [];
let prevIndex = null;
let current = null;
+ const currentYear = koreaDate(new Date()).slice(0, 4);
series.dates.forEach((d, i) => {
- const key = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
+ const key = koreaDate(d).slice(0, 7);
if (!current || current.key !== key) {
if (current) out.push(current);
current = {
key,
- label: `${d.getFullYear() !== new Date().getFullYear() ? `${String(d.getFullYear()).slice(2)}년 ` : ""}${d.getMonth() + 1}월`,
+ label: `${key.slice(0, 4) !== currentYear ? `${key.slice(2, 4)}년 ` : ""}${Number(key.slice(5, 7))}월`,
startIndex: prevIndex,
endIndex: i,
};
@@ -604,14 +611,9 @@
function currentMonthContributionState(basket) {
if (!basket || !basket.contribution_plan?.enabled) return "not-planned";
if (state.flowStatus.get(basket.basket) !== "ready") return "unknown";
- const now = new Date();
+ const month = koreaDate(new Date()).slice(0, 7);
const recorded = (state.flows.get(basket.basket) || []).some((flow) => {
- const when = parseDate(flow.occurred_at);
- return (
- when &&
- when.getFullYear() === now.getFullYear() &&
- when.getMonth() === now.getMonth()
- );
+ return koreaDate(flow.occurred_at).slice(0, 7) === month;
});
return recorded ? "recorded" : "empty";
}
@@ -1003,6 +1005,9 @@
perspective: 1,
selected: null,
paintFrame: null,
+ tableSeries: null,
+ tablePage: null,
+ tableRenderedPage: null,
};
function ensureChartAccountOptions() {
@@ -1076,21 +1081,53 @@
}
function renderChartTable(series) {
+ if (!$("historyDetails").open || !series.rows.length) return;
+ const lastPage = Math.ceil(series.rows.length / 100) - 1;
+ const page = clamp(chart.tablePage ?? lastPage, 0, lastPage);
+ if (chart.tableSeries === series && chart.tableRenderedPage === page)
+ return;
+ const start = page * 100;
+ const end = Math.min(start + 100, series.rows.length);
el.chartRows.innerHTML = series.rows
+ .slice(start, end)
.map(
- (row, i) => `
+ (row, offset) => `
${won(row.total_value)}
-
${won(series.principal[i])}
+
${won(series.principal[start + offset])}
${pct(row.cumulative_return)}
`,
)
.join("");
+ chart.tableSeries = series;
+ chart.tableRenderedPage = page;
+ $("historyTablePosition").textContent =
+ `${start + 1}–${end} / ${series.rows.length}개 기록`;
+ $("historyTablePrev").disabled = page === 0;
+ $("historyTableNext").disabled = page === lastPage;
+ }
+
+ function moveHistoryTable(direction) {
+ if (!chart.series || $("historyDetails").hidden) return;
+ const lastPage = Math.ceil(chart.series.rows.length / 100) - 1;
+ const currentPage = clamp(chart.tablePage ?? lastPage, 0, lastPage);
+ chart.tablePage = clamp(currentPage + direction, 0, lastPage);
+ // 마지막 페이지는 새 기록이 추가돼도 최근 날짜를 계속 보여 준다.
+ if (chart.tablePage === lastPage) chart.tablePage = null;
+ renderChartTable(chart.series);
+ $("historyTableScroll").scrollTop = 0;
+ }
+
+ function clearChartDetails() {
+ $("historyDetails").hidden = true;
+ el.chartRows.innerHTML = "";
+ chart.tableSeries = null;
+ chart.tableRenderedPage = null;
}
function renderMonthStrip(series, history) {
const visibleMonths = new Set(
- series.dates.map((d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`),
+ series.dates.map((d) => koreaDate(d).slice(0, 7)),
);
// 조회 기간이 월 중간에서 시작해도 전월 말 기준 수익률을 사용한다.
const months = monthlyReturns(history).filter((month) =>
@@ -1489,6 +1526,7 @@
chart.selected === chart.series?.rows.length - 1;
const previousDate = chart.series?.rows[chart.selected]?.date;
const sameAccount = state.chartDataAccount === state.chartAccount;
+ if (!sameAccount) chart.tablePage = null;
chart.series = series;
state.chartDataAccount = state.chartAccount;
state.chartRows = series.rows;
@@ -1502,6 +1540,8 @@
$("historyLatest").disabled = !series.rows.length;
$("historyStart").textContent = fmtMD(series.rows[0]?.date);
$("historyEnd").textContent = fmtMD(series.rows.at(-1)?.date);
+ // 접힌 표의 수천 개 행을 매 갱신마다 만들지 않는다.
+ clearChartDetails();
if (!series.rows.length) {
el.chartWrap.hidden = true;
@@ -1521,6 +1561,7 @@
el.chartSummary.textContent = `${fmtMD(first.date)} ~ ${fmtMD(last.date)} · 평가금액 ${signedWon(change)}${deposits ? ` (적립 ${won(deposits)} 포함)` : ""} · 누적 수익률 ${pct(last.cumulative_return)}`;
el.chartWrap.hidden = false;
el.chartEmpty.hidden = true;
+ $("historyDetails").hidden = false;
renderChartTable(series);
renderMonthStrip(series, history);
const previousIndex =
@@ -1543,8 +1584,10 @@
$("performance").setAttribute("aria-busy", "true");
$("exportHistory").disabled = true;
$("historyCursor").disabled = true;
+ $("historyLatest").disabled = true;
// 계좌가 바뀌면 이전 숫자를 남기지 않는다. 오래 걸린 이전 응답도 폐기한다.
if (state.chartDataAccount !== account) {
+ clearChartDetails();
el.chartWrap.hidden = true;
el.chartEmpty.hidden = false;
el.chartEmpty.innerHTML =
@@ -1562,9 +1605,9 @@
);
const cached = state.series.get(account);
// 전체 기록은 갱신 주기에 한 번 읽는다. 기간·계좌 변경 때 같은 데이터를 다시 받지 않는다.
- const cutoff = new Date();
- cutoff.setDate(cutoff.getDate() - days);
- cutoff.setHours(0, 0, 0, 0);
+ const cutoff = new Date(
+ parseDate(koreaDate(new Date())).getTime() - days * DAY_MS,
+ );
const data = cached
? { snapshots: cached.filter((row) => parseDate(row.date) >= cutoff) }
: await fetchJson(
@@ -1580,6 +1623,7 @@
updateChart(data?.snapshots || []);
} catch {
if (request !== state.chartRequest) return;
+ clearChartDetails();
el.chartSummary.textContent =
"성과 기록을 읽지 못했습니다. 운영 영역의 다시 확인을 눌러주세요.";
el.chartWrap.hidden = true;
@@ -1821,7 +1865,7 @@
count.textContent = "확인 불가";
return;
}
- const isToday = !signalsDate || signalsDate === localIsoDate();
+ const isToday = !signalsDate || signalsDate === koreaDate(new Date());
const rows = isToday && Array.isArray(signals) ? signals : [];
count.textContent = `${rows.length}건`;
table.hidden = !rows.length;
@@ -2055,7 +2099,9 @@
showToast(
`${state.mode === "live" ? "실전 입금" : "모의투자 적립"} ${won(data.amount)}을 기록했습니다.`,
);
- await refreshCore();
+ // 적립 전에 시작한 조회가 있으면 끝낸 뒤 새 금액을 다시 읽는다.
+ await coreRefresh;
+ await refreshCore(true);
} catch (error) {
showDepositError(
`기록하지 못했습니다. ${error.message || "연결을 확인한 뒤 다시 시도하세요."}`,
@@ -2079,8 +2125,19 @@
// 주기 갱신은 숨은 탭에서 쉬지만, 첫 로드는 탭이 뒤에 있어도 반드시 한 번 채운다
// (백그라운드로 연 탭이 빈 화면으로 남던 문제).
- async function refreshCore(force = false) {
+ let coreRefresh = null;
+ let slowRefresh = null;
+ function refreshCore(force = false) {
if (!force && document.visibilityState === "hidden") return;
+ // 타이머·탭 복귀·버튼 클릭이 겹쳐도 진행 중인 조회를 취소하지 않는다.
+ if (!coreRefresh)
+ coreRefresh = loadCore().finally(() => {
+ coreRefresh = null;
+ });
+ return coreRefresh;
+ }
+
+ async function loadCore() {
state.coreStatus = "loading";
updateSyncIndicator();
const basketTask = fetchJson("/api/baskets", {
@@ -2122,8 +2179,16 @@
renderDecision();
}
- async function refreshSlow(force = false) {
+ function refreshSlow(force = false) {
if (!force && document.visibilityState === "hidden") return;
+ if (!slowRefresh)
+ slowRefresh = loadSlow().finally(() => {
+ slowRefresh = null;
+ });
+ return slowRefresh;
+ }
+
+ async function loadSlow() {
if (!state.runtime) {
state.runtimeStatus = "loading";
updateSyncIndicator();
@@ -2254,8 +2319,17 @@
);
$("exportHistory").addEventListener("click", exportHistory);
$("historyDetails").addEventListener("toggle", () => {
- if ($("historyDetails").open && chart.series) drawDrawdown(chart.series);
+ if ($("historyDetails").hidden || !chart.series) return;
+ if ($("historyDetails").open) {
+ renderChartTable(chart.series);
+ drawDrawdown(chart.series);
+ } else {
+ el.chartRows.innerHTML = "";
+ chart.tableSeries = null;
+ }
});
+ $("historyTablePrev").addEventListener("click", () => moveHistoryTable(-1));
+ $("historyTableNext").addEventListener("click", () => moveHistoryTable(1));
$("chartView").addEventListener("click", (event) => {
const button = event.target.closest("button[data-view]");
if (button) setChartView(button.dataset.view);
diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html
index cb13e4fe..7c7f1baf 100644
--- a/monitoring/templates/dashboard.html
+++ b/monitoring/templates/dashboard.html
@@ -265,7 +265,7 @@