/** * The Index view: the pattern index as an operator reads it. What is * published and how it ranks, what this identity did with it, and what a query * would actually return — the last beside the request that produced it, because * the point of the playground is the search's behavior rather than its answer. * * Every read goes through the server, which signs it with the fabric identity * and refuses anything outside its allowlist. Source is not among what this * surface asks for: a pattern's metadata, schemas, dependencies and events are * what it shows, and source is read through the CLI. */ import type { PatternIndexEvent, PatternIndexListedPattern, PatternIndexPattern, PatternIndexSearchResult, } from "@commonfabric/pattern-index/client"; import { html, LitElement, nothing, type TemplateResult } from "lit"; import { eventBadges, filterEvents, formatIndexTime, matchRatio, patternsByScore, searchRequestOf, truncateId, } from "../index-inspector.ts"; import { listIndexEvents, listIndexPatterns, readIndexPattern, searchIndexPatterns, } from "./api.ts"; /** One pattern read open, and that pattern's own events beside it. */ interface OpenPattern { patternId: string; pattern?: PatternIndexPattern; events?: readonly PatternIndexEvent[]; error?: string; /** Why `events` is absent when its read failed rather than pending. */ eventsError?: string; } const reason = (error: unknown): string => error instanceof Error ? error.message : String(error); const prettyJson = (value: unknown): string => JSON.stringify(value, null, 2); export class ConsoleIndexView extends LitElement { static override properties = { patterns: { attribute: false }, eventTypes: { attribute: false }, patternsError: { attribute: false }, open: { attribute: false }, events: { attribute: false }, eventsError: { attribute: false }, eventFilter: { attribute: false }, copied: { attribute: false }, searchRequest: { attribute: false }, searchResults: { attribute: false }, searchError: { attribute: false }, searching: { attribute: false }, loaded: { attribute: false }, }; declare patterns: readonly PatternIndexListedPattern[]; /** The weight the index scores each event type at, as it reported them. */ declare eventTypes: Readonly>; declare patternsError: string | undefined; declare open: OpenPattern | undefined; declare events: readonly PatternIndexEvent[]; declare eventsError: string | undefined; declare eventFilter: string; /** The identifier the last copy button copied, so the page can say so. */ declare copied: string | undefined; declare searchRequest: string | undefined; declare searchResults: readonly PatternIndexSearchResult[] | undefined; declare searchError: string | undefined; declare searching: boolean; /** False until the first read of the index answers, however it answered. */ declare loaded: boolean; /** Which read of a pattern's detail is current; only the newest may show. */ #detailReads = 0; #refreshes = 0; /** Which search is current, for the same reason. */ #searches = 0; constructor() { super(); this.patterns = []; this.eventTypes = {}; this.events = []; this.eventFilter = ""; this.searching = false; this.loaded = false; } protected override createRenderRoot(): HTMLElement { return this; } override connectedCallback(): void { super.connectedCallback(); void this.refresh(); } /** * Re-reads both listings. Neither read's failure hides the other's answer, * and a refresh superseded by a newer one writes nothing — two clicks in * flight must not let the older answer land last. */ async refresh(): Promise { const generation = ++this.#refreshes; await Promise.all([ this.#loadPatterns(generation), this.#loadEvents(generation), ]); if (generation === this.#refreshes) { this.loaded = true; } } async #loadPatterns(generation: number): Promise { try { const listing = await listIndexPatterns(); if (generation !== this.#refreshes) return; this.patterns = listing.patterns; this.eventTypes = listing.eventTypes ?? {}; this.patternsError = undefined; } catch (error) { if (generation !== this.#refreshes) return; this.patternsError = reason(error); } } async #loadEvents(generation: number): Promise { try { const events = await listIndexEvents({ limit: 100 }); if (generation !== this.#refreshes) return; this.events = events; this.eventsError = undefined; } catch (error) { if (generation !== this.#refreshes) return; this.eventsError = reason(error); } } /** * Opens one pattern, or closes it when it is the one already open. The * pattern and its events are read independently, so one endpoint refusing * still shows what the other returned. */ async #openPattern(patternId: string): Promise { if (this.open?.patternId === patternId) { // Closing also invalidates a read still in flight, so its late answer // cannot reopen the row. ++this.#detailReads; this.open = undefined; return; } const read = ++this.#detailReads; this.open = { patternId }; const [pattern, events] = await Promise.all([ readIndexPattern(patternId).catch((error: unknown) => reason(error)), listIndexEvents({ patternId }).catch((error: unknown) => reason(error)), ]); if (read !== this.#detailReads) { return; } this.open = { patternId, ...(typeof pattern === "string" ? { error: pattern } : { pattern }), ...(typeof events === "string" ? { eventsError: events } : { events }), }; } async #runSearch(): Promise { // The disabled binding lands on the next render, not on the click. if (this.searching) return; const field = (id: string): string => (this.querySelector(`#${id}`) as HTMLInputElement | null)?.value ?? ""; const request = searchRequestOf( field("index-tags"), field("index-text"), field("index-limit"), ); const run = ++this.#searches; this.searchRequest = prettyJson(request); this.searching = true; try { const response = await searchIndexPatterns(request); if (run !== this.#searches) { return; } this.searchResults = response.results; this.searchError = undefined; } catch (error) { if (run !== this.#searches) { return; } this.searchError = reason(error); this.searchResults = undefined; } finally { if (run === this.#searches) { this.searching = false; } } } /** * A truncated identifier that hands over the whole of itself. The column is * narrow enough to read a table by and a pattern id is what every other tool * wants in full, so the cell is the button. */ #idCell(value: string | undefined, length = 10): TemplateResult { if (value === undefined || value === "") { return html`—`; } return html` `; } #hashtags(hashtags: readonly string[] | undefined): TemplateResult { return html`${ (hashtags ?? []).map((tag) => html`#${tag}` ) }`; } #eventsTable(events: readonly PatternIndexEvent[]): TemplateResult { return html` ${events.map((event) => html` ` )}
when event pattern author note
${formatIndexTime(event.ts)} ${event.eventType} ${this.#idCell(event.patternId)} ${this.#idCell(event.did, 14)} ${event.note ?? ""}
`; } #detail(open: OpenPattern): TemplateResult { const pattern = open.pattern; return html`
${open.error === undefined ? nothing : html`

${open.error}

`} ${pattern === undefined ? (open.error === undefined ? html`

Reading…

` : nothing) : html`
owner ${this.#idCell(pattern.ownerDid, 18)} · created ${formatIndexTime(pattern.createdAt)} ${pattern.priorPatternId === undefined ? nothing : html`· revises ${this.#idCell(pattern.priorPatternId)}`}
dependencies ${pattern.dependencies.length === 0 ? "none" : pattern.dependencies.join(", ")}
${pattern.argumentSchema === undefined ? nothing : html`
argument schema
${prettyJson(pattern.argumentSchema)}
`} ${pattern.resultSchema === undefined ? nothing : html`
result schema
${prettyJson(pattern.resultSchema)}
`} `}
its events
${open.eventsError !== undefined ? html`

${open.eventsError}

` : open.events === undefined ? html`

Reading…

` : open.events.length === 0 ? html`

You recorded none against this pattern.

` : this.#eventsTable(open.events)}
`; } #patternsPane(): TemplateResult { if (this.patternsError !== undefined) { return html`

${this.patternsError}

`; } if (this.patterns.length === 0) { return html`

${this.loaded ? "The index holds no patterns." : "Reading…"}

`; } return html` ${patternsByScore(this.patterns).map((pattern) => html` this.#openPattern(pattern.patternId)} > ${this.open?.patternId === pattern.patternId ? html` ` : nothing} ` )}
pattern description hashtags events score created
${this.#idCell(pattern.patternId)} ${pattern.description} ${this.#hashtags(pattern.hashtags)} ${eventBadges(pattern.events, pattern.eventAuthors).map(( badge, ) => html` ${badge.eventType} ×${badge.count} · ${ badge.did === undefined ? "author unavailable" : this.#idCell(badge.did, 18) } ` )} ${pattern.score ?? 0} ${pattern.signals?.inherited ? html`
Includes ${pattern.signals.inherited.score} inherited from ${this.#idCell(pattern.signals.inherited.priorPatternId)} as of ${formatIndexTime(pattern.signals.inherited.asOf)}
` : nothing}
${formatIndexTime(pattern.createdAt)}
${this.#detail(this.open)}
`; } #eventsPane(): TemplateResult { if (this.eventsError !== undefined) { return html`

${this.eventsError}

`; } const showing = filterEvents(this.events, this.eventFilter); return html`
${showing.length} of ${this.events.length} shown
${this.events.length === 0 ? html`

${this.loaded ? "This identity has recorded nothing in the index." : "Reading…"}

` : this.#eventsTable(showing)} `; } #searchPane(): TemplateResult { return html`
`; } protected override render(): TemplateResult { const weights = Object.entries(this.eventTypes) .map(([eventType, weight]) => `${eventType}=${weight}`) .join(" "); return html`
Patterns ${weights === "" ? nothing : html`${weights}`}
${this.#patternsPane()}
Your events
${this.#eventsPane()}
Search
${this.#searchPane()}
`; } } customElements.define("console-index-view", ConsoleIndexView);