fix: smooth request table scrolling and clarify logging guidance
- Prevent dropped scroll updates and position jumps - Keep scroll ranges aligned when resizing or toggling columns - Update logging guidance across all supported languages - Add browser regression coverage for table scrolling
This commit is contained in:
1 parent
bac67c1524
commit
327b824ef5
9 files changed
+230
-56
No files matched your search
+1
-1
@@ -615,7 +615,7 @@ export const jaOverrides = {
|
||||
'config.notice.sessionAffinityDisabled': 'セッションアフィニティを無効にしました',
|
||||
'config.notice.sessionTtlUpdated': 'セッションアフィニティ TTL を保存しました',
|
||||
'config.diagnostics.title': 'ログと診断',
|
||||
'config.diagnostics.description': 'デバッグ出力、ファイルログ、使用量統計を CPA WebUI と同じ項目で設定します。',
|
||||
'config.diagnostics.description': '有効にすると、呼び出しでエラーが発生したリクエストのみを記録し、成功したリクエストは記録しません。ストレージ容量を過度に消費しないよう、不要になったら速やかに「**ログファイルへ書き込む**」を無効にしてください。',
|
||||
'config.diagnostics.debug.title': 'デバッグモード',
|
||||
'config.diagnostics.debug.description': '問題調査用の詳細なデバッグログを出力します。',
|
||||
'config.diagnostics.commercial.title': '商用モード',
|
||||
|
||||
@@ -646,7 +646,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'config.software.behavior.minimize': 'Minimize to Tray',
|
||||
'config.software.behavior.exit': 'Exit App',
|
||||
'config.diagnostics.title': 'Logging & Diagnostics',
|
||||
'config.diagnostics.description': 'Configure debug output, file logging, and usage statistics with the same controls available in the CPA WebUI.',
|
||||
'config.diagnostics.description': 'When enabled, only failed requests are logged; successful requests are not logged. Turn off "**Write logs to files**" promptly when no longer needed to avoid excessive storage use.',
|
||||
'config.diagnostics.debug.title': 'Debug mode',
|
||||
'config.diagnostics.debug.description': 'Emit detailed debug-level logs to help diagnose runtime issues.',
|
||||
'config.diagnostics.commercial.title': 'Commercial mode',
|
||||
|
||||
@@ -645,7 +645,7 @@ export const zhCN = {
|
||||
'config.software.behavior.minimize': '最小化到托盘',
|
||||
'config.software.behavior.exit': '退出程序',
|
||||
'config.diagnostics.title': '日志与诊断',
|
||||
'config.diagnostics.description': '配置调试输出、文件日志及用量统计。所有字段与 CPA WebUI 的日志设置保持一致。',
|
||||
'config.diagnostics.description': '启用后将记录调用报错的请求,成功的请求不会记录,请及时关闭"**写入日志文件**",避免占用过多存储空间。',
|
||||
'config.diagnostics.debug.title': '调试模式',
|
||||
'config.diagnostics.debug.description': '输出更详细的调试级日志,便于定位运行异常。',
|
||||
'config.diagnostics.commercial.title': '商业模式',
|
||||
|
||||
@@ -9,6 +9,7 @@ export type MessageVariables = Record<string, string | number>;
|
||||
|
||||
export const zhTW: Record<MessageKey, string> = {
|
||||
...createTraditionalMessages(zhCN),
|
||||
'config.diagnostics.description': '啟用後將記錄呼叫出錯的請求,成功的請求不會記錄,請及時關閉「**寫入日誌檔案**」,避免占用過多儲存空間。',
|
||||
'appUpdate.notes.title': '軟體更新說明',
|
||||
'appUpdate.notes.expand': '展開',
|
||||
'appUpdate.notes.collapse': '收合',
|
||||
|
||||
@@ -1248,7 +1248,11 @@ export function ConfigPanelPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="config-diagnostics-intro">{t('config.diagnostics.description')}</p>
|
||||
<p className="config-diagnostics-intro">
|
||||
{t('config.diagnostics.description').split('**').map((part, index) => (
|
||||
index % 2 === 1 ? <strong key={index}>{part}</strong> : part
|
||||
))}
|
||||
</p>
|
||||
|
||||
<div className="config-diagnostics-toggle-grid">
|
||||
<div className="config-diagnostics-setting">
|
||||
|
||||
@@ -1644,93 +1644,79 @@ const getInitialColumnWidths = (): Record<EventColumnKey, number> => {
|
||||
|
||||
function TableTopScrollbar({
|
||||
tableWrapRef,
|
||||
totalWidth,
|
||||
visibleColumnKeys,
|
||||
}: {
|
||||
tableWrapRef: React.RefObject<HTMLDivElement | null>;
|
||||
totalWidth: number;
|
||||
visibleColumnKeys: EventColumnKey[];
|
||||
}) {
|
||||
const scrollbarRef = useRef<HTMLDivElement | null>(null);
|
||||
const [hasOverflow, setHasOverflow] = useState(false);
|
||||
const [scrollWidth, setScrollWidth] = useState(totalWidth);
|
||||
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollbar = scrollbarRef.current;
|
||||
const track = trackRef.current;
|
||||
const tableWrap = tableWrapRef.current;
|
||||
if (!scrollbar || !tableWrap) return;
|
||||
if (!scrollbar || !track || !tableWrap) return;
|
||||
|
||||
let syncing = false;
|
||||
// Remember the positions we applied, rather than locking a whole frame.
|
||||
// This ignores delayed programmatic/vertical scroll events without dropping
|
||||
// newer drag or trackpad input on either surface.
|
||||
let lastScrollbarLeft = scrollbar.scrollLeft;
|
||||
let lastTableLeft = tableWrap.scrollLeft;
|
||||
|
||||
const syncTable = () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
tableWrap.scrollLeft = scrollbar.scrollLeft;
|
||||
window.requestAnimationFrame(() => {
|
||||
syncing = false;
|
||||
});
|
||||
const left = scrollbar.scrollLeft;
|
||||
if (left === lastScrollbarLeft) return;
|
||||
lastScrollbarLeft = left;
|
||||
tableWrap.scrollLeft = left;
|
||||
lastTableLeft = tableWrap.scrollLeft;
|
||||
};
|
||||
|
||||
const syncScrollbar = () => {
|
||||
if (syncing) return;
|
||||
syncing = true;
|
||||
scrollbar.scrollLeft = tableWrap.scrollLeft;
|
||||
window.requestAnimationFrame(() => {
|
||||
syncing = false;
|
||||
});
|
||||
const left = tableWrap.scrollLeft;
|
||||
if (left === lastTableLeft) return;
|
||||
lastTableLeft = left;
|
||||
scrollbar.scrollLeft = left;
|
||||
lastScrollbarLeft = scrollbar.scrollLeft;
|
||||
};
|
||||
|
||||
const updateLayout = () => {
|
||||
const clientWidth = tableWrap.clientWidth;
|
||||
const wrapScrollWidth = tableWrap.scrollWidth;
|
||||
const maxScroll = Math.max(0, wrapScrollWidth - clientWidth);
|
||||
const isOverflowing = maxScroll > 1;
|
||||
const maxScroll = Math.max(0, tableWrap.scrollWidth - clientWidth);
|
||||
const left = Math.min(tableWrap.scrollLeft, maxScroll);
|
||||
|
||||
setHasOverflow(isOverflowing);
|
||||
|
||||
if (isOverflowing) {
|
||||
const scrollbarClientWidth = scrollbar.clientWidth || clientWidth;
|
||||
const targetInnerWidth = scrollbarClientWidth + maxScroll;
|
||||
setScrollWidth(targetInnerWidth);
|
||||
|
||||
if (tableWrap.scrollLeft > maxScroll) {
|
||||
tableWrap.scrollLeft = maxScroll;
|
||||
}
|
||||
scrollbar.scrollLeft = tableWrap.scrollLeft;
|
||||
} else {
|
||||
tableWrap.scrollLeft = 0;
|
||||
scrollbar.scrollLeft = 0;
|
||||
setScrollWidth(clientWidth);
|
||||
}
|
||||
// Commit the range before the position. A deferred React width update can
|
||||
// clamp the thumb to its old range and then rewind the table via scroll.
|
||||
scrollbar.classList.toggle('is-hidden', maxScroll <= 1);
|
||||
track.style.width = `${(scrollbar.clientWidth || clientWidth) + maxScroll}px`;
|
||||
tableWrap.scrollLeft = left;
|
||||
scrollbar.scrollLeft = left;
|
||||
lastTableLeft = tableWrap.scrollLeft;
|
||||
lastScrollbarLeft = scrollbar.scrollLeft;
|
||||
};
|
||||
|
||||
updateLayout();
|
||||
const frameId = window.requestAnimationFrame(updateLayout);
|
||||
|
||||
scrollbar.addEventListener('scroll', syncTable, { passive: true });
|
||||
tableWrap.addEventListener('scroll', syncScrollbar, { passive: true });
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateLayout();
|
||||
});
|
||||
const resizeObserver = new ResizeObserver(updateLayout);
|
||||
resizeObserver.observe(tableWrap);
|
||||
resizeObserver.observe(scrollbar);
|
||||
// Column resizing changes the table's width without resizing its viewport.
|
||||
if (tableWrap.firstElementChild) resizeObserver.observe(tableWrap.firstElementChild);
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
scrollbar.removeEventListener('scroll', syncTable);
|
||||
tableWrap.removeEventListener('scroll', syncScrollbar);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [tableWrapRef, totalWidth, visibleColumnKeys]);
|
||||
}, [tableWrapRef]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollbarRef}
|
||||
className={`usage-table-top-scrollbar ${hasOverflow ? '' : 'is-hidden'}`}
|
||||
className="usage-table-top-scrollbar"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div style={{ width: `${scrollWidth}px`, height: '1px' }} />
|
||||
<div ref={trackRef} style={{ height: '1px' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2080,8 +2066,6 @@ function EventsView({
|
||||
{events.items.length > 0 ? (
|
||||
<TableTopScrollbar
|
||||
tableWrapRef={tableWrapRef}
|
||||
totalWidth={totalTableWidth}
|
||||
visibleColumnKeys={visibleColumnKeys}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -9343,6 +9343,11 @@ textarea:focus-visible {
|
||||
transition: background-color 0.15s ease, height 0.15s ease, width 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
/* Keep the final resize handle from adding overflow when all columns fit. */
|
||||
.usage-events-table th:last-child .usage-col-resizer {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.usage-events-table th:hover .usage-col-resizer::after {
|
||||
width: 2px;
|
||||
height: 78%;
|
||||
|
||||
Vendored
+39
-2
@@ -7,7 +7,7 @@ import '../../src/styles.css';
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
localStorage.setItem('easy-cli-proxy-api.locale', params.get('locale') || 'zh-TW');
|
||||
localStorage.setItem('cpa-gui.usage-records-tab.v1', 'overview');
|
||||
localStorage.setItem('cpa-gui.usage-records-tab.v1', params.get('tab') || 'overview');
|
||||
localStorage.setItem('cpa-gui.usage-records-range.v1', '4h');
|
||||
document.documentElement.dataset.theme = params.get('theme') || 'dark';
|
||||
|
||||
@@ -42,7 +42,7 @@ const timeline = Array.from({ length: 4 }, (_, index) => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
mockIPC(async (cmd) => {
|
||||
mockIPC(async (cmd, args) => {
|
||||
if (cmd === 'plugin:event|listen') return 1;
|
||||
if (cmd === 'plugin:event|unlisten' || cmd === 'set_app_locale') return null;
|
||||
if (cmd === 'get_usage_collector_status') {
|
||||
@@ -51,6 +51,43 @@ mockIPC(async (cmd) => {
|
||||
if (cmd === 'get_usage_analysis') {
|
||||
return { models: [], providers: [], sources: [], apiKeys: [] };
|
||||
}
|
||||
if (cmd === 'get_usage_events') {
|
||||
const query = args?.query as { page: number; page_size: number };
|
||||
return {
|
||||
page: query.page,
|
||||
pageSize: query.page_size,
|
||||
total: 400,
|
||||
totalPages: Math.ceil(400 / query.page_size),
|
||||
items: Array.from({ length: query.page_size }, (_, index) => ({
|
||||
id: `record-${query.page}-${index}`,
|
||||
timestamp: now.toISOString(),
|
||||
latency_ms: 2000,
|
||||
ttft_ms: 200,
|
||||
source: 'test-source',
|
||||
source_display: 'Test source',
|
||||
failed: index % 4 === 0,
|
||||
canceled: false,
|
||||
failure_status: index % 4 === 0 ? 429 : 0,
|
||||
failure_body: index % 4 === 0 ? 'Rate limit exceeded' : '',
|
||||
provider: 'test-provider',
|
||||
model: 'test-model',
|
||||
alias: '',
|
||||
reasoning_effort: 'high',
|
||||
endpoint: '/v1/responses',
|
||||
api_key_hash: 'test-key',
|
||||
api_key_display: 'sk-test',
|
||||
api_key_remark: 'Test key',
|
||||
tokens: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 200,
|
||||
reasoning_tokens: 100,
|
||||
cache_read_tokens: 400,
|
||||
cache_creation_tokens: 0,
|
||||
total_tokens: 1600,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (cmd === 'get_usage_overview') {
|
||||
return {
|
||||
totalRequests: 54,
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const base = 'http://127.0.0.1:1421';
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
channel: 'msedge', headless: true, args: ['--no-proxy-server'],
|
||||
ignoreDefaultArgs: ['--hide-scrollbars'],
|
||||
});
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 700 } });
|
||||
await page.route('**/*', (route) => route.request().url().startsWith(`${base}/`) ? route.continue() : route.abort());
|
||||
await page.goto(`${base}/tests/fixtures/usage-layout.html?tab=events&locale=en`, { waitUntil: 'domcontentloaded' });
|
||||
await page.locator('.usage-table-top-scrollbar:not(.is-hidden)').waitFor();
|
||||
await page.locator('.usage-page-size-select').selectOption('200');
|
||||
await page.waitForFunction(() => document.querySelectorAll('.usage-events-table tbody tr').length === 200);
|
||||
|
||||
// Multiple input updates can arrive before the next animation frame. None
|
||||
// may be dropped, and queued programmatic scroll events must not echo back.
|
||||
const rapidScroll = await page.evaluate(async () => {
|
||||
const bar = document.querySelector('.usage-table-top-scrollbar');
|
||||
const table = document.querySelector('.usage-table-wrap');
|
||||
await new Promise(requestAnimationFrame);
|
||||
const positions = [80, 180, 320, 240, 420];
|
||||
const samples = positions.map((position) => {
|
||||
bar.scrollLeft = position;
|
||||
bar.dispatchEvent(new Event('scroll'));
|
||||
return { expected: position, actual: table.scrollLeft };
|
||||
});
|
||||
await new Promise(requestAnimationFrame);
|
||||
await new Promise(requestAnimationFrame);
|
||||
return { samples, bar: bar.scrollLeft, table: table.scrollLeft };
|
||||
});
|
||||
for (const { expected, actual } of rapidScroll.samples) {
|
||||
assert.equal(actual, expected, 'Every scrollbar position reaches the table without a frame lock');
|
||||
}
|
||||
assert.equal(rapidScroll.table, 420);
|
||||
assert.equal(rapidScroll.bar, 420);
|
||||
|
||||
const reverseScroll = await page.evaluate(async () => {
|
||||
const bar = document.querySelector('.usage-table-top-scrollbar');
|
||||
const table = document.querySelector('.usage-table-wrap');
|
||||
const samples = [360, 200, 90].map((position) => {
|
||||
table.scrollLeft = position;
|
||||
table.dispatchEvent(new Event('scroll'));
|
||||
return { expected: position, actual: bar.scrollLeft };
|
||||
});
|
||||
// A vertical scroll / delayed echo must not overwrite a newer bar input.
|
||||
bar.scrollLeft = 300;
|
||||
table.scrollTop = 200;
|
||||
table.dispatchEvent(new Event('scroll'));
|
||||
bar.dispatchEvent(new Event('scroll'));
|
||||
await new Promise(requestAnimationFrame);
|
||||
await new Promise(requestAnimationFrame);
|
||||
return { samples, bar: bar.scrollLeft, table: table.scrollLeft };
|
||||
});
|
||||
for (const { expected, actual } of reverseScroll.samples) assert.equal(actual, expected);
|
||||
assert.equal(reverseScroll.bar, 300, 'Vertical scrolling cannot rewind pending horizontal input');
|
||||
assert.equal(reverseScroll.table, 300);
|
||||
|
||||
await page.waitForTimeout(1200); // Exercise the existing one-second background refresh.
|
||||
assert.deepEqual(await page.evaluate(() => ({
|
||||
bar: document.querySelector('.usage-table-top-scrollbar').scrollLeft,
|
||||
table: document.querySelector('.usage-table-wrap').scrollLeft,
|
||||
})), { bar: 300, table: 300 }, 'Background refresh preserves the horizontal position');
|
||||
|
||||
for (const width of [850, 1400, 1100]) {
|
||||
await page.setViewportSize({ width, height: 700 });
|
||||
await page.waitForFunction(() => {
|
||||
const bar = document.querySelector('.usage-table-top-scrollbar');
|
||||
const table = document.querySelector('.usage-table-wrap');
|
||||
return Math.abs(bar.scrollLeft - table.scrollLeft) < 1
|
||||
&& Math.abs((bar.scrollWidth - bar.clientWidth) - (table.scrollWidth - table.clientWidth)) < 1;
|
||||
});
|
||||
}
|
||||
|
||||
// Real pointer dragging covers the native thumb with a large request page.
|
||||
const bar = page.locator('.usage-table-top-scrollbar');
|
||||
await bar.evaluate((element) => { element.scrollLeft = 0; });
|
||||
await page.waitForFunction(() => document.querySelector('.usage-table-wrap').scrollLeft === 0);
|
||||
const box = await bar.boundingBox();
|
||||
await page.mouse.move(box.x + 60, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(box.x + 300, box.y + box.height / 2, { steps: 30 });
|
||||
await page.mouse.up();
|
||||
if (process.env.SCREENSHOT_PATH) await page.screenshot({ path: process.env.SCREENSHOT_PATH });
|
||||
await page.waitForFunction(() => {
|
||||
const bar = document.querySelector('.usage-table-top-scrollbar');
|
||||
const table = document.querySelector('.usage-table-wrap');
|
||||
return bar.scrollLeft > 0 && Math.abs(bar.scrollLeft - table.scrollLeft) < 1;
|
||||
});
|
||||
|
||||
await bar.evaluate((element) => { element.scrollLeft = element.scrollWidth; });
|
||||
await page.waitForFunction(() => {
|
||||
const table = document.querySelector('.usage-table-wrap');
|
||||
return table.scrollLeft === table.scrollWidth - table.clientWidth;
|
||||
});
|
||||
for (const delta of [-30, 90]) {
|
||||
const previous = await page.locator('.usage-table-wrap').evaluate((element) => ({
|
||||
left: element.scrollLeft, max: element.scrollWidth - element.clientWidth,
|
||||
}));
|
||||
const handle = await page.locator('.usage-th-latency .usage-col-resizer').boundingBox();
|
||||
// Use the part inside this sticky header; the next header overlaps its edge.
|
||||
await page.mouse.move(handle.x + 1, handle.y + handle.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handle.x + 1 + delta, handle.y + handle.height / 2, { steps: 15 });
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(({ oldMax, delta }) => {
|
||||
const bar = document.querySelector('.usage-table-top-scrollbar');
|
||||
const table = document.querySelector('.usage-table-wrap');
|
||||
const max = table.scrollWidth - table.clientWidth;
|
||||
return (delta > 0 ? max > oldMax : max < oldMax)
|
||||
&& Math.abs(bar.scrollLeft - table.scrollLeft) < 1
|
||||
&& Math.abs((bar.scrollWidth - bar.clientWidth) - max) < 1;
|
||||
}, { oldMax: previous.max, delta });
|
||||
const current = await page.locator('.usage-table-wrap').evaluate((element) => ({
|
||||
left: element.scrollLeft, max: element.scrollWidth - element.clientWidth,
|
||||
}));
|
||||
assert.equal(current.left, Math.min(previous.left, current.max), 'Column resizing only clamps at the new boundary');
|
||||
}
|
||||
|
||||
await page.locator('.usage-col-settings-btn').click();
|
||||
const checkboxes = page.locator('.usage-column-option input');
|
||||
for (let index = 2; index < await checkboxes.count(); index += 1) await checkboxes.nth(index).uncheck();
|
||||
await page.locator('.usage-column-dialog-actions .primary-button').click();
|
||||
await page.waitForFunction(() => document.querySelector('.usage-table-top-scrollbar').classList.contains('is-hidden'));
|
||||
assert.equal(await page.locator('.usage-table-wrap').evaluate((element) => element.scrollLeft), 0);
|
||||
await page.locator('.usage-col-settings-btn').click();
|
||||
await page.locator('.usage-column-select-all').click();
|
||||
await page.locator('.usage-column-dialog-actions .primary-button').click();
|
||||
await page.locator('.usage-table-top-scrollbar:not(.is-hidden)').waitFor();
|
||||
await bar.evaluate((element) => { element.scrollLeft = 250; });
|
||||
await page.waitForFunction(() => document.querySelector('.usage-table-wrap').scrollLeft === 250);
|
||||
|
||||
console.log('PASS: rapid two-way scrolling, delayed echoes, refresh, window/column resizing, column visibility, and native dragging stay synchronized with 200 rows.');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in new issue
Block a user