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') + }) +})