Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions plugins/data-inspector/app/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ function saveCurrent(input: { title?: string, description?: string, scope: Saved
<Pane class="min-w-0">
<ResultViewer
:result="wb.result.value"
:observation="wb.observation.value"
:observation-notices="wb.observationNotices.value"
:has-result="wb.hasResult.value"
:stats="wb.stats.value"
:stats-stale="wb.statsStale.value"
Expand Down
69 changes: 57 additions & 12 deletions plugins/data-inspector/app/components/ResultViewer.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
<script setup lang="ts">
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
Expand Down Expand Up @@ -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<typeof setTimeout> | undefined
function copyResult(): void {
onUnmounted(() => clearTimeout(copiedTimer))

async function copyResult(format: 'json' | 'markdown'): Promise<void> {
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 {}
}
</script>

Expand Down Expand Up @@ -138,13 +166,22 @@ function copyResult(): void {
@click="viewer.setExpanded(1)"
/>
<ActionIconButton
v-if="hasResult"
class="text-sm"
:icon="copied ? 'i-ph:check' : 'i-ph:copy-duotone'"
:disabled="!hasResult || copying"
:icon="copied === 'json' ? 'i-ph:check' : 'i-ph:copy-duotone'"
label="Copy result as JSON"
tooltip="Copy the result as JSON"
@click="copyResult"
@click="copyResult('json')"
/>
<ActionIconButton
class="text-sm"
:disabled="!hasResult || copying"
:icon="copied === 'markdown' ? 'i-ph:check' : 'i-ph:file-text-duotone'"
label="Copy result with context"
tooltip="Copy result with context"
@click="copyResult('markdown')"
/>
<span class="sr-only" role="status">{{ copied ? 'Copied to clipboard' : '' }}</span>
<ActionIconButton
class="text-sm"
:icon="running ? 'i-ph:arrows-clockwise animate-spin' : 'i-ph:arrows-clockwise'"
Expand All @@ -155,6 +192,14 @@ function copyResult(): void {
/>
</div>

<div v-if="hasResult && observationNotices.length" class="px-3 py-2 text-xs color-muted border-b border-base" role="status">
<p v-for="notice in observationNotices" :key="notice">
{{ notice }}
</p>
</div>
<OverlayModal v-model:open="manualCopyOpen" title="Copy manually" :description="copyError">
<FormTextarea v-model="manualCopyText" readonly :rows="14" class="font-mono text-xs" aria-label="Complete copy text" @focus="($event.target as HTMLTextAreaElement).select()" />
</OverlayModal>
<div
v-if="error"
class="mx-3 mt-2 px-3 py-2 font-mono text-xs whitespace-pre-wrap rounded-lg border border-red-600/40 bg-red-500:8 color-red-700 dark:(border-red-400/40 color-red-300)"
Expand All @@ -173,7 +218,7 @@ function copyResult(): void {
<div v-if="!hasResult" class="flex-1 grid place-items-center select-none">
<div class="flex flex-col items-center gap-2 color-faint">
<span class="i-ph:tree-structure-duotone text-3xl" />
<span class="text-sm">Results render here, start typing a query</span>
<span class="text-sm">Run a successful query to view and copy its result</span>
</div>
</div>
</div>
Expand Down
38 changes: 38 additions & 0 deletions plugins/data-inspector/app/composables/query-observation.ts
Original file line number Diff line number Diff line change
@@ -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<FilterOptions>
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')
}
43 changes: 42 additions & 1 deletion plugins/data-inspector/app/composables/workbench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -178,6 +179,22 @@ export function useWorkbench() {
const statsStale = ref(false)
const result = shallowRef<unknown>()
const hasResult = ref(false)
const observation = shallowRef<QueryObservation | null>(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<number | null>(null)

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -569,6 +608,8 @@ export function useWorkbench() {
statsStale,
result,
hasResult,
observation,
observationNotices,
lastRunAt,
suggestions,
skeleton,
Expand Down
56 changes: 56 additions & 0 deletions plugins/data-inspector/test/query-observation.test.ts
Original file line number Diff line number Diff line change
@@ -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))
})
})
Loading
Loading