From 234632b71166cf0470fe6212dbbe8dde57180a18 Mon Sep 17 00:00:00 2001 From: Sakana <15715093608@163.com> Date: Thu, 5 Nov 2026 15:25:53 +0800 Subject: [PATCH] feat(data-inspector): copy query results with context Sharing a query result requires its source and executed conditions to make sense of the observation. Capture them with the successful response and copy the result, receipt time and data limits as Markdown. Preserve result-only JSON copying. Explain retained results, clear them on source changes, reject late responses and provide complete manual text when clipboard writes fail. --- plugins/data-inspector/app/App.vue | 2 + .../app/components/ResultViewer.vue | 69 ++++- .../app/composables/query-observation.ts | 38 +++ .../app/composables/workbench.ts | 43 ++- .../test/query-observation.test.ts | 56 ++++ .../test/workbench-observation.test.ts | 274 ++++++++++++++++++ 6 files changed, 469 insertions(+), 13 deletions(-) create mode 100644 plugins/data-inspector/app/composables/query-observation.ts create mode 100644 plugins/data-inspector/test/query-observation.test.ts create mode 100644 plugins/data-inspector/test/workbench-observation.test.ts diff --git a/plugins/data-inspector/app/App.vue b/plugins/data-inspector/app/App.vue index b8b29c0b..833e4803 100644 --- a/plugins/data-inspector/app/App.vue +++ b/plugins/data-inspector/app/App.vue @@ -89,6 +89,8 @@ function saveCurrent(input: { title?: string, description?: string, scope: Saved import type { NodePath, QueryStats } from '@devframes/plugin-data-inspector/engine' +import type { QueryObservation } from '../composables/query-observation' import ActionIconButton from '@antfu/design/components/Action/ActionIconButton.vue' import DisplayBadge from '@antfu/design/components/Display/DisplayBadge.vue' import DisplayBytes from '@antfu/design/components/Display/DisplayBytes.vue' import DisplayDate from '@antfu/design/components/Display/DisplayDate.vue' import DisplayDuration from '@antfu/design/components/Display/DisplayDuration.vue' -import { computed, shallowRef, watch } from 'vue' +import FormTextarea from '@antfu/design/components/Form/FormTextarea.vue' +import OverlayModal from '@antfu/design/components/Overlay/OverlayModal.vue' +import { computed, onUnmounted, shallowRef, watch } from 'vue' import { useDiscoveryViewer } from '../composables/discovery' import { prepareForDisplay } from '../composables/display-transform' +import { observationMarkdown } from '../composables/query-observation' import { colorScheme } from '../composables/scheme' import EditPanel from './EditPanel.vue' const props = defineProps<{ + observation: QueryObservation | null + observationNotices: string[] result: unknown hasResult: boolean stats: (QueryStats & { rpcMs: number }) | null @@ -72,16 +78,38 @@ const lockTooltip = computed(() => { return '' }) -const copied = shallowRef(false) +const copied = shallowRef<'json' | 'markdown' | null>(null) +const copying = shallowRef(false) +const manualCopyOpen = shallowRef(false) +const manualCopyText = shallowRef('') +const copyError = shallowRef('') let copiedTimer: ReturnType | undefined -function copyResult(): void { +onUnmounted(() => clearTimeout(copiedTimer)) + +async function copyResult(format: 'json' | 'markdown'): Promise { + if (!props.hasResult || !props.observation || copying.value) + return + // Serialize at the click, before a refresh can replace the observation. + const text = format === 'markdown' + ? observationMarkdown(props.observation, props.observationNotices) + : JSON.stringify(props.observation.result, null, 2) ?? 'null' + copying.value = true + copied.value = null + copyError.value = '' + clearTimeout(copiedTimer) try { - void navigator.clipboard.writeText(JSON.stringify(props.result, null, 2)) - copied.value = true - clearTimeout(copiedTimer) - copiedTimer = setTimeout(() => (copied.value = false), 1200) + await navigator.clipboard.writeText(text) + copied.value = format + copiedTimer = setTimeout(() => (copied.value = null), 1200) + } + catch { + copyError.value = 'Clipboard write failed. Select and copy the complete text below.' + manualCopyText.value = text + manualCopyOpen.value = true + } + finally { + copying.value = false } - catch {} } @@ -138,13 +166,22 @@ function copyResult(): void { @click="viewer.setExpanded(1)" /> + + {{ copied ? 'Copied to clipboard' : '' }} +
+

+ {{ notice }} +

+
+ + +
- Results render here, start typing a query + Run a successful query to view and copy its result
diff --git a/plugins/data-inspector/app/composables/query-observation.ts b/plugins/data-inspector/app/composables/query-observation.ts new file mode 100644 index 00000000..912afec5 --- /dev/null +++ b/plugins/data-inspector/app/composables/query-observation.ts @@ -0,0 +1,38 @@ +import type { FilterOptions, QueryStats } from '@devframes/plugin-data-inspector/engine' + +/** One successful response and the conditions that produced it. */ +export interface QueryObservation { + source: { id: string, title: string } + query: string + filters: Required + receivedAt: number + result: unknown + normalize: QueryStats['normalize'] +} + +function fenced(text: string, language = ''): string { + const runs = text.match(/`+/g) ?? [] + const fence = '`'.repeat(runs.reduce((length, run) => Math.max(length, run.length + 1), 3)) + return `${fence}${language}\n${text}\n${fence}` +} + +export function observationMarkdown(observation: QueryObservation, notices: string[]): string { + const { source, query, filters, receivedAt, result, normalize } = observation + return [ + '# Data Inspector query result', + '## Source and time', + fenced(`Name: ${source.title}\nID: ${source.id}`), + `Browser received this result at ${new Date(receivedAt).toISOString()} (UTC). This is not the time when the data was sampled on the node side.`, + ...notices, + '## Executed query', + fenced(query, 'jora'), + 'Filters:', + ...Object.entries(filters).map(([key, value]) => `- ${key}: ${value}`), + '## Result', + fenced(JSON.stringify(result, null, 2) ?? 'null', 'json'), + '## Data scope and limits', + 'This is one successful response after Data Inspector conversion and filtering, not a lossless copy of the original object.', + `Reported truncation: depth=${normalize.truncatedDepth}, entries=${normalize.truncatedEntries}, properties=${normalize.truncatedProps}.`, + 'No reported truncation does not prove completeness. Later node expansions are not included. The source name and ID do not identify a machine or deployment.', + ].join('\n\n') +} diff --git a/plugins/data-inspector/app/composables/workbench.ts b/plugins/data-inspector/app/composables/workbench.ts index 2ca35d7f..593a7ec3 100644 --- a/plugins/data-inspector/app/composables/workbench.ts +++ b/plugins/data-inspector/app/composables/workbench.ts @@ -8,6 +8,7 @@ import type { DataSourceMeta, FilterOptions, NodePath, QueryOutcome, QueryStats, * An empty query runs `$` (the root), so every source lands on a full view. */ import type { InjectionKey } from 'vue' +import type { QueryObservation } from './query-observation' import jora from 'jora' import { computed, inject, reactive, ref, shallowRef, watch } from 'vue' import { backend, connection } from './rpc' @@ -178,6 +179,22 @@ export function useWorkbench() { const statsStale = ref(false) const result = shallowRef() const hasResult = ref(false) + const observation = shallowRef(null) + const observationNotices = computed(() => { + const saved = observation.value + if (!saved) + return [] + const notices: string[] = [] + if (saved.query !== (query.value.trim() || '$') || FILTER_KEYS.some(key => saved.filters[key] !== settings[key])) + notices.push('Query conditions changed. This result uses the earlier conditions shown in the copy.') + if (running.value) + notices.push('A new query is pending. This is the previous successful result.') + if (serverError.value) + notices.push('The latest query failed. This is an earlier successful result.') + if (syntax.value.kind !== 'ok') + notices.push('The current query has incomplete or invalid syntax and was not executed.') + return notices + }) /** When the last successful query landed, driving the "ran … ago" label. */ const lastRunAt = ref(null) @@ -270,12 +287,17 @@ export function useWorkbench() { syntax.value = { kind: 'ok' } } + const request = { + source: { id: sourceId.value, title: activeSource.value?.title ?? sourceId.value }, + query: text, + filters: { ...settings }, + } const seq = ++runSeq running.value = true const started = performance.now() let outcome: QueryOutcome try { - outcome = await backend().query(sourceId.value, text, { ...settings }) + outcome = await backend().query(request.source.id, request.query, request.filters) } catch (error) { if (seq === runSeq) { @@ -305,6 +327,12 @@ export function useWorkbench() { result.value = outcome.result hasResult.value = true lastRunAt.value = Date.now() + observation.value = { + ...request, + receivedAt: lastRunAt.value, + result: outcome.result, + normalize: { ...outcome.stats.normalize }, + } } function scheduleRun(): void { @@ -499,6 +527,17 @@ export function useWorkbench() { } scheduleRun() }) + watch(sourceId, () => { + ++runSeq + running.value = false + result.value = undefined + hasResult.value = false + observation.value = null + lastRunAt.value = null + stats.value = null + statsStale.value = false + serverError.value = null + }, { flush: 'sync' }) watch(sourceId, () => { suggestions.value = [] restoreDraft() @@ -569,6 +608,8 @@ export function useWorkbench() { statsStale, result, hasResult, + observation, + observationNotices, lastRunAt, suggestions, skeleton, diff --git a/plugins/data-inspector/test/query-observation.test.ts b/plugins/data-inspector/test/query-observation.test.ts new file mode 100644 index 00000000..b61e31bf --- /dev/null +++ b/plugins/data-inspector/test/query-observation.test.ts @@ -0,0 +1,56 @@ +import type { QueryObservation } from '../app/composables/query-observation' +import { describe, expect, it } from 'vitest' +import { observationMarkdown } from '../app/composables/query-observation' + +function observation(): QueryObservation { + return { + source: { id: 'tasks', title: 'Task queue' }, + query: '$', + filters: { excludeFunctions: false, excludeUnderscoreProps: true, excludeDollarProps: false }, + receivedAt: Date.UTC(2026, 10, 5, 12), + result: [], + normalize: { + nodes: 1, + refs: 0, + truncatedDepth: 0, + truncatedEntries: 0, + truncatedProps: 0, + ms: 1, + }, + } +} + +describe('query observation Markdown', () => { + it('copies large results with many backtick runs without dropping content', () => { + const saved = observation() + saved.result = Array.from({ length: 200 }, () => '`a'.repeat(2000)) + expect(observationMarkdown(saved, [])).toContain(JSON.stringify(saved.result, null, 2)) + }) + + it('includes the successful empty result, explicit filters, receipt time and limits', () => { + const text = observationMarkdown(observation(), ['A new query is pending. This is the previous successful result.']) + expect(text).toContain('Name: Task queue\nID: tasks') + expect(text).toContain('2026-11-05T12:00:00.000Z (UTC)') + expect(text).toContain('excludeFunctions: false') + expect(text).toContain('excludeUnderscoreProps: true') + expect(text).toContain('excludeDollarProps: false') + expect(text).toContain('```json\n[]\n```') + expect(text).toContain('A new query is pending.') + expect(text).toContain('No reported truncation does not prove completeness.') + expect(text).not.toContain('queryMs') + }) + + it('reports all truncation counters and preserves arbitrary fenced content', () => { + const saved = observation() + saved.source.title = 'Queue\n```\n# arbitrary title' + saved.query = '"````"' + saved.result = { value: '`````\ntext' } + saved.normalize.truncatedProps = 5 + const text = observationMarkdown(saved, []) + expect(text).toContain('````\nName: Queue\n```\n# arbitrary title\nID: tasks\n````') + expect(text).toContain('`````jora\n"````"\n`````') + expect(text).toContain('``````json\n') + expect(text).toContain('depth=0, entries=0, properties=5') + expect(text).toContain(JSON.stringify(saved.result, null, 2)) + }) +}) diff --git a/plugins/data-inspector/test/workbench-observation.test.ts b/plugins/data-inspector/test/workbench-observation.test.ts new file mode 100644 index 00000000..7b46e8de --- /dev/null +++ b/plugins/data-inspector/test/workbench-observation.test.ts @@ -0,0 +1,274 @@ +import type { QueryOutcome } from '../src/node/engine/contract' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { effectScope, nextTick } from 'vue' +import { useWorkbench } from '../app/composables/workbench' + +const query = vi.hoisted(() => vi.fn()) +vi.mock('../app/composables/rpc', () => ({ + connection: { mode: 'rpc' }, + backend: () => ({ + query, + skeleton: async () => ({ + ok: true, + skeleton: {}, + nodes: 0, + ms: 0, + }), + sources: async () => [ + { + id: 'a', + title: 'Source A', + static: false, + writable: false, + }, + { + id: 'b', + title: 'Source B', + static: false, + writable: false, + }, + ], + }), +})) + +function success(result: unknown): QueryOutcome { + return { + ok: true, + result, + stats: { + queryMs: 1, + payloadBytes: 2, + normalize: { + nodes: 1, + refs: 0, + truncatedDepth: 0, + truncatedEntries: 0, + truncatedProps: 0, + ms: 1, + }, + }, + } +} + +let scope: ReturnType +beforeEach(() => { + vi.useFakeTimers() + vi.stubGlobal('location', { hash: '', pathname: '/', search: '' }) + vi.stubGlobal('history', { + replaceState: vi.fn(), + }) + vi.stubGlobal('localStorage', { + getItem: () => null, + setItem: vi.fn(), + }) + query.mockReset().mockResolvedValue(success([])) + scope = effectScope() +}) +afterEach(() => { + scope.stop() + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() +}) +async function setup() { + const wb = scope.run(() => useWorkbench())! + await wb.loadSources() + await nextTick() + return wb +} + +describe('successful query observation', () => { + it('keeps the executed conditions when the editor and filters change', async () => { + const wb = await setup() + expect(wb.hasResult.value).toBe(true) + wb.query.value = 'items' + expect(wb.observation.value?.query).toBe('$') + expect(wb.observationNotices.value.join(' ')).toContain('conditions changed') + let finish!: (value: QueryOutcome) => void + query.mockImplementationOnce(() => new Promise(resolve => (finish = resolve))) + const run = wb.runNow() + expect(wb.observationNotices.value.join(' ')).toContain('pending') + wb.query.value = 'different' + finish(success({ original: true })) + await run + expect(wb.observation.value?.query).toBe('items') + expect(wb.observation.value?.result).toEqual({ original: true }) + expect(wb.observation.value?.source).toEqual({ id: 'a', title: 'Source A' }) + expect(wb.observation.value?.filters.excludeFunctions).toBe(false) + expect(wb.observationNotices.value.join(' ')).toContain('conditions changed') + }) + + it('keeps the earlier success through syntax, server and transport failures', async () => { + const wb = await setup() + wb.query.value = '[' + await wb.runNow() + expect(wb.hasResult.value).toBe(true) + expect(wb.observationNotices.value.join(' ')).toContain('syntax') + wb.query.value = '$' + query.mockResolvedValueOnce({ + ok: false, + error: { name: 'QueryError', message: 'failed' }, + }) + await wb.runNow() + expect(wb.observation.value?.result).toEqual([]) + expect(wb.observationNotices.value.join(' ')).toContain('latest query failed') + query.mockRejectedValueOnce(new Error('disconnected')) + await wb.runNow() + expect(wb.observation.value?.result).toEqual([]) + expect(wb.serverError.value).toContain('disconnected') + }) + + it('clears immediately on source changes and drops late old-source responses', async () => { + const wb = await setup() + let finish!: (value: QueryOutcome) => void + query.mockImplementationOnce(() => new Promise(resolve => (finish = resolve))) + const oldRun = wb.runNow() + query.mockResolvedValueOnce({ + ok: false, + error: { name: 'Unavailable', message: 'new source failed' }, + }) + wb.sourceId.value = 'b' + expect(wb.hasResult.value).toBe(false) + expect(wb.observation.value).toBeNull() + finish(success('old source')) + await oldRun + await nextTick() + expect(wb.hasResult.value).toBe(false) + expect(wb.observation.value).toBeNull() + wb.sourceId.value = '' + expect(wb.running.value).toBe(false) + }) + + it.each([[], {}, null, false, 0, ''])('retains a successful empty value: %j', async (value) => { + query.mockResolvedValueOnce(success(value)) + const wb = await setup() + expect(wb.hasResult.value).toBe(true) + expect(wb.observation.value?.result).toEqual(value) + expect(wb.result.value).toEqual(value) + }) + + it('has no observation before the first success or after an initial failure', async () => { + let finish!: (value: QueryOutcome) => void + query.mockImplementationOnce(() => new Promise(resolve => (finish = resolve))) + const wb = await setup() + expect(wb.hasResult.value).toBe(false) + expect(wb.observation.value).toBeNull() + finish({ + ok: false, + error: { name: 'Unavailable', message: 'failed' }, + }) + await nextTick() + expect(wb.hasResult.value).toBe(false) + expect(wb.observation.value).toBeNull() + }) + + it('binds filter changes to the newer request and drops an older response', async () => { + const wb = await setup() + let finishOld!: (value: QueryOutcome) => void + let finishNew!: (value: QueryOutcome) => void + query.mockImplementationOnce(() => new Promise(resolve => (finishOld = resolve))) + const oldRun = wb.runNow() + query.mockImplementationOnce(() => new Promise(resolve => (finishNew = resolve))) + wb.settings.excludeFunctions = true + wb.settings.excludeUnderscoreProps = true + wb.settings.excludeDollarProps = true + expect(wb.observation.value?.filters).toEqual({ excludeFunctions: false, excludeUnderscoreProps: false, excludeDollarProps: false }) + expect(wb.observationNotices.value.join(' ')).toContain('conditions changed') + await nextTick() + expect(query).toHaveBeenLastCalledWith('a', '$', { excludeFunctions: true, excludeUnderscoreProps: true, excludeDollarProps: true }) + finishOld(success('older filters')) + await oldRun + expect(wb.running.value).toBe(true) + expect(wb.observation.value?.result).toEqual([]) + finishNew(success('newer filters')) + await nextTick() + expect(wb.running.value).toBe(false) + expect(wb.observation.value?.result).toBe('newer filters') + expect(wb.observation.value?.filters).toEqual({ excludeFunctions: true, excludeUnderscoreProps: true, excludeDollarProps: true }) + expect(wb.observationNotices.value).toEqual([]) + }) + + it.each(['success', 'failure', 'rejection'])('ignores a late older %s after the newer response', async (kind) => { + const wb = await setup() + let finish!: (value: QueryOutcome) => void + let reject!: (reason: Error) => void + query.mockImplementationOnce(() => new Promise((resolve, fail) => { + finish = resolve + reject = fail + })) + const oldRun = wb.runNow() + wb.query.value = 'items' + query.mockResolvedValueOnce(success('newest')) + await wb.runNow() + const latest = wb.observation.value + if (kind === 'rejection') { + reject(new Error('old transport failure')) + } + else if (kind === 'failure') { + finish({ + ok: false, + error: { name: 'OldError', message: 'old failure' }, + }) + } + else { + finish(success('old success')) + } + await oldRun + expect(wb.observation.value).toBe(latest) + expect(wb.observation.value?.query).toBe('items') + expect(wb.result.value).toBe('newest') + expect(wb.serverError.value).toBeNull() + expect(wb.running.value).toBe(false) + }) + + it('clears the old observation when the next source restores an invalid draft', async () => { + vi.stubGlobal('localStorage', { + getItem: () => JSON.stringify({ b: '[' }), + setItem: vi.fn(), + }) + const wb = await setup() + let finish!: (value: QueryOutcome) => void + query.mockImplementationOnce(() => new Promise(resolve => (finish = resolve))) + const oldRun = wb.runNow() + wb.sourceId.value = 'b' + await nextTick() + expect(wb.query.value).toBe('[') + expect(wb.syntax.value.kind).not.toBe('ok') + expect(wb.hasResult.value).toBe(false) + expect(wb.running.value).toBe(false) + expect(wb.lastRunAt.value).toBeNull() + expect(wb.stats.value).toBeNull() + expect(query).toHaveBeenCalledTimes(2) + finish(success('old source')) + await oldRun + expect(wb.observation.value).toBeNull() + expect(wb.result.value).toBeUndefined() + expect(wb.hasResult.value).toBe(false) + }) + + it('keeps the receipt time and observation when only time passes', async () => { + vi.setSystemTime(new Date('2026-11-05T01:00:00Z')) + const wb = await setup() + const saved = wb.observation.value + expect(saved?.receivedAt).toBe(Date.now()) + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000) + expect(wb.observation.value).toBe(saved) + expect(wb.lastRunAt.value).toBe(saved?.receivedAt) + expect(wb.hasResult.value).toBe(true) + expect(wb.observationNotices.value).toEqual([]) + expect(query).toHaveBeenCalledTimes(1) + }) + + it.each(['', '&source=a'])('preserves a shared query with source selection %s', async (source) => { + vi.stubGlobal('location', { hash: `#query=items&excludeFunctions=1${source}`, pathname: '/', search: '' }) + vi.stubGlobal('localStorage', { + getItem: () => JSON.stringify({ a: 'oldDraft' }), + setItem: vi.fn(), + }) + const wb = await setup() + expect(wb.query.value).toBe('items') + await wb.runNow() + expect(query).toHaveBeenLastCalledWith('a', 'items', { excludeFunctions: true, excludeUnderscoreProps: false, excludeDollarProps: false }) + expect(wb.observation.value?.query).toBe('items') + }) +})