From c8b8de6887fc2f8284b2d9680a759776f4746a4d Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 13 Mar 2026 21:24:24 +1100 Subject: [PATCH 1/6] chore: add .worktrees to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5166e60131..00ad6a65be 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ test_logs # AI tools .claude +.worktrees From 695e4fbf0cfa75e5d808d01ff59fca955fbe3d29 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 13 Mar 2026 21:49:33 +1100 Subject: [PATCH 2/6] feat: Add log pagination with "Load more" button Closes #376 --- src/dashboard/Data/Logs/Logs.react.js | 45 ++++++++++++++++++++++++--- src/dashboard/Data/Logs/Logs.scss | 5 +++ src/lib/ParseApp.js | 25 ++++++++++----- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/dashboard/Data/Logs/Logs.react.js b/src/dashboard/Data/Logs/Logs.react.js index f78ea680bf..2683339542 100644 --- a/src/dashboard/Data/Logs/Logs.react.js +++ b/src/dashboard/Data/Logs/Logs.react.js @@ -5,6 +5,7 @@ * This source code is licensed under the license found in the LICENSE file in * the root directory of this source tree. */ +import Button from 'components/Button/Button.react'; import CategoryList from 'components/CategoryList/CategoryList.react'; import DashboardView from 'dashboard/DashboardView.react'; import EmptyState from 'components/EmptyState/EmptyState.react'; @@ -32,6 +33,8 @@ class Logs extends DashboardView { this.state = { logs: undefined, release: undefined, + loading: false, + hasMore: false, }; } @@ -47,14 +50,38 @@ class Logs extends DashboardView { } } - fetchLogs(app, type) { + fetchLogs(app, type, until) { + const PAGE_SIZE = 100; const typeParam = (type || 'INFO').toUpperCase(); - app.getLogs(typeParam).then( - logs => this.setState({ logs }), - () => this.setState({ logs: [] }) + const options = { size: PAGE_SIZE }; + if (until) { + options.until = until; + } + this.setState({ loading: true }); + app.getLogs(typeParam, options).then( + newLogs => { + this.setState(prevState => ({ + logs: until && Array.isArray(prevState.logs) + ? prevState.logs.concat(newLogs) + : newLogs, + hasMore: newLogs.length >= PAGE_SIZE, + loading: false, + })); + }, + () => this.setState({ logs: [], hasMore: false, loading: false }) ); } + handleLoadMore() { + const logs = this.state.logs; + if (!logs || logs.length === 0) { + return; + } + const oldestLog = logs[logs.length - 1]; + const oldestTimestamp = oldestLog.timestamp.iso || oldestLog.timestamp; + this.fetchLogs(this.context, this.props.params.type, oldestTimestamp); + } + // As parse-server doesn't support (yet?) versioning, we are disabling // this call in the meantime. @@ -115,6 +142,16 @@ class Logs extends DashboardView { ))} + {this.state.hasMore && ( +
+
+ )} ); } diff --git a/src/dashboard/Data/Logs/Logs.scss b/src/dashboard/Data/Logs/Logs.scss index acbba2298a..c8b0590048 100644 --- a/src/dashboard/Data/Logs/Logs.scss +++ b/src/dashboard/Data/Logs/Logs.scss @@ -25,3 +25,8 @@ right: 0; bottom: 0; } + +.showMore { + padding: 20px; + text-align: center; +} diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index 3da640d0ea..d992f9a793 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -144,15 +144,24 @@ export default class ParseApp { /** * Fetches scriptlogs from api.parse.com - * lines - maximum number of lines to fetch - * since - only fetch lines since this Date + * level - log level (info or error) + * options.from - only fetch logs after this date + * options.until - only fetch logs before this date + * options.size - maximum number of logs to fetch (default 100) + * options.order - sort order (asc or desc) */ - getLogs(level, since) { - const path = - 'scriptlog?level=' + - encodeURIComponent(level.toLowerCase()) + - '&n=100' + - (since ? '&startDate=' + encodeURIComponent(since.getTime()) : ''); + getLogs(level, { from, until, size = 100, order } = {}) { + let path = 'scriptlog?level=' + encodeURIComponent(level.toLowerCase()); + path += '&size=' + encodeURIComponent(size); + if (from) { + path += '&from=' + encodeURIComponent(from); + } + if (until) { + path += '&until=' + encodeURIComponent(until); + } + if (order) { + path += '&order=' + encodeURIComponent(order); + } return this.apiRequest('GET', path, {}, { useMasterKey: true }); } From 71a965b06e3a10075ca0d5741762fc4ebd79626b Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 13 Mar 2026 22:05:27 +1100 Subject: [PATCH 3/6] fix: Preserve loaded logs when pagination fetch fails The error handler was resetting logs to an empty array, discarding previously fetched entries. Use functional setState to keep existing logs on failure. --- src/dashboard/Data/Logs/Logs.react.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/dashboard/Data/Logs/Logs.react.js b/src/dashboard/Data/Logs/Logs.react.js index 2683339542..e57ae8f75e 100644 --- a/src/dashboard/Data/Logs/Logs.react.js +++ b/src/dashboard/Data/Logs/Logs.react.js @@ -68,7 +68,11 @@ class Logs extends DashboardView { loading: false, })); }, - () => this.setState({ logs: [], hasMore: false, loading: false }) + () => this.setState(prevState => ({ + logs: prevState.logs || [], + hasMore: false, + loading: false, + })) ); } From af59ac5e52eb81d3087c823153e1d06cec3a74f6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 13 Mar 2026 22:14:47 +1100 Subject: [PATCH 4/6] fix: prevent duplicate logs on pagination by excluding last seen timestamp --- src/dashboard/Data/Logs/Logs.react.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/dashboard/Data/Logs/Logs.react.js b/src/dashboard/Data/Logs/Logs.react.js index e57ae8f75e..4a5ee610a7 100644 --- a/src/dashboard/Data/Logs/Logs.react.js +++ b/src/dashboard/Data/Logs/Logs.react.js @@ -83,7 +83,8 @@ class Logs extends DashboardView { } const oldestLog = logs[logs.length - 1]; const oldestTimestamp = oldestLog.timestamp.iso || oldestLog.timestamp; - this.fetchLogs(this.context, this.props.params.type, oldestTimestamp); + const exclusiveUntil = new Date(new Date(oldestTimestamp).getTime() - 1).toISOString(); + this.fetchLogs(this.context, this.props.params.type, exclusiveUntil); } // As parse-server doesn't support (yet?) versioning, we are disabling From 7dae480a5cc20a9cc9d1a5d89401fe52fbe3ecde Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 13 Mar 2026 22:20:56 +1100 Subject: [PATCH 5/6] fix: guard handleLoadMore against concurrent fetches --- src/dashboard/Data/Logs/Logs.react.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dashboard/Data/Logs/Logs.react.js b/src/dashboard/Data/Logs/Logs.react.js index 4a5ee610a7..5879e6c812 100644 --- a/src/dashboard/Data/Logs/Logs.react.js +++ b/src/dashboard/Data/Logs/Logs.react.js @@ -78,7 +78,7 @@ class Logs extends DashboardView { handleLoadMore() { const logs = this.state.logs; - if (!logs || logs.length === 0) { + if (!logs || logs.length === 0 || this.state.loading) { return; } const oldestLog = logs[logs.length - 1]; From 8cb0618d2a0c067ebece0a52bda9c65253a09b41 Mon Sep 17 00:00:00 2001 From: Daniel Date: Fri, 13 Mar 2026 22:41:34 +1100 Subject: [PATCH 6/6] fix: add stale-response guard, deduplicate logs, and improve hasMore logic - Add request token to ignore stale fetch responses when user navigates - Deduplicate incoming logs by timestamp+message composite key instead of subtracting 1ms from the pagination cursor (which dropped rows) - Base hasMore on whether any rows were returned rather than matching PAGE_SIZE exactly --- src/dashboard/Data/Logs/Logs.react.js | 49 ++++++++++++++++++++------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/dashboard/Data/Logs/Logs.react.js b/src/dashboard/Data/Logs/Logs.react.js index 5879e6c812..7adcb6e225 100644 --- a/src/dashboard/Data/Logs/Logs.react.js +++ b/src/dashboard/Data/Logs/Logs.react.js @@ -36,6 +36,7 @@ class Logs extends DashboardView { loading: false, hasMore: false, }; + this.latestLogsRequestId = 0; } componentDidMount() { @@ -57,22 +58,47 @@ class Logs extends DashboardView { if (until) { options.until = until; } + const requestId = ++this.latestLogsRequestId; this.setState({ loading: true }); app.getLogs(typeParam, options).then( newLogs => { + if (requestId !== this.latestLogsRequestId) { + return; + } + this.setState(prevState => { + let merged; + if (until && Array.isArray(prevState.logs)) { + const existingKeys = new Set( + prevState.logs.map(l => { + const ts = l.timestamp.iso || l.timestamp; + return `${ts}|${l.message}`; + }) + ); + const unique = newLogs.filter(l => { + const ts = l.timestamp.iso || l.timestamp; + return !existingKeys.has(`${ts}|${l.message}`); + }); + merged = prevState.logs.concat(unique); + } else { + merged = newLogs; + } + return { + logs: merged, + hasMore: newLogs.length > 0, + loading: false, + }; + }); + }, + () => { + if (requestId !== this.latestLogsRequestId) { + return; + } this.setState(prevState => ({ - logs: until && Array.isArray(prevState.logs) - ? prevState.logs.concat(newLogs) - : newLogs, - hasMore: newLogs.length >= PAGE_SIZE, + logs: prevState.logs || [], + hasMore: false, loading: false, })); - }, - () => this.setState(prevState => ({ - logs: prevState.logs || [], - hasMore: false, - loading: false, - })) + } ); } @@ -83,8 +109,7 @@ class Logs extends DashboardView { } const oldestLog = logs[logs.length - 1]; const oldestTimestamp = oldestLog.timestamp.iso || oldestLog.timestamp; - const exclusiveUntil = new Date(new Date(oldestTimestamp).getTime() - 1).toISOString(); - this.fetchLogs(this.context, this.props.params.type, exclusiveUntil); + this.fetchLogs(this.context, this.props.params.type, oldestTimestamp); } // As parse-server doesn't support (yet?) versioning, we are disabling