sova-devtools 0.1.10

In-app DevTools bar for Sova (HTML inject, SSE timeline, request snapshots)
Documentation
<script setup lang="ts">
import { computed, ref } from "vue";
import { Braces, Play } from "@lucide/vue";
import Chip from "../components/Chip.vue";
import CodeBlock from "../components/CodeBlock.vue";
import EmptyState from "../components/EmptyState.vue";
import Pane from "../components/Pane.vue";
import SearchField from "../components/SearchField.vue";
import { postAction } from "../api";
import { useDevToolsStore } from "../stores/devtools";
import { fmtMs } from "../types";

const store = useDevToolsStore();
const q = ref("");
const query = ref("query { counter }");
const variables = ref("");
const loading = ref(false);
const error = ref<string | null>(null);
const result = ref("");

const hasGraphql = computed(() => {
  const cfg = store.config as { features?: string[] } | null;
  return cfg?.features?.includes("console-graphql") ?? false;
});

const lines = computed(() => store.current?.graphql ?? []);
const filtered = computed(() => {
  const s = q.value.trim().toLowerCase();
  if (!s) return lines.value;
  return lines.value.filter(
    (g) =>
      g.operation.toLowerCase().includes(s) ||
      g.kind.toLowerCase().includes(s) ||
      String(g.errors).includes(s),
  );
});
const totalMs = computed(() =>
  lines.value.reduce((a, g) => a + (g.duration_ms ?? 0), 0),
);

async function run() {
  loading.value = true;
  error.value = null;
  let vars: unknown = undefined;
  if (variables.value.trim()) {
    try {
      vars = JSON.parse(variables.value);
    } catch {
      error.value = "Variables must be valid JSON";
      loading.value = false;
      return;
    }
  }
  try {
    const res = await postAction(store.api, "graphql", {
      query: query.value,
      variables: vars,
    });
    if (!res.ok) error.value = res.error ?? "failed";
    else result.value = JSON.stringify(res.result, null, 2);
  } catch (e) {
    error.value = e instanceof Error ? e.message : String(e);
  } finally {
    loading.value = false;
  }
}
</script>

<template>
  <div class="flex flex-col gap-3">
    <Pane v-if="hasGraphql" title="GraphQL Playground" :icon="Braces">
      <div class="flex flex-col gap-2">
        <textarea
          v-model="query"
          rows="4"
          class="w-full rounded border border-[var(--dt-border)] bg-[var(--dt-bg)] p-2 font-mono text-[11px]"
          placeholder="query { ... }"
        />
        <textarea
          v-model="variables"
          rows="2"
          class="w-full rounded border border-[var(--dt-border)] bg-[var(--dt-bg)] p-2 font-mono text-[10px]"
          placeholder='Variables JSON (optional)'
        />
        <button
          type="button"
          class="inline-flex items-center gap-1 self-start rounded bg-[var(--dt-accent)] px-3 py-1.5 text-[11px] font-semibold text-white disabled:opacity-50"
          :disabled="loading"
          @click="run"
        >
          <Play class="size-3.5" /> Run
        </button>
        <p v-if="error" class="text-[11px] text-[var(--dt-err)]">{{ error }}</p>
        <CodeBlock v-if="result" :code="result" title="response" language="json" />
      </div>
    </Pane>

    <EmptyState
      v-if="!store.current"
      title="No snapshot"
      hint="Select a request to see traced operations."
      :icon="Braces"
    />
    <EmptyState
      v-else-if="!lines.length"
      title="No GraphQL operations on this request"
      hint="POST /graphql queries appear in traces when the server plugin is installed."
      :icon="Braces"
    />
    <template v-else>
      <SearchField v-model="q" placeholder="Filter operations…" />
      <Pane
        title="Operations"
        :icon="Braces"
        :hint="`${filtered.length} · ${fmtMs(totalMs)}`"
      >
        <div class="flex flex-col gap-2">
          <div
            v-for="(g, i) in filtered"
            :key="i"
            class="rounded-[var(--dt-radius)] border border-[var(--dt-border)] bg-[var(--dt-bg)] p-2.5"
          >
            <div class="flex flex-wrap items-center gap-2">
              <span
                class="dt-mono rounded border border-[var(--dt-border)] px-1.5 text-[10px] uppercase text-[var(--dt-accent)]"
                >{{ g.kind }}</span
              >
              <span class="truncate text-[12px] font-medium text-[var(--dt-text)]">{{
                g.operation
              }}</span>
              <span class="dt-mono ml-auto text-[11px] text-[var(--dt-muted)]">{{
                fmtMs(g.duration_ms)
              }}</span>
            </div>
            <div class="mt-2 flex flex-wrap gap-2">
              <Chip
                v-if="g.errors > 0"
                label="errors"
                :value="String(g.errors)"
                tone="err"
              />
              <Chip
                v-if="g.auth != null"
                label="auth"
                :value="g.auth ? 'header' : 'none'"
                :tone="g.auth ? 'ok' : 'default'"
              />
            </div>
          </div>
        </div>
      </Pane>
    </template>
  </div>
</template>