feat(rngit_tool): add RNGit Explorer page and navigation integration

This commit is contained in:
Ivan
2026-04-25 16:29:37 -05:00
parent 1e62ab10b4
commit d2eb616ed3
6 changed files with 526 additions and 2 deletions
@@ -209,6 +209,14 @@ export default {
type: "navigation",
route: { name: "rncp" },
},
{
id: "nav-rngit-explorer",
title: "nav_rngit_explorer",
description: "nav_rngit_explorer_desc",
icon: "source-branch",
type: "navigation",
route: { name: "rngit-explorer" },
},
{
id: "nav-rnstatus",
title: "nav_rnstatus",
@@ -0,0 +1,383 @@
<!-- SPDX-License-Identifier: 0BSD -->
<template>
<div class="flex flex-col flex-1 h-full min-w-0 overflow-hidden bg-slate-50 dark:bg-zinc-950">
<div class="bg-slate-50 dark:bg-zinc-950 border-b border-gray-200 dark:border-zinc-800 shadow-sm z-10">
<div class="px-4 py-3 md:px-6 md:py-4 flex flex-wrap items-center justify-between gap-3 min-w-0">
<div class="flex items-center gap-3 min-w-0">
<div class="p-2 bg-teal-100 dark:bg-teal-900/30 rounded-xl shrink-0">
<MaterialDesignIcon
icon-name="source-branch"
class="size-5 md:size-6 text-teal-600 dark:text-teal-300"
/>
</div>
<div class="min-w-0">
<h1 class="text-lg md:text-xl font-bold text-gray-900 dark:text-white truncate">
{{ $t("tools.rngit_explorer.title") }}
</h1>
<p class="text-[10px] md:text-xs text-gray-500 dark:text-gray-400 truncate">
{{ $t("tools.rngit_explorer.description") }}
</p>
</div>
</div>
<RouterLink
to="/tools"
class="inline-flex items-center gap-2 text-sm text-teal-600 dark:text-teal-300 hover:underline shrink-0"
>
<MaterialDesignIcon icon-name="arrow-left" class="size-4" />
{{ $t("rngit_explorer.back_tools") }}
</RouterLink>
</div>
</div>
<div class="flex-1 overflow-y-auto min-w-0">
<div class="p-3 sm:p-4 md:p-6 max-w-5xl mx-auto space-y-4 pb-[max(1rem,env(safe-area-inset-bottom))]">
<div
class="rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-2"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("rngit_explorer.heard_heading") }}
</h2>
<button
type="button"
class="text-xs text-teal-600 dark:text-teal-400 hover:underline"
@click="loadHeardAnnounces"
>
{{ $t("rngit_explorer.refresh_heard") }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-zinc-500">
{{ $t("rngit_explorer.heard_hint") }}
</p>
<div v-if="heardLoading" class="text-xs text-gray-500 py-2">{{ $t("common.loading") }}</div>
<ul
v-else-if="heardAnnounces.length"
class="max-h-48 overflow-y-auto divide-y divide-gray-100 dark:divide-zinc-800 rounded-lg border border-gray-100 dark:border-zinc-800"
>
<li
v-for="h in heardAnnounces"
:key="h.destination_hash"
class="px-3 py-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-900/80 text-sm"
:class="pickedHeardHash === h.destination_hash ? 'bg-teal-50/80 dark:bg-teal-950/30' : ''"
@click="applyHeardAnnounce(h)"
>
<div class="font-medium text-gray-900 dark:text-white truncate">
{{ heardNodeTitle(h) }}
</div>
<div class="font-mono text-[11px] text-gray-500 dark:text-zinc-400 truncate">
{{ h.destination_hash }}
</div>
<div class="text-[10px] text-gray-400 dark:text-zinc-500">
{{ $t("rngit_explorer.hops_label", { n: h.hops != null ? h.hops : "—" }) }}
</div>
</li>
</ul>
<p v-else class="text-xs text-gray-500 dark:text-zinc-500 py-1">
{{ $t("rngit_explorer.heard_empty") }}
</p>
</div>
<div
class="rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
>
<p class="text-xs text-gray-600 dark:text-zinc-400 leading-relaxed">
{{ $t("rngit_explorer.intro") }}
</p>
<div class="grid sm:grid-cols-2 gap-3">
<div>
<label class="glass-label">{{ $t("rngit_explorer.destination_hash") }}</label>
<input
v-model="destinationHash"
type="text"
autocomplete="off"
class="w-full rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-2 text-sm font-mono text-gray-900 dark:text-white"
:disabled="busy"
/>
</div>
<div>
<label class="glass-label">{{ $t("rngit_explorer.group_name") }}</label>
<input
v-model="groupName"
type="text"
autocomplete="off"
class="w-full rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-2 text-sm font-mono text-gray-900 dark:text-white"
:disabled="busy"
/>
</div>
</div>
<div>
<label class="glass-label">{{ $t("rngit_explorer.repo_names_label") }}</label>
<textarea
v-model="repoNamesText"
rows="6"
class="w-full rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-2 text-sm font-mono text-gray-900 dark:text-white"
:disabled="busy"
:placeholder="$t('rngit_explorer.repo_names_placeholder')"
/>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
<label
class="inline-flex items-start gap-2 text-xs text-gray-600 dark:text-zinc-400 max-w-prose"
>
<input
v-model="forPush"
type="checkbox"
class="rounded border-gray-300 shrink-0 mt-0.5"
:disabled="busy"
/>
<span>{{ $t("rngit_explorer.for_push") }}</span>
</label>
<button
type="button"
class="inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-teal-600 hover:bg-teal-500 text-white text-sm font-medium disabled:opacity-50 shrink-0 self-start sm:self-auto"
:disabled="busy || !canProbe"
@click="runProbe"
>
<MaterialDesignIcon v-if="!busy" icon-name="radar" class="size-5" />
<MaterialDesignIcon v-else icon-name="loading" class="size-5 animate-spin" />
{{ $t("rngit_explorer.probe") }}
</button>
</div>
</div>
<div
v-if="results.length"
class="rounded-lg border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div
class="px-3 py-2 border-b border-gray-100 dark:border-zinc-800 text-xs font-semibold text-gray-600 dark:text-zinc-400"
>
{{ $t("rngit_explorer.results") }}
</div>
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
<li
v-for="row in results"
:key="row.repository"
class="px-3 py-2.5 cursor-pointer transition-colors text-sm"
:class="
selected && selected.repository === row.repository
? 'bg-teal-50 dark:bg-teal-950/40'
: 'hover:bg-gray-50 dark:hover:bg-zinc-900/80'
"
@click="selectRow(row)"
>
<div class="flex items-center justify-between gap-2 min-w-0">
<span class="font-mono font-medium text-gray-900 dark:text-white truncate">{{
row.repository
}}</span>
<span
class="shrink-0 text-xs font-semibold uppercase"
:class="
row.reachable
? 'text-emerald-600 dark:text-emerald-400'
: 'text-gray-400 dark:text-zinc-500'
"
>
{{
row.reachable
? $t("rngit_explorer.reachable")
: $t("rngit_explorer.unreachable")
}}
</span>
</div>
<div
v-if="!row.reachable && row.error"
class="text-xs text-red-600 dark:text-red-400 mt-1 font-mono truncate"
>
{{ row.error }}
</div>
</li>
</ul>
</div>
<div
v-if="selected"
class="rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
>
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("rngit_explorer.detail_title", { repo: selected.repository }) }}
</div>
<div v-if="selected.reachable && selected.clone_command" class="space-y-2">
<div class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("rngit_explorer.clone_command") }}
</div>
<div class="flex flex-wrap gap-2 items-start">
<pre
class="flex-1 min-w-0 p-2 rounded bg-gray-100 dark:bg-zinc-900 text-xs font-mono text-gray-800 dark:text-zinc-200 overflow-x-auto whitespace-pre-wrap break-all"
>{{ selected.clone_command }}</pre
>
<button
type="button"
class="shrink-0 px-3 py-2 rounded-lg border border-gray-300 dark:border-zinc-600 text-sm text-gray-800 dark:text-zinc-200 hover:bg-gray-50 dark:hover:bg-zinc-800"
@click.stop="copyClone"
>
{{ $t("rngit_explorer.copy") }}
</button>
</div>
</div>
<div v-else class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("rngit_explorer.no_clone_hint") }}
</div>
<div v-if="selected.reachable && selected.refs_preview" class="space-y-1">
<div class="text-xs text-gray-500 dark:text-zinc-400">
{{ $t("rngit_explorer.refs_preview") }}
<span v-if="selected.refs_truncated" class="text-amber-600 dark:text-amber-400">{{
$t("rngit_explorer.truncated")
}}</span>
</div>
<pre
class="p-2 rounded bg-gray-100 dark:bg-zinc-900 text-xs font-mono text-gray-800 dark:text-zinc-200 max-h-48 overflow-y-auto whitespace-pre-wrap break-all"
>{{ selected.refs_preview }}</pre
>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import WebSocketConnection from "../../js/WebSocketConnection";
const RNGIT_ASPECT = "git.repositories";
export default {
name: "RNGitExplorerPage",
components: { MaterialDesignIcon },
data() {
return {
heardAnnounces: [],
heardLoading: false,
pickedHeardHash: "",
destinationHash: "",
groupName: "",
repoNamesText: "",
forPush: false,
busy: false,
results: [],
selected: null,
};
},
computed: {
normalizedDestinationHash() {
return (this.destinationHash || "").trim().toLowerCase().replace(/:/g, "");
},
canProbe() {
const h = this.normalizedDestinationHash;
return (
h.length === 32 &&
/^[0-9a-f]+$/.test(h) &&
(this.groupName || "").trim().length > 0 &&
(this.repoNamesText || "").trim().length > 0
);
},
},
mounted() {
WebSocketConnection.on("message", this.onSocketMessage);
this.loadHeardAnnounces();
},
beforeUnmount() {
WebSocketConnection.off("message", this.onSocketMessage);
},
methods: {
onSocketMessage(event) {
let json;
try {
json = JSON.parse(event.data);
} catch {
return;
}
if (json.type !== "announce" || !json.announce || json.announce.aspect !== RNGIT_ASPECT) {
return;
}
const a = json.announce;
const i = this.heardAnnounces.findIndex((x) => x.destination_hash === a.destination_hash);
if (i >= 0) {
this.heardAnnounces.splice(i, 1, a);
} else {
this.heardAnnounces.unshift(a);
}
},
async loadHeardAnnounces() {
this.heardLoading = true;
try {
const { data } = await window.api.get("/api/v1/announces", {
params: { aspect: RNGIT_ASPECT, limit: 200 },
});
this.heardAnnounces = Array.isArray(data.announces) ? data.announces : [];
} catch (e) {
ToastUtils.error(e.response?.data?.message || this.$t("rngit_explorer.heard_load_failed"));
} finally {
this.heardLoading = false;
}
},
heardNodeTitle(h) {
const custom = (h.custom_display_name || "").trim();
if (custom) {
return custom;
}
const d = (h.display_name || "").trim();
if (d && d !== "Anonymous Peer") {
return d;
}
const hex = (h.destination_hash || "").trim().toLowerCase();
if (hex.length >= 8) {
return this.$t("rngit_explorer.heard_title_short", { prefix: hex.slice(0, 8) });
}
return this.$t("rngit_explorer.unnamed_node");
},
applyHeardAnnounce(h) {
this.pickedHeardHash = h.destination_hash || "";
this.destinationHash = h.destination_hash || "";
},
selectRow(row) {
this.selected = { ...row };
},
async copyClone() {
if (!this.selected?.clone_command) {
return;
}
try {
await navigator.clipboard.writeText(this.selected.clone_command);
ToastUtils.success(this.$t("rngit_explorer.copied"));
} catch {
ToastUtils.error(this.$t("rngit_explorer.copy_failed"));
}
},
async runProbe() {
if (!this.canProbe) {
return;
}
this.busy = true;
this.results = [];
this.selected = null;
ToastUtils.loading(this.$t("rngit_explorer.probing"), 0, "rngit-explorer-probe");
try {
const { data } = await window.api.post("/api/v1/rngit-tool/probe", {
destination_hash: this.normalizedDestinationHash,
group_name: (this.groupName || "").trim(),
repository_names_text: this.repoNamesText,
for_push: this.forPush,
});
if (data.ok && Array.isArray(data.results)) {
this.results = data.results;
const firstOk = data.results.find((r) => r.reachable);
this.selected = firstOk ? { ...firstOk } : data.results[0] ? { ...data.results[0] } : null;
ToastUtils.success(this.$t("rngit_explorer.probe_done"));
} else {
ToastUtils.error(data.error || this.$t("rngit_explorer.probe_failed"));
}
} catch (e) {
const d = e.response?.data;
ToastUtils.error(d?.error || d?.message || this.$t("rngit_explorer.probe_failed"));
} finally {
ToastUtils.dismiss("rngit-explorer-probe");
this.busy = false;
}
},
},
};
</script>
@@ -150,6 +150,14 @@ export default {
titleKey: "tools.rncp.title",
descriptionKey: "tools.rncp.description",
},
{
name: "rngit-explorer",
route: { name: "rngit-explorer" },
icon: "source-branch",
iconBg: "tool-card__icon bg-teal-50 text-teal-600 dark:bg-teal-900/30 dark:text-teal-200",
titleKey: "tools.rngit_explorer.title",
descriptionKey: "tools.rngit_explorer.description",
},
{
name: "rnstatus",
route: { name: "rnstatus" },
+5
View File
@@ -144,6 +144,11 @@ const router = createRouter({
path: "/rncp",
component: defineAsyncComponent(() => import("./components/rncp/RNCPPage.vue")),
},
{
name: "rngit-explorer",
path: "/tools/rngit-explorer",
component: defineAsyncComponent(() => import("./components/tools/RNGitExplorerPage.vue")),
},
{
name: "rnstatus",
path: "/rnstatus",
+119
View File
@@ -0,0 +1,119 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createRouter, createWebHistory } from "vue-router";
import RNGitExplorerPage from "@/components/tools/RNGitExplorerPage.vue";
import ToastUtils from "@/js/ToastUtils";
vi.mock("@/js/ToastUtils", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
loading: vi.fn(),
dismiss: vi.fn(),
},
}));
describe("RNGitExplorerPage.vue", () => {
let axiosMock;
const router = createRouter({
history: createWebHistory(),
routes: [{ path: "/tools", name: "tools", component: { template: "div" } }],
});
beforeEach(() => {
axiosMock = {
post: vi.fn(),
get: vi.fn().mockResolvedValue({ data: { announces: [] } }),
};
window.api = axiosMock;
});
afterEach(() => {
delete window.api;
vi.clearAllMocks();
});
const mountPage = () =>
mount(RNGitExplorerPage, {
global: {
plugins: [router],
mocks: { $t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key) },
stubs: {
MaterialDesignIcon: { template: "<span />", props: ["iconName"] },
RouterLink: { template: "<a><slot /></a>", props: ["to"] },
},
},
});
it("posts probe with textarea names", async () => {
axiosMock.post.mockResolvedValue({
data: {
ok: true,
results: [
{
repository: "MeshChatX",
reachable: true,
refs_preview: "abc\tHEAD",
refs_truncated: false,
clone_command: "git clone rns://h/quad4/MeshChatX",
error: null,
},
],
},
});
const wrapper = mountPage();
await wrapper.setData({
destinationHash: "a".repeat(32),
groupName: "quad4",
repoNamesText: "MeshChatX",
});
await wrapper.vm.runProbe();
expect(axiosMock.post).toHaveBeenCalledWith(
"/api/v1/rngit-tool/probe",
expect.objectContaining({
destination_hash: "a".repeat(32),
group_name: "quad4",
repository_names_text: "MeshChatX",
for_push: false,
})
);
expect(wrapper.vm.results.length).toBe(1);
expect(ToastUtils.success).toHaveBeenCalled();
});
it("heardNodeTitle prefers custom_display_name then display_name", () => {
const wrapper = mountPage();
expect(
wrapper.vm.heardNodeTitle({
custom_display_name: " Mine ",
display_name: "Other",
destination_hash: "a".repeat(32),
})
).toBe("Mine");
expect(
wrapper.vm.heardNodeTitle({
custom_display_name: "",
display_name: " Bob ",
destination_hash: "b".repeat(32),
})
).toBe("Bob");
});
it("heardNodeTitle uses short hash when display is Anonymous Peer", () => {
const wrapper = mountPage();
expect(
wrapper.vm.heardNodeTitle({
display_name: "Anonymous Peer",
destination_hash: "926baefe13daf5178c174f158dae1b45",
})
).toBe('rngit_explorer.heard_title_short:{"prefix":"926baefe"}');
});
it("separates for-push checkbox row from probe button", () => {
const wrapper = mountPage();
const row = wrapper.find(".flex.flex-col.gap-3.sm\\:flex-row");
expect(row.exists()).toBe(true);
expect(row.find('input[type="checkbox"]').exists()).toBe(true);
expect(row.find("button").exists()).toBe(true);
});
});
+3 -2
View File
@@ -10,6 +10,7 @@ describe("ToolsPage.vue", () => {
{ path: "/ping", name: "ping", component: { template: "div" } },
{ path: "/rnprobe", name: "rnprobe", component: { template: "div" } },
{ path: "/rncp", name: "rncp", component: { template: "div" } },
{ path: "/tools/rngit-explorer", name: "rngit-explorer", component: { template: "div" } },
{ path: "/rnstatus", name: "rnstatus", component: { template: "div" } },
{ path: "/rnpath", name: "rnpath", component: { template: "div" } },
{ path: "/rnpath-trace", name: "rnpath-trace", component: { template: "div" } },
@@ -55,7 +56,7 @@ describe("ToolsPage.vue", () => {
it("renders all tool rows", () => {
const wrapper = mountToolsPage();
const toolRows = wrapper.findAll(".tool-row");
expect(toolRows.length).toBe(21);
expect(toolRows.length).toBe(22);
});
it("filters tools based on search query", async () => {
@@ -80,6 +81,6 @@ describe("ToolsPage.vue", () => {
await clearButton.trigger("click");
expect(wrapper.vm.searchQuery).toBe("");
expect(wrapper.vm.filteredTools.length).toBe(21);
expect(wrapper.vm.filteredTools.length).toBe(22);
});
});