P4: messages, topics, pubkey
Backend
- api/messages.rs covers send/pop/reply/status with an externally
tagged MessageDestination enum that matches the daemon's
{ip|pk: ...} body shape; pop_message uses an inflated request
timeout to outlast the long-poll window
- api/topics.rs implements default action, topic CRUD, sources
whitelist, and forward-socket get/set/remove. POST /topics ships
the raw base64 string as the body (not JSON); path segments are
percent-encoded inline (topics contain '/' and '+')
- api/pubkey.rs resolves an overlay IPv6 to a hex public key
- poller spawns a third long-poll loop on /messages?peek=false
that fans every inbound message into a 200-deep ring buffer and
emits messages://incoming for the UI
Frontend
- messages store: live inbox via the event, persisted outbox via
tauri-plugin-store keyed under outbox.json
- ComposeMessage form: ip/pk toggle, optional UTF-8 topic and
payload that get base64-encoded with a TextEncoder-based helper
- MessageList renders printable payloads decoded; binary payloads
fall back to a "(N bytes binary)" hint
- Topics view: split layout with whitelist on the left, per-topic
sources/forward editor on the right; default-action toggle is
surfaced at the top
This commit is contained in:
115
src/components/ComposeMessage.vue
Normal file
115
src/components/ComposeMessage.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { Send, Loader2 } from "lucide-vue-next";
|
||||
import { useMessagesStore } from "@/stores/messages";
|
||||
import { utf8ToBase64 } from "@/lib/utils";
|
||||
import type { MessageDestination } from "@/lib/api";
|
||||
|
||||
const messages = useMessagesStore();
|
||||
|
||||
const destKind = ref<"ip" | "pk">("ip");
|
||||
const destValue = ref("");
|
||||
const topic = ref("");
|
||||
const payload = ref("");
|
||||
const submitting = ref(false);
|
||||
const result = ref<{ kind: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const canSubmit = computed(
|
||||
() =>
|
||||
destValue.value.trim().length > 0 &&
|
||||
payload.value.length > 0 &&
|
||||
!submitting.value,
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
result.value = null;
|
||||
if (!canSubmit.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const dest: MessageDestination =
|
||||
destKind.value === "ip"
|
||||
? { ip: destValue.value.trim() }
|
||||
: { pk: destValue.value.trim() };
|
||||
const topicB64 = topic.value ? utf8ToBase64(topic.value) : "";
|
||||
const payloadB64 = utf8ToBase64(payload.value);
|
||||
const receipt = await messages.send(dest, topicB64, payloadB64);
|
||||
result.value = { kind: "ok", text: `Sent — id ${receipt.id}` };
|
||||
payload.value = "";
|
||||
} catch (e) {
|
||||
result.value = { kind: "err", text: String(e) };
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-3 rounded-lg border border-border bg-card p-4" @submit.prevent="submit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Destination
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
v-model="destKind"
|
||||
class="rounded-md border border-input bg-background px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="ip">IP</option>
|
||||
<option value="pk">Public key</option>
|
||||
</select>
|
||||
<input
|
||||
v-model="destValue"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
class="flex-1 rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
:placeholder="destKind === 'ip' ? '503:5478:df06:d79a::1' : 'hex public key'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Topic (optional, plain text — encoded as base64)
|
||||
</label>
|
||||
<input
|
||||
v-model="topic"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
class="rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="my-topic"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Payload (UTF-8 — encoded as base64)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="payload"
|
||||
rows="4"
|
||||
class="resize-y rounded-md border border-input bg-background px-3 py-1.5 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
placeholder="Hello, mycelium!"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<p
|
||||
v-if="result"
|
||||
class="text-xs"
|
||||
:class="result.kind === 'ok' ? 'text-emerald-500' : 'text-destructive'"
|
||||
>
|
||||
{{ result.text }}
|
||||
</p>
|
||||
<span v-else />
|
||||
<button
|
||||
type="submit"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
||||
:disabled="!canSubmit"
|
||||
>
|
||||
<Loader2 v-if="submitting" class="h-3.5 w-3.5 animate-spin" />
|
||||
<Send v-else class="h-3.5 w-3.5" />
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
67
src/components/MessageList.vue
Normal file
67
src/components/MessageList.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { Inbox, Trash2 } from "lucide-vue-next";
|
||||
import { base64ToUtf8, isPrintableUtf8 } from "@/lib/utils";
|
||||
import type { IncomingMessage } from "@/lib/api";
|
||||
|
||||
defineProps<{
|
||||
messages: IncomingMessage[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "clear"): void;
|
||||
}>();
|
||||
|
||||
function topicLabel(t: string): string {
|
||||
return t ? (isPrintableUtf8(t) ? base64ToUtf8(t) : `b64:${t}`) : "—";
|
||||
}
|
||||
|
||||
function payloadLabel(p: string): string {
|
||||
return isPrintableUtf8(p) ? base64ToUtf8(p) : `(${atob(p).length} bytes binary)`;
|
||||
}
|
||||
|
||||
function shortIp(addr: string): string {
|
||||
if (addr.length <= 28) return addr;
|
||||
return `${addr.slice(0, 12)}…${addr.slice(-10)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-border bg-card">
|
||||
<header class="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Inbox class="h-4 w-4 text-muted-foreground" />
|
||||
<h3 class="text-sm font-medium">Inbox</h3>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ messages.length }} message{{ messages.length === 1 ? "" : "s" }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
v-if="messages.length"
|
||||
class="rounded p-1 text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
title="Clear inbox"
|
||||
@click="$emit('clear')"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="max-h-[55vh] divide-y divide-border overflow-y-auto">
|
||||
<p v-if="!messages.length" class="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||
No messages yet.
|
||||
</p>
|
||||
<article v-for="m in messages" :key="m.id" class="px-4 py-3 hover:bg-muted/30">
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
from
|
||||
<span class="font-mono">{{ shortIp(m.srcIp) }}</span>
|
||||
<span class="mx-1">·</span>
|
||||
topic <span class="font-mono">{{ topicLabel(m.topic) }}</span>
|
||||
</div>
|
||||
<code class="text-[10px] text-muted-foreground">{{ m.id }}</code>
|
||||
</div>
|
||||
<div class="mt-1 whitespace-pre-wrap break-all font-mono text-sm">
|
||||
{{ payloadLabel(m.payload) }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,6 +74,34 @@ export interface RoutesSnapshot {
|
||||
queried: QueriedSubnet[];
|
||||
}
|
||||
|
||||
// Messages & topics
|
||||
|
||||
export type MessageDestination = { ip: string } | { pk: string };
|
||||
|
||||
export interface PushMessageReceipt {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface IncomingMessage {
|
||||
id: string;
|
||||
srcIp: string;
|
||||
srcPk: string;
|
||||
dstIp: string;
|
||||
dstPk: string;
|
||||
/** base64-encoded topic bytes */
|
||||
topic: string;
|
||||
/** base64-encoded payload bytes */
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export interface DefaultAction {
|
||||
accept: boolean;
|
||||
}
|
||||
|
||||
export interface PubkeyLookup {
|
||||
NodePubKey: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
daemonStatus: () => cmd<DaemonStatus>("daemon_status"),
|
||||
startDaemon: (config?: SidecarConfig) =>
|
||||
@@ -88,6 +116,54 @@ export const api = {
|
||||
peersStats: () => cmd<AggregatedStats>("peers_stats"),
|
||||
|
||||
routesSnapshot: () => cmd<RoutesSnapshot>("routes_snapshot"),
|
||||
|
||||
sendMessage: (
|
||||
destination: MessageDestination,
|
||||
topicB64: string,
|
||||
payloadB64: string,
|
||||
) =>
|
||||
cmd<PushMessageReceipt>("send_message", {
|
||||
destination,
|
||||
topicB64,
|
||||
payloadB64,
|
||||
}),
|
||||
replyMessage: (
|
||||
id: string,
|
||||
destination: MessageDestination,
|
||||
topicB64: string,
|
||||
payloadB64: string,
|
||||
) =>
|
||||
cmd<PushMessageReceipt>("reply_message", {
|
||||
id,
|
||||
destination,
|
||||
topicB64,
|
||||
payloadB64,
|
||||
}),
|
||||
messageStatus: (id: string) =>
|
||||
cmd<Record<string, unknown>>("message_status", { id }),
|
||||
inboxMessages: () => cmd<IncomingMessage[]>("inbox_messages"),
|
||||
inboxClear: () => cmd<void>("inbox_clear"),
|
||||
|
||||
topicsDefaultGet: () => cmd<DefaultAction>("topics_default_get"),
|
||||
topicsDefaultSet: (accept: boolean) =>
|
||||
cmd<void>("topics_default_set", { accept }),
|
||||
topicsList: () => cmd<string[]>("topics_list"),
|
||||
topicAdd: (topicB64: string) => cmd<void>("topic_add", { topicB64 }),
|
||||
topicRemove: (topicB64: string) => cmd<void>("topic_remove", { topicB64 }),
|
||||
topicSourcesList: (topicB64: string) =>
|
||||
cmd<string[]>("topic_sources_list", { topicB64 }),
|
||||
topicSourceAdd: (topicB64: string, subnet: string) =>
|
||||
cmd<void>("topic_source_add", { topicB64, subnet }),
|
||||
topicSourceRemove: (topicB64: string, subnet: string) =>
|
||||
cmd<void>("topic_source_remove", { topicB64, subnet }),
|
||||
topicForwardGet: (topicB64: string) =>
|
||||
cmd<string | null>("topic_forward_get", { topicB64 }),
|
||||
topicForwardSet: (topicB64: string, socketPath: string) =>
|
||||
cmd<void>("topic_forward_set", { topicB64, socketPath }),
|
||||
topicForwardRemove: (topicB64: string) =>
|
||||
cmd<void>("topic_forward_remove", { topicB64 }),
|
||||
|
||||
lookupPubkey: (ip: string) => cmd<PubkeyLookup>("lookup_pubkey", { ip }),
|
||||
};
|
||||
|
||||
/** Format the canonical peer endpoint string the API expects. */
|
||||
|
||||
@@ -21,3 +21,37 @@ export function shortenIpv6(addr: string): string {
|
||||
if (addr.length <= 24) return addr;
|
||||
return `${addr.slice(0, 10)}…${addr.slice(-8)}`;
|
||||
}
|
||||
|
||||
// ─── base64 helpers (UTF-8 safe) ─────────────────────────────────────────────
|
||||
|
||||
export function utf8ToBase64(s: string): string {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
let bin = "";
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
export function base64ToUtf8(b64: string): string {
|
||||
try {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
} catch {
|
||||
return b64;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPrintableUtf8(b64: string): boolean {
|
||||
try {
|
||||
const bin = atob(b64);
|
||||
for (let i = 0; i < bin.length; i++) {
|
||||
const c = bin.charCodeAt(i);
|
||||
// Reject NUL and most C0 except CR/LF/TAB.
|
||||
if (c === 0 || (c < 0x20 && c !== 9 && c !== 10 && c !== 13)) return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
139
src/stores/messages.ts
Normal file
139
src/stores/messages.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { Store as TauriStore } from "@tauri-apps/plugin-store";
|
||||
import {
|
||||
api,
|
||||
type IncomingMessage,
|
||||
type MessageDestination,
|
||||
} from "@/lib/api";
|
||||
import { Events, on } from "@/lib/events";
|
||||
|
||||
const STORE_FILE = "outbox.json";
|
||||
const OUTBOX_KEY = "outbox";
|
||||
const OUTBOX_CAP = 100;
|
||||
|
||||
export interface OutboxEntry {
|
||||
id: string;
|
||||
destination: MessageDestination;
|
||||
topicB64: string;
|
||||
payloadB64: string;
|
||||
sentAt: number;
|
||||
/** "pending" until we resolve message_status, then daemon-reported value. */
|
||||
status: string;
|
||||
}
|
||||
|
||||
export const useMessagesStore = defineStore("messages", () => {
|
||||
const inbox = ref<IncomingMessage[]>([]);
|
||||
const outbox = ref<OutboxEntry[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
let store: TauriStore | null = null;
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
async function ensureStore() {
|
||||
if (!store) {
|
||||
store = await TauriStore.load(STORE_FILE);
|
||||
const saved = (await store.get<OutboxEntry[]>(OUTBOX_KEY)) ?? [];
|
||||
outbox.value = saved;
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
async function persistOutbox() {
|
||||
const s = await ensureStore();
|
||||
await s.set(OUTBOX_KEY, outbox.value.slice(-OUTBOX_CAP));
|
||||
await s.save();
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
await ensureStore();
|
||||
if (!unlisten) {
|
||||
unlisten = await on<IncomingMessage>(Events.MessageIncoming, (e) => {
|
||||
// Emitted from src-tauri/src/poller.rs every time the daemon
|
||||
// long-poll resolves with an inbound message.
|
||||
inbox.value = [e.payload, ...inbox.value].slice(0, 200);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshInbox() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
// The poller's ring buffer is the source of truth; the page just
|
||||
// catches up after navigation.
|
||||
const list = await api.inboxMessages();
|
||||
inbox.value = list.slice().reverse(); // newest first
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearInbox() {
|
||||
await api.inboxClear();
|
||||
inbox.value = [];
|
||||
}
|
||||
|
||||
async function send(
|
||||
destination: MessageDestination,
|
||||
topicB64: string,
|
||||
payloadB64: string,
|
||||
) {
|
||||
const receipt = await api.sendMessage(destination, topicB64, payloadB64);
|
||||
outbox.value.push({
|
||||
id: receipt.id,
|
||||
destination,
|
||||
topicB64,
|
||||
payloadB64,
|
||||
sentAt: Date.now(),
|
||||
status: "pending",
|
||||
});
|
||||
if (outbox.value.length > OUTBOX_CAP) {
|
||||
outbox.value = outbox.value.slice(-OUTBOX_CAP);
|
||||
}
|
||||
await persistOutbox();
|
||||
return receipt;
|
||||
}
|
||||
|
||||
async function refreshOutboxStatus(id: string) {
|
||||
try {
|
||||
const status = await api.messageStatus(id);
|
||||
const entry = outbox.value.find((o) => o.id === id);
|
||||
if (entry) {
|
||||
const v = (status as { state?: unknown }).state;
|
||||
entry.status = typeof v === "string" ? v : JSON.stringify(status);
|
||||
await persistOutbox();
|
||||
}
|
||||
} catch (e) {
|
||||
const entry = outbox.value.find((o) => o.id === id);
|
||||
if (entry) entry.status = `error: ${String(e)}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearOutbox() {
|
||||
outbox.value = [];
|
||||
await persistOutbox();
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
unlisten?.();
|
||||
unlisten = null;
|
||||
}
|
||||
|
||||
return {
|
||||
inbox,
|
||||
outbox,
|
||||
loading,
|
||||
error,
|
||||
bootstrap,
|
||||
refreshInbox,
|
||||
clearInbox,
|
||||
send,
|
||||
refreshOutboxStatus,
|
||||
clearOutbox,
|
||||
dispose,
|
||||
};
|
||||
});
|
||||
89
src/stores/topics.ts
Normal file
89
src/stores/topics.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { api, type DefaultAction } from "@/lib/api";
|
||||
|
||||
export const useTopicsStore = defineStore("topics", () => {
|
||||
const defaultAction = ref<DefaultAction>({ accept: true });
|
||||
const topics = ref<string[]>([]);
|
||||
const sources = ref<Record<string, string[]>>({});
|
||||
const forwards = ref<Record<string, string | null>>({});
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
defaultAction.value = await api.topicsDefaultGet();
|
||||
topics.value = await api.topicsList();
|
||||
} catch (e) {
|
||||
error.value = String(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(accept: boolean) {
|
||||
await api.topicsDefaultSet(accept);
|
||||
defaultAction.value = { accept };
|
||||
}
|
||||
|
||||
async function addTopic(topicB64: string) {
|
||||
await api.topicAdd(topicB64);
|
||||
if (!topics.value.includes(topicB64)) topics.value.push(topicB64);
|
||||
}
|
||||
|
||||
async function removeTopic(topicB64: string) {
|
||||
await api.topicRemove(topicB64);
|
||||
topics.value = topics.value.filter((t) => t !== topicB64);
|
||||
delete sources.value[topicB64];
|
||||
delete forwards.value[topicB64];
|
||||
}
|
||||
|
||||
async function refreshSources(topicB64: string) {
|
||||
sources.value[topicB64] = await api.topicSourcesList(topicB64);
|
||||
}
|
||||
|
||||
async function addSource(topicB64: string, subnet: string) {
|
||||
await api.topicSourceAdd(topicB64, subnet);
|
||||
await refreshSources(topicB64);
|
||||
}
|
||||
|
||||
async function removeSource(topicB64: string, subnet: string) {
|
||||
await api.topicSourceRemove(topicB64, subnet);
|
||||
await refreshSources(topicB64);
|
||||
}
|
||||
|
||||
async function refreshForward(topicB64: string) {
|
||||
forwards.value[topicB64] = await api.topicForwardGet(topicB64);
|
||||
}
|
||||
|
||||
async function setForward(topicB64: string, socketPath: string) {
|
||||
await api.topicForwardSet(topicB64, socketPath);
|
||||
await refreshForward(topicB64);
|
||||
}
|
||||
|
||||
async function removeForward(topicB64: string) {
|
||||
await api.topicForwardRemove(topicB64);
|
||||
forwards.value[topicB64] = null;
|
||||
}
|
||||
|
||||
return {
|
||||
defaultAction,
|
||||
topics,
|
||||
sources,
|
||||
forwards,
|
||||
loading,
|
||||
error,
|
||||
refresh,
|
||||
setDefault,
|
||||
addTopic,
|
||||
removeTopic,
|
||||
refreshSources,
|
||||
addSource,
|
||||
removeSource,
|
||||
refreshForward,
|
||||
setForward,
|
||||
removeForward,
|
||||
};
|
||||
});
|
||||
@@ -1,5 +1,114 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { Trash2, RefreshCw } from "lucide-vue-next";
|
||||
import ComposeMessage from "@/components/ComposeMessage.vue";
|
||||
import MessageList from "@/components/MessageList.vue";
|
||||
import { useMessagesStore, type OutboxEntry } from "@/stores/messages";
|
||||
import { useNodeStore } from "@/stores/node";
|
||||
import { base64ToUtf8, isPrintableUtf8 } from "@/lib/utils";
|
||||
|
||||
const messages = useMessagesStore();
|
||||
const node = useNodeStore();
|
||||
const { inbox, outbox } = storeToRefs(messages);
|
||||
const { phase } = storeToRefs(node);
|
||||
|
||||
const isReady = computed(() => phase.value === "ready");
|
||||
|
||||
onMounted(async () => {
|
||||
await messages.bootstrap();
|
||||
if (isReady.value) await messages.refreshInbox();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
messages.dispose();
|
||||
});
|
||||
|
||||
async function clearInbox() {
|
||||
if (!confirm("Clear all received messages?")) return;
|
||||
await messages.clearInbox();
|
||||
}
|
||||
|
||||
function destLabel(d: OutboxEntry["destination"]): string {
|
||||
return "ip" in d ? d.ip : d.pk;
|
||||
}
|
||||
|
||||
function topicShort(t: string): string {
|
||||
if (!t) return "—";
|
||||
return isPrintableUtf8(t) ? base64ToUtf8(t) : `b64:${t.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
async function refreshStatus(id: string) {
|
||||
await messages.refreshOutboxStatus(id);
|
||||
}
|
||||
|
||||
async function clearOutbox() {
|
||||
if (!confirm("Clear sent message history?")) return;
|
||||
await messages.clearOutbox();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="text-sm text-muted-foreground">Messages view — wired in P4.</p>
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<section class="space-y-4">
|
||||
<ComposeMessage />
|
||||
|
||||
<div class="rounded-lg border border-border bg-card">
|
||||
<header class="flex items-center justify-between border-b border-border px-4 py-2">
|
||||
<h3 class="text-sm font-medium">Outbox</h3>
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>{{ outbox.length }}</span>
|
||||
<button
|
||||
v-if="outbox.length"
|
||||
class="rounded p-1 hover:bg-secondary hover:text-foreground"
|
||||
title="Clear outbox"
|
||||
@click="clearOutbox"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="max-h-[35vh] divide-y divide-border overflow-y-auto">
|
||||
<p
|
||||
v-if="!outbox.length"
|
||||
class="px-4 py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No sent messages.
|
||||
</p>
|
||||
<article
|
||||
v-for="entry in outbox"
|
||||
:key="entry.id"
|
||||
class="flex items-start gap-3 px-4 py-2 hover:bg-muted/30"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-2 text-xs">
|
||||
<code class="text-muted-foreground">{{ entry.id }}</code>
|
||||
<span class="text-muted-foreground">·</span>
|
||||
<span>to <span class="font-mono">{{ destLabel(entry.destination) }}</span></span>
|
||||
</div>
|
||||
<div class="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
topic
|
||||
<span class="font-mono">{{ topicShort(entry.topicB64) }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 text-xs">
|
||||
status:
|
||||
<span class="font-mono">{{ entry.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
title="Refresh status"
|
||||
@click="refreshStatus(entry.id)"
|
||||
>
|
||||
<RefreshCw class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<MessageList :messages="inbox" @clear="clearInbox" />
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,262 @@
|
||||
<script setup lang="ts"></script>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { Plus, Trash2, Hash } from "lucide-vue-next";
|
||||
import { useTopicsStore } from "@/stores/topics";
|
||||
import { useNodeStore } from "@/stores/node";
|
||||
import { base64ToUtf8, isPrintableUtf8, utf8ToBase64 } from "@/lib/utils";
|
||||
|
||||
const topicsStore = useTopicsStore();
|
||||
const node = useNodeStore();
|
||||
const { topics, defaultAction, sources, forwards, error } = storeToRefs(topicsStore);
|
||||
const { phase } = storeToRefs(node);
|
||||
|
||||
const isReady = computed(() => phase.value === "ready");
|
||||
const newTopic = ref("");
|
||||
const selected = ref<string | null>(null);
|
||||
const newSource = ref("");
|
||||
const newForward = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
if (isReady.value) await topicsStore.refresh();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => topics.value,
|
||||
(list) => {
|
||||
if (selected.value && !list.includes(selected.value)) selected.value = null;
|
||||
if (!selected.value && list.length) selected.value = list[0];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(selected, async (t) => {
|
||||
if (!t) return;
|
||||
await Promise.all([topicsStore.refreshSources(t), topicsStore.refreshForward(t)]);
|
||||
});
|
||||
|
||||
function topicLabel(t: string): string {
|
||||
return isPrintableUtf8(t) ? base64ToUtf8(t) : `b64:${t.slice(0, 18)}…`;
|
||||
}
|
||||
|
||||
async function addTopic() {
|
||||
const v = newTopic.value.trim();
|
||||
if (!v) return;
|
||||
try {
|
||||
await topicsStore.addTopic(utf8ToBase64(v));
|
||||
newTopic.value = "";
|
||||
} catch (e) {
|
||||
alert(`Could not add topic: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTopic(t: string) {
|
||||
if (!confirm(`Remove topic "${topicLabel(t)}"?`)) return;
|
||||
try {
|
||||
await topicsStore.removeTopic(t);
|
||||
} catch (e) {
|
||||
alert(`Could not remove: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function addSource() {
|
||||
if (!selected.value || !newSource.value.trim()) return;
|
||||
try {
|
||||
await topicsStore.addSource(selected.value, newSource.value.trim());
|
||||
newSource.value = "";
|
||||
} catch (e) {
|
||||
alert(`Could not add source: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSource(subnet: string) {
|
||||
if (!selected.value) return;
|
||||
try {
|
||||
await topicsStore.removeSource(selected.value, subnet);
|
||||
} catch (e) {
|
||||
alert(`Could not remove source: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function setForward() {
|
||||
if (!selected.value || !newForward.value.trim()) return;
|
||||
try {
|
||||
await topicsStore.setForward(selected.value, newForward.value.trim());
|
||||
newForward.value = "";
|
||||
} catch (e) {
|
||||
alert(`Could not set forward: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function clearForward() {
|
||||
if (!selected.value) return;
|
||||
if (!confirm("Remove forward socket?")) return;
|
||||
await topicsStore.removeForward(selected.value);
|
||||
}
|
||||
|
||||
async function toggleDefault() {
|
||||
await topicsStore.setDefault(!defaultAction.value.accept);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p class="text-sm text-muted-foreground">Topics view — wired in P4.</p>
|
||||
<div v-if="!isReady" class="text-sm text-muted-foreground">Daemon offline.</div>
|
||||
<div v-else class="grid gap-4 lg:grid-cols-[260px_1fr]">
|
||||
<section class="space-y-3">
|
||||
<div class="rounded-lg border border-border bg-card p-3">
|
||||
<div class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Default action
|
||||
</div>
|
||||
<button
|
||||
class="mt-2 w-full rounded-md border border-border px-3 py-2 text-sm hover:bg-secondary"
|
||||
@click="toggleDefault"
|
||||
>
|
||||
{{ defaultAction.accept ? "Accept by default" : "Reject by default" }}
|
||||
</button>
|
||||
<p class="mt-2 text-[11px] text-muted-foreground">
|
||||
Controls how the daemon treats topics that aren't in the whitelist
|
||||
below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-card">
|
||||
<header class="flex items-center justify-between border-b border-border px-3 py-2">
|
||||
<span class="text-sm font-medium">Whitelisted topics</span>
|
||||
<span class="text-xs text-muted-foreground">{{ topics.length }}</span>
|
||||
</header>
|
||||
<ul class="max-h-[40vh] divide-y divide-border overflow-y-auto">
|
||||
<li v-if="!topics.length" class="px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No topics yet.
|
||||
</li>
|
||||
<li
|
||||
v-for="t in topics"
|
||||
:key="t"
|
||||
class="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-sm hover:bg-muted/30"
|
||||
:class="selected === t ? 'bg-secondary text-secondary-foreground' : ''"
|
||||
@click="selected = t"
|
||||
>
|
||||
<Hash class="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{{ topicLabel(t) }}
|
||||
</span>
|
||||
<button
|
||||
class="shrink-0 rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
@click.stop="removeTopic(t)"
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<footer class="flex gap-2 border-t border-border p-3">
|
||||
<input
|
||||
v-model="newTopic"
|
||||
type="text"
|
||||
placeholder="topic name"
|
||||
class="flex-1 rounded-md border border-input bg-background px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
@keyup.enter="addTopic"
|
||||
/>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
|
||||
@click="addTopic"
|
||||
>
|
||||
<Plus class="h-3 w-3" /> Add
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
<p v-if="error" class="text-xs text-destructive">{{ error }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="selected" class="space-y-4">
|
||||
<div class="rounded-lg border border-border bg-card p-4">
|
||||
<div class="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Selected topic
|
||||
</div>
|
||||
<div class="mt-1 break-all font-mono text-sm">
|
||||
{{ topicLabel(selected) }}
|
||||
</div>
|
||||
<div class="mt-1 break-all text-[11px] text-muted-foreground">
|
||||
base64: {{ selected }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-card">
|
||||
<header class="border-b border-border px-4 py-2 text-sm font-medium">
|
||||
Allowed source subnets
|
||||
</header>
|
||||
<ul class="divide-y divide-border">
|
||||
<li
|
||||
v-if="!(sources[selected]?.length)"
|
||||
class="px-4 py-3 text-center text-xs text-muted-foreground"
|
||||
>
|
||||
No sources — all subnets allowed for this topic.
|
||||
</li>
|
||||
<li
|
||||
v-for="s in sources[selected] ?? []"
|
||||
:key="s"
|
||||
class="flex items-center justify-between px-4 py-2 text-sm hover:bg-muted/30"
|
||||
>
|
||||
<span class="font-mono text-xs">{{ s }}</span>
|
||||
<button
|
||||
class="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="removeSource(s)"
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<footer class="flex gap-2 border-t border-border p-3">
|
||||
<input
|
||||
v-model="newSource"
|
||||
type="text"
|
||||
placeholder="503:5478:df06:d79a::/64"
|
||||
class="flex-1 rounded-md border border-input bg-background px-2 py-1 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
@keyup.enter="addSource"
|
||||
/>
|
||||
<button
|
||||
class="inline-flex items-center gap-1 rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
|
||||
@click="addSource"
|
||||
>
|
||||
<Plus class="h-3 w-3" /> Allow
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-card">
|
||||
<header class="border-b border-border px-4 py-2 text-sm font-medium">
|
||||
Forward socket
|
||||
</header>
|
||||
<div class="px-4 py-3 text-sm">
|
||||
<span v-if="forwards[selected]" class="font-mono text-xs">
|
||||
{{ forwards[selected] }}
|
||||
</span>
|
||||
<span v-else class="text-muted-foreground">No forward configured.</span>
|
||||
</div>
|
||||
<footer class="flex gap-2 border-t border-border p-3">
|
||||
<input
|
||||
v-model="newForward"
|
||||
type="text"
|
||||
placeholder="/var/run/myapp.sock"
|
||||
class="flex-1 rounded-md border border-input bg-background px-2 py-1 font-mono text-xs focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
@keyup.enter="setForward"
|
||||
/>
|
||||
<button
|
||||
class="rounded-md bg-primary px-2 py-1 text-xs font-medium text-primary-foreground hover:opacity-90"
|
||||
@click="setForward"
|
||||
>
|
||||
Set
|
||||
</button>
|
||||
<button
|
||||
v-if="forwards[selected]"
|
||||
class="rounded-md border border-border px-2 py-1 text-xs hover:bg-destructive/10 hover:text-destructive"
|
||||
@click="clearForward"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
<section v-else class="text-sm text-muted-foreground">
|
||||
Pick a topic on the left to manage its sources and forward.
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user