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, watch } from "vue";
import {
  Check,
  Copy,
  Play,
  Radio,
  RotateCw,
  Search,
} 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 method = ref("hello.Greeter/SayHello");
const body = ref('{\n  "name": "devtools"\n}');
const loading = ref(false);
const error = ref<string | null>(null);
const result = ref("");
const durationMs = ref<number | null>(null);
const copied = ref(false);

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

const grpcMount = computed(() => {
  const mounts = (store.config as { mounts?: Record<string, unknown> } | null)
    ?.mounts;
  return mounts?.grpc as
    | { client_base?: string; methods?: string[]; bind?: string }
    | undefined;
});

const clientBase = computed(() => grpcMount.value?.client_base ?? "—");

const methodSuggestions = computed(() => {
  const out = new Set<string>();
  for (const m of grpcMount.value?.methods ?? []) out.add(m);
  for (const line of store.current?.grpc ?? []) out.add(line.method);
  if (method.value) out.add(method.value);
  return [...out];
});

const lines = computed(() => store.current?.grpc ?? []);
const filtered = computed(() => {
  const s = q.value.trim().toLowerCase();
  if (!s) return lines.value;
  return lines.value.filter(
    (g) =>
      g.method.toLowerCase().includes(s) ||
      g.base.toLowerCase().includes(s) ||
      g.direction.toLowerCase().includes(s) ||
      String(g.error ?? "").toLowerCase().includes(s),
  );
});
const totalMs = computed(() =>
  lines.value.reduce((a, g) => a + (g.duration_ms ?? 0), 0),
);

watch(
  () => grpcMount.value?.methods?.[0],
  (first) => {
    if (first && method.value === "hello.Greeter/SayHello") {
      method.value = first;
    }
  },
  { immediate: true },
);

function formatBody() {
  try {
    body.value = JSON.stringify(JSON.parse(body.value), null, 2);
    error.value = null;
  } catch {
    error.value = "Body must be valid JSON";
  }
}

