diff --git a/client/src/assets/css/EditorPanel.scss b/client/src/assets/css/EditorPanel.scss index 6c8ca26f..07ebf7f0 100644 --- a/client/src/assets/css/EditorPanel.scss +++ b/client/src/assets/css/EditorPanel.scss @@ -12,12 +12,21 @@ .header { border-bottom: 1px solid #d2d2d2; background-color: #fff; + + // Flex (not floats) so a crowded toolbar wraps into clean rows instead + // of collapsing the float container and leaving an empty band. + display: flex; + flex-wrap: wrap; + align-items: stretch; } .actions { - float: left; + display: flex; + align-items: stretch; &.right { - float: right; + // Push the run/clear/etc. group to the right; on a narrow pane it + // wraps to its own row rather than overflowing. + margin-left: auto; } } diff --git a/client/src/components/EditorPanel.js b/client/src/components/EditorPanel.js index ddcb2cee..57d8a592 100644 --- a/client/src/components/EditorPanel.js +++ b/client/src/components/EditorPanel.js @@ -19,6 +19,7 @@ import { } from 'actions/query' import QueryVarsEditor from 'components/QueryVarsEditor' +import RunHistoryPanel from 'components/RunHistoryPanel' import Editor from 'containers/Editor' import '../assets/css/EditorPanel.scss' @@ -101,6 +102,7 @@ export default function EditorPanel() { {queryOptions}
+ + ) + } + + return ( +
+ + + {isOpen && coords && ( +
+
+ setSearch(e.target.value)} + /> +
+
+ {visibleFrames.length ? ( + visibleFrames.map(renderRow) + ) : ( +
+ {items.length + ? 'No runs match your search' + : 'No queries have been run yet'} +
+ )} +
+
+ )} +
+ ) +} diff --git a/client/src/components/RunHistoryPanel.scss b/client/src/components/RunHistoryPanel.scss new file mode 100644 index 00000000..da204f9d --- /dev/null +++ b/client/src/components/RunHistoryPanel.scss @@ -0,0 +1,116 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +.run-history { + display: inline-block; + position: relative; + + > .action.open { + background: #f7f7f7; + } + + .run-history-panel { + // Fixed + JS-computed top/left/width (see RunHistoryPanel.js) so the + // panel stays on-screen from a mid-toolbar button and is never clipped + // by a narrow editor pane or an overflow:hidden ancestor. + position: fixed; + z-index: 1000; + + background: #fff; + border: 1px solid #d2d2d2; + border-radius: 2px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + .run-history-search { + padding: 8px; + border-bottom: 1px solid #e5e5e5; + } + + .run-history-list { + max-height: 320px; + overflow-y: auto; + } + + .run-history-empty { + padding: 14px 12px; + text-align: center; + font-size: 13px; + } + + .run-history-row { + display: flex; + align-items: center; + width: 100%; + + padding: 6px 10px; + border: none; + border-bottom: 1px solid #f0f0f0; + background: transparent; + text-align: left; + font-size: 13px; + color: #333; + cursor: pointer; + + &:last-child { + border-bottom: none; + } + + &:hover { + background: #f7f7f7; + } + + &.active { + background: #eef6fb; + } + + .action-icon { + flex: 0 0 auto; + margin-right: 8px; + color: #8a8a8a; + font-size: 12px; + } + + .status-dot { + flex: 0 0 auto; + width: 8px; + height: 8px; + margin-right: 8px; + border-radius: 50%; + + &.status-ok { + background-color: #28a745; + } + &.status-error { + background-color: #dc3545; + } + &.status-unknown { + background-color: #adb5bd; + } + } + + .snippet { + flex: 1 1 auto; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-family: monospace; + font-size: 12px; + } + + .meta { + flex: 0 0 auto; + margin-left: 10px; + color: #8a8a8a; + font-size: 11px; + white-space: nowrap; + + .latency { + margin-right: 8px; + color: #5b8c5a; + } + } + } +} diff --git a/client/src/lib/runHistory.js b/client/src/lib/runHistory.js new file mode 100644 index 00000000..bb34d8ad --- /dev/null +++ b/client/src/lib/runHistory.js @@ -0,0 +1,98 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TAB_JSON } from 'actions/frames' + +export const SNIPPET_MAX_LENGTH = 60 + +/** + * formatLatencyMs renders a latency expressed in nanoseconds as a short + * millisecond string, e.g. 1234567 -> "1.2ms". Returns '' when the value + * is missing or not a positive number. + */ +export function formatLatencyMs(latencyNs) { + if (typeof latencyNs !== 'number' || !isFinite(latencyNs) || latencyNs <= 0) { + return '' + } + const ms = latencyNs / 1e6 + if (ms < 1) { + return `${ms.toFixed(2)}ms` + } + if (ms < 10) { + return `${ms.toFixed(1)}ms` + } + return `${Math.round(ms)}ms` +} + +// Collapses all whitespace runs (including newlines) into single spaces +// and truncates to SNIPPET_MAX_LENGTH characters, appending an ellipsis. +function makeSnippet(query) { + const oneLine = String(query || '') + .replace(/\s+/g, ' ') + .trim() + if (oneLine.length <= SNIPPET_MAX_LENGTH) { + return oneLine + } + return `${oneLine.slice(0, SNIPPET_MAX_LENGTH)}…` +} + +// Picks the most relevant tab result for a frame: the JSON tab when it has +// completed (mutations and JSON queries land there), otherwise any other +// completed tab (e.g. a query only executed on the visual tab). +function pickTabResult(frameResult) { + if (!frameResult) { + return null + } + const jsonResult = frameResult[TAB_JSON] + if (jsonResult && jsonResult.completed) { + return jsonResult + } + const completed = Object.values(frameResult).find( + (tabResult) => tabResult && tabResult.completed, + ) + return completed || null +} + +/** + * summarizeFrame computes display info for one history row. + * + * @param frame - a frames.items entry ({ id, action, query, ... }) + * @param frameResults - the frames.frameResults map keyed by frame id, + * holding per-tab results ({ completed, error, serverLatencyNs, ... }) + * @returns {{status: 'ok'|'error'|'unknown', latencyText: string, snippet: string}} + */ +export function summarizeFrame(frame, frameResults) { + const snippet = makeSnippet(frame && frame.query) + const frameResult = frame && frameResults ? frameResults[frame.id] : undefined + const tabResult = pickTabResult(frameResult) + + if (!tabResult) { + return { status: 'unknown', latencyText: '', snippet } + } + return { + status: tabResult.error ? 'error' : 'ok', + latencyText: formatLatencyMs(tabResult.serverLatencyNs), + snippet, + } +} + +/** + * filterFrames returns the frames whose query text contains the search + * string (case-insensitive). A blank search returns all frames. + */ +export function filterFrames(items, query) { + const frames = items || [] + const needle = String(query || '') + .trim() + .toLowerCase() + if (!needle) { + return frames + } + return frames.filter( + (frame) => + typeof frame?.query === 'string' && + frame.query.toLowerCase().includes(needle), + ) +} diff --git a/client/src/lib/runHistory.test.js b/client/src/lib/runHistory.test.js new file mode 100644 index 00000000..0d9cb33f --- /dev/null +++ b/client/src/lib/runHistory.test.js @@ -0,0 +1,244 @@ +/* + * SPDX-FileCopyrightText: © 2017-2026 Istari Digital, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TAB_JSON, TAB_VISUAL } from 'actions/frames' + +import { + SNIPPET_MAX_LENGTH, + filterFrames, + formatLatencyMs, + summarizeFrame, +} from './runHistory' + +const FRAME_ID = 'frame-1' +const makeFrame = (overrides = {}) => ({ + id: FRAME_ID, + action: 'query', + query: '{ q(func: has(name)) { name } }', + ...overrides, +}) + +describe('summarizeFrame', () => { + describe('status', () => { + it('is unknown when there are no results for the frame', () => { + const { status } = summarizeFrame(makeFrame(), {}) + expect(status).toBe('unknown') + }) + + it('is unknown when frameResults is undefined', () => { + const { status } = summarizeFrame(makeFrame(), undefined) + expect(status).toBe('unknown') + }) + + it('is unknown when no tab has completed yet', () => { + const frameResults = { + [FRAME_ID]: { + [TAB_JSON]: { canExecute: true }, + [TAB_VISUAL]: { canExecute: false, executionStart: 123 }, + }, + } + expect(summarizeFrame(makeFrame(), frameResults).status).toBe('unknown') + }) + + it('is ok when the JSON tab completed without error', () => { + const frameResults = { + [FRAME_ID]: { + [TAB_JSON]: { completed: true, response: { data: {} } }, + }, + } + expect(summarizeFrame(makeFrame(), frameResults).status).toBe('ok') + }) + + it('is error when the JSON tab completed with an error', () => { + const frameResults = { + [FRAME_ID]: { + [TAB_JSON]: { completed: true, error: new Error('boom') }, + }, + } + expect(summarizeFrame(makeFrame(), frameResults).status).toBe('error') + }) + + it('falls back to another completed tab when JSON tab never ran', () => { + const frameResults = { + [FRAME_ID]: { + [TAB_JSON]: { canExecute: true }, + [TAB_VISUAL]: { completed: true, response: { data: {} } }, + }, + } + expect(summarizeFrame(makeFrame(), frameResults).status).toBe('ok') + }) + + it('reports an error from a fallback tab', () => { + const frameResults = { + [FRAME_ID]: { + [TAB_JSON]: { canExecute: true }, + [TAB_VISUAL]: { completed: true, error: { message: 'bad' } }, + }, + } + expect(summarizeFrame(makeFrame(), frameResults).status).toBe('error') + }) + + it('prefers the JSON tab result over other completed tabs', () => { + const frameResults = { + [FRAME_ID]: { + [TAB_JSON]: { completed: true, error: { message: 'bad json' } }, + [TAB_VISUAL]: { completed: true, response: { data: {} } }, + }, + } + expect(summarizeFrame(makeFrame(), frameResults).status).toBe('error') + }) + + it('ignores results belonging to other frames', () => { + const frameResults = { + 'other-frame': { + [TAB_JSON]: { completed: true, response: { data: {} } }, + }, + } + expect(summarizeFrame(makeFrame(), frameResults).status).toBe('unknown') + }) + }) + + describe('latencyText', () => { + it('formats server latency from the completed tab as milliseconds', () => { + const frameResults = { + [FRAME_ID]: { + [TAB_JSON]: { completed: true, serverLatencyNs: 42e6 }, + }, + } + expect(summarizeFrame(makeFrame(), frameResults).latencyText).toBe('42ms') + }) + + it('is empty when the frame never executed', () => { + expect(summarizeFrame(makeFrame(), {}).latencyText).toBe('') + }) + + it('is empty when server latency is missing on a completed tab', () => { + const frameResults = { + [FRAME_ID]: { [TAB_JSON]: { completed: true } }, + } + expect(summarizeFrame(makeFrame(), frameResults).latencyText).toBe('') + }) + + it('is empty when server latency is zero (unknown)', () => { + const frameResults = { + [FRAME_ID]: { + [TAB_JSON]: { completed: true, serverLatencyNs: 0 }, + }, + } + expect(summarizeFrame(makeFrame(), frameResults).latencyText).toBe('') + }) + }) + + describe('snippet', () => { + it('returns short queries unchanged', () => { + const frame = makeFrame({ query: '{ me { name } }' }) + expect(summarizeFrame(frame, {}).snippet).toBe('{ me { name } }') + }) + + it('collapses newlines and extra whitespace into single spaces', () => { + const frame = makeFrame({ query: '{\n me {\n name\t }\n}' }) + expect(summarizeFrame(frame, {}).snippet).toBe('{ me { name } }') + }) + + it('trims leading and trailing whitespace', () => { + const frame = makeFrame({ query: ' { me { name } } \n' }) + expect(summarizeFrame(frame, {}).snippet).toBe('{ me { name } }') + }) + + it('truncates long queries to the limit with an ellipsis', () => { + const frame = makeFrame({ query: 'x'.repeat(200) }) + const { snippet } = summarizeFrame(frame, {}) + expect(snippet).toBe(`${'x'.repeat(SNIPPET_MAX_LENGTH)}…`) + expect(snippet.length).toBe(SNIPPET_MAX_LENGTH + 1) + }) + + it('does not truncate a query exactly at the limit', () => { + const frame = makeFrame({ query: 'y'.repeat(SNIPPET_MAX_LENGTH) }) + expect(summarizeFrame(frame, {}).snippet).toBe( + 'y'.repeat(SNIPPET_MAX_LENGTH), + ) + }) + + it('handles frames without a query', () => { + const frame = makeFrame({ query: undefined }) + expect(summarizeFrame(frame, {}).snippet).toBe('') + }) + }) +}) + +describe('formatLatencyMs', () => { + it('formats sub-millisecond latencies with two decimals', () => { + expect(formatLatencyMs(450000)).toBe('0.45ms') + }) + + it('formats single-digit milliseconds with one decimal', () => { + expect(formatLatencyMs(1234567)).toBe('1.2ms') + }) + + it('rounds latencies of 10ms and above to whole milliseconds', () => { + expect(formatLatencyMs(15.6e6)).toBe('16ms') + expect(formatLatencyMs(1234e6)).toBe('1234ms') + }) + + it('returns empty string for missing or invalid values', () => { + expect(formatLatencyMs(undefined)).toBe('') + expect(formatLatencyMs(null)).toBe('') + expect(formatLatencyMs(0)).toBe('') + expect(formatLatencyMs(-5)).toBe('') + expect(formatLatencyMs(NaN)).toBe('') + expect(formatLatencyMs('100')).toBe('') + }) +}) + +describe('filterFrames', () => { + const items = [ + makeFrame({ id: 'a', query: '{ people(func: has(Name)) { name } }' }), + makeFrame({ id: 'b', query: 'schema {}' }), + makeFrame({ + id: 'c', + action: 'mutate', + query: '{ set { _:x "Alice" . } }', + }), + ] + + it('returns all items for an empty search', () => { + expect(filterFrames(items, '')).toEqual(items) + }) + + it('returns all items for a whitespace-only search', () => { + expect(filterFrames(items, ' ')).toEqual(items) + }) + + it('returns all items when search is undefined', () => { + expect(filterFrames(items, undefined)).toEqual(items) + }) + + it('matches by case-insensitive substring', () => { + expect(filterFrames(items, 'NAME').map((f) => f.id)).toEqual(['a', 'c']) + expect(filterFrames(items, 'alice').map((f) => f.id)).toEqual(['c']) + expect(filterFrames(items, 'SCHEMA').map((f) => f.id)).toEqual(['b']) + }) + + it('trims the search string before matching', () => { + expect(filterFrames(items, ' alice ').map((f) => f.id)).toEqual(['c']) + }) + + it('returns no items when nothing matches', () => { + expect(filterFrames(items, 'does-not-exist')).toEqual([]) + }) + + it('skips frames without a query string', () => { + const withBroken = [...items, { id: 'd' }, { id: 'e', query: 42 }] + expect(filterFrames(withBroken, 'name').map((f) => f.id)).toEqual([ + 'a', + 'c', + ]) + }) + + it('handles a missing items list', () => { + expect(filterFrames(undefined, 'name')).toEqual([]) + expect(filterFrames(null, '')).toEqual([]) + }) +}) diff --git a/client/src/reducers/frames.js b/client/src/reducers/frames.js index c2ef0451..65ff4d54 100644 --- a/client/src/reducers/frames.js +++ b/client/src/reducers/frames.js @@ -53,7 +53,9 @@ export default (state = defaultState, action) => draft.items.shift() } } - draft.items.unshift(frame) + // Stamp arrival time so run history can show relative times. + // Legacy frames (restored from older persisted state) may lack it. + draft.items.unshift({ createdAt: Date.now(), ...frame }) draft.frameResults[frame.id] = { [TAB_JSON]: { canExecute: true }, [TAB_VISUAL]: { canExecute: true },