Skip to content
Merged
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
## [0.6.0](https://github.com/holistics/anfra/compare/anfra-v0.5.0...anfra-v0.6.0) (2026-10-12)

### Features

* **appserve:** structure tab in the inspect panel ([e580f6e](https://github.com/holistics/anfra/commit/e580f6e83ab8912ffd19e0625ff6abc6f0fe799c))
* **sdk:** read a Data App's structure from its markup, show and locate it ([c2fd61c](https://github.com/holistics/anfra/commit/c2fd61cb08e898e42da21a6478f5cda9c1324ca2))

### Bug Fixes

* **appserve:** pick mode, the toolbar and copying a handle ([8745c5f](https://github.com/holistics/anfra/commit/8745c5fd3e25211d885d375ae4896b2835c06a36))
* **sdk,appserve:** plainer words in the structure's messages ([b056afd](https://github.com/holistics/anfra/commit/b056afde258dfe7b4a7058d4db263be249dd6acb))
* **sdk.app:** declaring an entity or a mapping notifies subscribers ([9e8ccd0](https://github.com/holistics/anfra/commit/9e8ccd074d4245ff65c4623c41e63c7fc1eda1b0))
* **sdk.app:** set the overlay label's parts apart ([333dbff](https://github.com/holistics/anfra/commit/333dbffc2247a75a5bb0b9a99ae8d32ed3baae4c))

### Build

* publish @holistics/anfra-sdk ([df8071d](https://github.com/holistics/anfra/commit/df8071de74815aeed41d32b5966fbd2b5b6b5198))
## [0.5.0](https://github.com/holistics/anfra/compare/anfra-v0.4.3...anfra-v0.5.0) (2026-10-08)

### ⚠ BREAKING CHANGES
Expand Down
4 changes: 3 additions & 1 deletion docs/designs/data-apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,12 @@ The frame is `sandbox="allow-scripts"` with its document as `srcdoc`, so it has
The bridge (`common/bridge.ts`, served by `host/bridge.ts`) is a small postMessage protocol:

- The frame asks: `anfra:request` with a method and an id, `anfra:cancel`, `anfra:ready`, and inspection snapshots.
- The host answers `anfra:response`, ok or with an error by its SDK class name, and tells the frame whether the inspect panel is open.
- The host answers `anfra:response`, ok or with an error by its SDK class name, tells the frame whether the inspect panel is open, and, while it is, asks the frame to **highlight** a node of the structure, **locate** one (scroll to it) or an entity wherever it is drawn, and to start or stop **pick mode** (`anfra:highlight`, `anfra:locate`, `anfra:pick-watch`). The frame answers none of them; in pick mode it posts `anfra:picked` with the node the reader chose.
- **Only two methods are served: `submitQuery` and `fieldSuggestions`**, the `Backend`'s own. Nothing else of the API is reachable from a frame, whatever the host's credentials.
- Messages are accepted only from that frame's window (an opaque origin can't be checked by name), and the frame posts only to the host's origin, from the provision data.

The host never sees the frame's DOM. What it knows of the page is the **structure**: the semantic parts a definition marks on its own elements (`data-anfra-container`, `data-anfra-block`, `data-anfra-query`, `data-anfra-control`, with an optional `data-anfra-label`), read by the app runtime into a tree of nodes and posted in the inspect snapshot beside the apps. A container holds anything; a block holds no container or block; a query or control node is an element drawn by that entity, under whatever marked element encloses it. Problems in the markup (an unknown name, a duplicate id, a block inside a block) are reported on the node, never thrown: the markup is optional, partial, and for inspection only. It is read only while a host inspects, on each snapshot and on a debounced mutation observer limited to elements coming and going and the five attributes, so a frame nobody inspects does no work. Highlighting, locating and picking all run in the frame, which draws one overlay (a tinted box and a label) over the marked element; the host needs no geometry. A definition can locate its own entities too (`query.locate()`), for a button of its own. The attributes are static, so a tool that never runs the definition can read the same structure from the file; an author copies a node's handle, `[River](data-anfra-block="river")`, to hand to an agent, which finds the element by that attribute.

**`@holistics/anfra-sdk/app` imports only `common`.** The frame script carries no API client, so a definition can't find one to misuse. `app/bundle.test.ts` builds the frame script as released and checks it. The frame script is built into `host` (a virtual module, `anfra-sdk:frame-script`, answered by a plugin in `web/sdk/tsup.config.ts`), so a host always carries the app runtime it was built with.

## What each query carries: the query and the reader's state
Expand Down
2 changes: 1 addition & 1 deletion manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# which triggers build_release.yml to download the pinned sidecars, cross-build,
# and publish. Always bump `version` when changing a pin — otherwise the tag
# already exists and no new release is cut.
version: 0.5.0
version: 0.6.0
sidecars:
anfra_node: anfra-node-v0.3.0 # holistics/holistics-core release tag (anfra_node_build_binaries.yml)
canal_query: query-v2.15.0 # holistics/canal release tag (build_and_release_binaries.yml)
52 changes: 49 additions & 3 deletions web/appserve/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import DataAppFrame from './DataAppFrame.vue';
import Icon from './Icon.vue';
import InspectPanel from './InspectPanel.vue';
import type { DatasetDescriptor, User } from '@holistics/anfra-sdk/common';
import type { InspectedApp } from './inspect';
import type { InspectedApp, InspectedStructure } from './inspect';
import {
filterEntries, findApp, folderPaths, pathFromLocation, urlFor, type CatalogEntry,
} from './catalog';
Expand Down Expand Up @@ -165,6 +165,36 @@ const problemsOpen = ref(false);
// The Inspect panel belongs to one Data App: it closes when another is picked, and survives Reload.
const inspectOpen = ref(false);
const inspected = ref<InspectedApp[]>();
const structure = ref<InspectedStructure>();
const selectedNode = ref<string>();
const picking = ref(false);
const frame = ref<InstanceType<typeof DataAppFrame>>();

function onInspect (apps: InspectedApp[], shape: InspectedStructure): void {
inspected.value = apps;
structure.value = shape;
}
// A pick chooses the node and ends pick mode; Escape in the frame only ends it. Either way the
// overlay the pick left on the page goes: it shows only while the reader hovers the tree.
function onPick (node: string | null): void {
picking.value = false;
if (node !== null) selectedNode.value = node;
frame.value?.highlight(null);
}
// Choosing a node in the tree is the other way to end a pick: the reader has found what they
// were pointing at.
function onSelect (node: string | undefined): void {
selectedNode.value = node;
picking.value = false;
}
function onHighlight (node: string | null): void {
frame.value?.highlight(node);
}
watch(inspectOpen, (open) => {
if (open) return;
picking.value = false;
selectedNode.value = undefined;
});
const WIDTH_KEY = 'anfra-appserve:inspect-width';
const MIN_WIDTH = 280;
function loadWidth (): number {
Expand Down Expand Up @@ -244,6 +274,9 @@ const title = computed(() => selected.value?.label ?? selectedPath.value?.split(
watch(() => (selected.value ? `${selected.value.path}#${revision.value}` : ''), (key) => {
loading.value = key !== '';
inspected.value = undefined;
structure.value = undefined;
selectedNode.value = undefined;
picking.value = false;
}, { immediate: true });
watch(selectedPath, () => {
inspectOpen.value = false;
Expand Down Expand Up @@ -470,13 +503,16 @@ onBeforeUnmount(() => {
<p v-if="selected && frameError" class="notice error" role="alert" data-testid="frame-error">{{ frameError }}</p>
<DataAppFrame
v-else-if="selected && datasets && reader"
ref="frame"
:key="`${selected.path}#${revision}`"
:path="selected.path"
:inspecting="inspectOpen"
:picking="picking"
:datasets="datasets"
:reader="reader"
@load="loading = false"
@inspect="inspected = $event"
@inspect="onInspect"
@pick="onPick"
@error="frameError = $event"
/>
<div v-else-if="selectedPath && loaded" class="empty-state" data-testid="not-found">
Expand All @@ -491,7 +527,17 @@ onBeforeUnmount(() => {
<div v-if="narrow" class="backdrop" @click="inspectOpen = false" />
<div class="inspect-dock" :style="{ width: `${inspectWidth}px` }">
<div class="resize-handle" role="separator" aria-orientation="vertical" @pointerdown.prevent="startResize" />
<InspectPanel :apps="inspected" @close="inspectOpen = false" />
<InspectPanel
:apps="inspected"
:structure="structure"
:selected-node="selectedNode"
:picking="picking"
@close="inspectOpen = false"
@highlight="onHighlight"
@locate="frame?.locate($event)"
@select="onSelect"
@pick="picking = $event"
/>
</div>
</template>
</div>
Expand Down
21 changes: 19 additions & 2 deletions web/appserve/src/DataAppFrame.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { coreApiBackend } from '@holistics/anfra-sdk/api';
import type { DatasetDescriptor, InspectedApp, User } from '@holistics/anfra-sdk/common';
import type { InspectedStructure, LocateTarget } from './inspect';
import { mountDataApp, type MountedDataApp } from '@holistics/anfra-sdk/host';
import { api, definition } from './server';

const props = defineProps<{
path: string,
inspecting: boolean,
picking: boolean,
datasets: Record<string, DatasetDescriptor>,
reader: User,
}>();
const emit = defineEmits<{ load: [], inspect: [apps: InspectedApp[]], error: [message: string] }>();
const emit = defineEmits<{
load: [],
inspect: [apps: InspectedApp[], structure: InspectedStructure],
pick: [node: string | null],
error: [message: string],
}>();

const container = ref<HTMLElement>();
let mounted: MountedDataApp | undefined;
Expand All @@ -29,21 +36,31 @@ onMounted(async () => {
datasets: props.datasets,
user: props.reader,
backend: coreApiBackend(api, { datasets: props.datasets }),
onInspect: (apps) => emit('inspect', apps),
onInspect: (apps, structure) => emit('inspect', apps, structure),
onPick: (node) => emit('pick', node),
title: 'Data App',
});
mounted.frame.classList.add('data-app');
mounted.frame.dataset.testid = 'data-app-frame';
mounted.frame.addEventListener('load', () => emit('load'));
mounted.setInspecting(props.inspecting);
if (props.picking) mounted.setPicking(true);
} catch (err) {
emit('error', (err as Error).message);
}
});

watch(() => props.inspecting, (open) => mounted?.setInspecting(open));
watch(() => props.picking, (on) => mounted?.setPicking(on));

onBeforeUnmount(() => mounted?.unmount());

// The inspect panel reaches the page through here: highlight a node, scroll to one, or to an
// entity wherever it is drawn. Nothing comes back but the next snapshot.
defineExpose({
highlight: (node: string | null) => mounted?.highlight(node),
locate: (target: LocateTarget) => mounted?.locate(target),
});
</script>

<template>
Expand Down
98 changes: 98 additions & 0 deletions web/appserve/src/EntityCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script setup lang="ts">
// One query's or control's card: its state and what it is drawn by. Shown in the Data tab for
// every entity, and in the Structure tab for a selected query or control node.
import { ref } from 'vue';
import {
entityAttribute, formatCondition, formatTime,
type EntityUsage, type InspectedControl, type InspectedQuery, type LocateTarget,
} from './inspect';

const props = defineProps<{
app: number,
name: string,
query?: InspectedQuery,
control?: InspectedControl,
usage?: EntityUsage,
}>();
const emit = defineEmits<{ locate: [target: LocateTarget] }>();

const kind = () => (props.query ? 'query' : 'control');
const copied = ref<string>();
async function copy (id: string, text: string): Promise<void> {
try {
await navigator.clipboard.writeText(text);
copied.value = id;
setTimeout(() => { if (copied.value === id) copied.value = undefined; }, 1500);
} catch { /* clipboard unavailable */ }
}
const controlKind = (control: InspectedControl) => (control.kind === 'dateDrill' ? 'Date drill' : 'Filter');
const drawn = () => {
if (!props.usage) return 'Not drawn yet';
const places = `${props.usage.markers} place${props.usage.markers === 1 ? '' : 's'}`;
return props.usage.blocks.length ? `In ${places}: ${props.usage.blocks.join(', ')}` : `In ${places}, outside any block`;
};
</script>

<template>
<article class="card" :data-testid="`inspect-${kind()}-${name}`">
<header>
<strong>{{ name }}</strong>
<template v-if="query">
<span class="pill" :class="query.state" data-testid="query-state">{{ query.state }}</span>
<span v-if="query.isDirty" class="pill warn">dirty</span>
</template>
<template v-else-if="control">
<span class="pill">{{ controlKind(control) }}</span>
<span v-if="control.isDirty" class="pill warn">unapplied</span>
</template>
<button
type="button"
class="locate"
:disabled="!usage"
:title="usage ? 'Scroll to it on the page' : `No element is marked ${entityAttribute(kind(), app, name)} yet`"
data-testid="locate"
@click="emit('locate', { app, kind: kind(), name })"
>
Locate
</button>
</header>
<dl>
<dt>Drawn</dt>
<dd :data-testid="`${kind()}-usage`">
{{ drawn() }}
<button type="button" class="copy" :title="entityAttribute(kind(), app, name)" @click="copy('handle', entityAttribute(kind(), app, name))">
{{ copied === 'handle' ? 'Copied' : 'Copy handle' }}
</button>
</dd>
<template v-if="query">
<dt>Rows</dt><dd>{{ query.rowCount }}<template v-if="query.selectedRowCount"> ({{ query.selectedRowCount }} selected)</template></dd>
<template v-if="query.debug">
<dt>Source</dt><dd>{{ query.debug.fromCache ? 'cache' : 'fresh run' }}<template v-if="query.debug.executedAt"> at {{ formatTime(query.debug.executedAt) }}</template></dd>
</template>
</template>
<template v-else-if="control">
<dt>Set to</dt><dd data-testid="control-condition">{{ formatCondition(control.condition) }}</dd>
<dt>Applied</dt><dd data-testid="control-applied">{{ formatCondition(control.appliedCondition) }}</dd>
<template v-if="control.options"><dt>Options</dt><dd>{{ control.options.join(', ') }}</dd></template>
</template>
</dl>
<template v-if="query">
<details class="signature">
<summary>Signature</summary>
<code class="wrap">{{ query.signature }}</code>
</details>
<p v-if="query.error" class="error-text" role="alert">{{ query.error.name }}: {{ query.error.message }}</p>
<template v-for="field in (['executedAql', 'executedSql'] as const)" :key="field">
<div v-if="query.debug?.[field]" class="code">
<div class="code-head">
<span>{{ field === 'executedAql' ? 'Executed AQL' : 'Executed SQL' }}</span>
<button type="button" class="copy" :data-testid="`copy-${field}`" @click="copy(field, query.debug![field]!)">
{{ copied === field ? 'Copied' : 'Copy' }}
</button>
</div>
<pre>{{ query.debug[field] }}</pre>
</div>
</template>
</template>
</article>
</template>
Loading
Loading