async function run() {
  loading.value = true;
  error.value = null;
  result.value = "";
  durationMs.value = null;
  let payload: unknown = {};
  if (body.value.trim()) {
    try {
      payload = JSON.parse(body.value);
    } catch {
      error.value = "Body must be valid JSON";
      loading.value = false;
      return;
    }
  }
  try {
    const res = await postAction(store.api, "grpc", {
      method: method.value,
      body: payload,
    });
    durationMs.value = res.duration_ms ?? null;
    if (!res.ok) {
      error.value = res.error ?? "RPC 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;
  }
}

function prefillFromTrace(line: (typeof lines.value)[number]) {
  method.value = line.method;
  if (!hasConsole.value) return;
}

async function copyResult() {
  if (!result.value) return;
  try {
    await navigator.clipboard.writeText(result.value);
    copied.value = true;
    setTimeout(() => {
      copied.value = false;
    }, 1200);
  } catch {
    /* ignore */
  }
}
</script>

<template>
  <div class="flex h-full min-h-0 flex-col gap-3">
    <Pane
      v-if="hasConsole"
      title="gRPC Playground"
      :icon="Radio"
      :hint="clientBase"
    >
      <div
        class="grid min-h-[280px] grid-cols-1 gap-3 lg:grid-cols-2 lg:gap-0 lg:divide-x lg:divide-[var(--dt-border)]"
      >
        <div class="flex min-h-0 flex-col gap-2 lg:pr-3">
          <label class="text-[10px] font-medium uppercase tracking-wide text-[var(--dt-faint)]"
            >Method</label
          >
          <input
            v-model="method"
            list="dt-grpc-methods"
            type="text"
            placeholder="package.Service/Method"
            class="w-full rounded border border-[var(--dt-border)] bg-[var(--dt-bg)] px-3 py-2 font-mono text-[12px] text-[var(--dt-text)]"
          />
          <datalist id="dt-grpc-methods">
            <option v-for="m in methodSuggestions" :key="m" :value="m" />
          </datalist>
          <div class="flex items-center justify-between gap-2">
            <span class="text-[10px] text-[var(--dt-muted)]">JSON request body</span>
            <button
              type="button"
              class="inline-flex items-center gap-1 rounded border border-[var(--dt-border)] px-2 py-0.5 text-[10px] text-[var(--dt-muted)] hover:text-[var(--dt-text)]"
              @click="formatBody"
            >
              <RotateCw class="size-3" /> Format
            </button>
          </div>
          <textarea
            v-model="body"
            rows="8"
            spellcheck="false"
            class="min-h-[140px] flex-1 resize-y rounded border border-[var(--dt-border)] bg-[var(--dt-bg)] p-2 font-mono text-[11px] leading-relaxed"
          />
          <button
            type="button"
            class="inline-flex items-center gap-1.5 self-start rounded bg-[var(--dt-accent)] px-4 py-1.5 text-[11px] font-semibold text-white disabled:opacity-50"
            :disabled="loading"
            @click="run"
          >
            <Play class="size-3.5" />
            {{ loading ? "Calling…" : "Invoke" }}
          </button>
          <p v-if="error" class="text-[11px] text-[var(--dt-err)]">{{ error }}</p>
        </div>

        <div class="flex min-h-0 flex-col gap-2 lg:pl-3">
          <div class="flex flex-wrap items-center gap-2">
            <span class="text-[10px] font-medium uppercase tracking-wide text-[var(--dt-faint)]"
              >Response</span
            >
            <Chip
              v-if="durationMs != null"
              label="duration"
              :value="fmtMs(durationMs)"
              tone="info"
            />
            <button
              v-if="result"
              type="button"
              class="ml-auto inline-flex items-center gap-1 rounded border border-[var(--dt-border)] px-2 py-0.5 text-[10px] text-[var(--dt-muted)] hover:text-[var(--dt-text)]"
              @click="copyResult"
            >
              <Check v-if="copied" class="size-3 text-[var(--dt-ok)]" />
              <Copy v-else class="size-3" />
              {{ copied ? "Copied" : "Copy" }}
            </button>
          </div>
          <div
            v-if="!result && !loading"
            class="flex flex-1 items-center justify-center rounded border border-dashed border-[var(--dt-border)] bg-[var(--dt-bg)] p-6 text-center text-[11px] text-[var(--dt-muted)]"
          >
            Connect-JSON response appears here
          </div>
          <CodeBlock
            v-else-if="result"
            :code="result"
            title="result"
            language="json"
            class="min-h-0 flex-1"
          />
        </div>
      </div>
    </Pane>

    <EmptyState
      v-else-if="grpcMount"
      title="gRPC client installed"
      hint="Enable devtools-console-grpc to invoke RPC from DevTools."
      :icon="Radio"
    />

    <EmptyState
      v-if="!store.current"
      title="No snapshot"
      hint="Select a request to see traced RPC calls."
      :icon="Radio"
    />
    <EmptyState
      v-else-if="!lines.length"
      title="No gRPC calls on this request"
      hint="Outbound req.grpc().call() and mounted unary handlers emit sova.grpc traces."
      :icon="Radio"
    />
    <template v-else>
      <SearchField v-model="q" placeholder="Filter method / base / direction…" />
      <Pane
        title="RPC traces"
        :icon="Search"
        :hint="`${filtered.length} · ${fmtMs(totalMs)}`"
      >
        <div class="flex flex-col gap-2">
          <button
            v-for="(g, i) in filtered"
            :key="i"
            type="button"
            class="rounded-[var(--dt-radius)] border border-[var(--dt-border)] bg-[var(--dt-bg)] p-2.5 text-left transition-colors hover:border-[var(--dt-accent)]"
            @click="prefillFromTrace(g)"
          >
            <div class="flex flex-wrap items-center gap-2">
              <span
                class="dt-mono rounded border px-1.5 text-[10px] uppercase"
                :class="
                  g.direction === 'server'
                    ? 'border-[var(--dt-info)] text-[var(--dt-info)]'
                    : 'border-[var(--dt-accent)] text-[var(--dt-accent)]'
                "
                >{{ g.direction }}</span
              >
              <span
                class="truncate font-mono text-[12px] font-medium text-[var(--dt-text)]"
                >{{ g.method }}</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 label="base" :value="g.base" tone="default" />
              <Chip
                v-if="g.status != null"
                label="status"
                :value="String(g.status)"
                :tone="g.ok ? 'ok' : 'err'"
              />
              <Chip
                v-if="g.bytes_in != null"
                label="in"
                :value="`${g.bytes_in} B`"
                tone="info"
              />
              <Chip
                v-if="g.bytes_out != null"
                label="out"
                :value="`${g.bytes_out} B`"
                tone="info"
              />
              <Chip v-if="!g.ok" label="error" :value="g.error ?? 'failed'" tone="err" />
            </div>
          </button>
        </div>
      </Pane>
    </template>
  </div>
</template>