feat(web): the console can say what just happened, and hand a phone the way in
**Recent activity.** The console could describe the present — a status snapshot — but never the recent past. A client that connected and left while you were on another page left no trace anywhere you could look, and the host's own log is a developer artifact rather than a narrative. The event stream was already open for cache invalidation, so a feed costs one ring buffer next to it: every frame is recorded, labelled per kind, and rendered newest-first on the dashboard. Deliberately in-memory and bounded to 200. It starts empty on a page load and fills as things happen, which is the honest shape for a live tail — an audit trail would need the host to keep one, and pretending otherwise would be worse than not having it. **Connect a device.** The console knew the host's address and identity all along and never offered either in a form you could hand to a phone: pairing meant reading an IP off the Host page and retyping it on a couch. There is a card now with the address and a `punktfunk://connect/<uniqueid>` deep link, both copyable — the link is the shipped client grammar (clients/shared/deeplink-vectors.json), so an installed client opens straight onto this host. No QR: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none. **Installable.** A web manifest and the theme/apple meta tags, so the console can live on a phone's home screen — which is where it is used from as often as from a desk. No service worker on purpose: an offline shell for a console whose every screen is live host state would only ever show stale numbers convincingly. The manifest is reachable without a session (install needs it, and it says nothing the login page doesn't); /api stays gated, verified. Verified in a browser: three host-emitted events appear in the feed with the right labels, the deep link renders and copies as `punktfunk://connect/abc123`, and the manifest serves 200 as application/manifest+json while /api/v1/host still answers 401. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,9 +77,36 @@
|
||||
"stream_packet_size": "Paketgröße",
|
||||
"stream_min_fec": "FEC-Minimum",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"activity_title": "Letzte Aktivität",
|
||||
"activity_empty": "Noch nichts — Ereignisse erscheinen hier, sobald sie auf dem Host passieren.",
|
||||
"activity_client_connected": "Verbunden",
|
||||
"activity_client_disconnected": "Getrennt",
|
||||
"activity_session_started": "Sitzung gestartet",
|
||||
"activity_session_ended": "Sitzung beendet",
|
||||
"activity_stream_started": "Stream gestartet",
|
||||
"activity_stream_stopped": "Stream gestoppt",
|
||||
"activity_game_running": "Spiel läuft",
|
||||
"activity_game_exited": "Spiel beendet",
|
||||
"activity_pairing_pending": "Kopplung angefragt",
|
||||
"activity_pairing_completed": "Gekoppelt",
|
||||
"activity_pairing_denied": "Kopplung abgelehnt",
|
||||
"activity_display_created": "Anzeige erstellt",
|
||||
"activity_display_released": "Anzeige freigegeben",
|
||||
"activity_library_changed": "Bibliothek geändert",
|
||||
"activity_update_available": "Update verfügbar",
|
||||
"activity_update_applied": "Update angewendet",
|
||||
"activity_plugins_changed": "Plugins geändert",
|
||||
"activity_store_changed": "Store geändert",
|
||||
"activity_host_started": "Host gestartet",
|
||||
"activity_host_stopping": "Host wird beendet",
|
||||
"action_stop_session": "Sitzung beenden",
|
||||
"action_request_idr": "Keyframe anfordern",
|
||||
"action_unpair": "Entkoppeln",
|
||||
"connect_title": "Gerät verbinden",
|
||||
"connect_help": "Gib die Adresse in einem Punktfunk-Client ein — oder öffne den Link auf einem Gerät, auf dem bereits einer installiert ist: er führt direkt zu diesem Host. Gekoppelt wird auf der Seite „Kopplung“.",
|
||||
"connect_address": "Host-Adresse",
|
||||
"connect_link": "Deep-Link",
|
||||
"connect_copy": "Kopieren",
|
||||
"host_identity": "Identität",
|
||||
"host_hostname": "Hostname",
|
||||
"host_os": "Betriebssystem",
|
||||
|
||||
@@ -77,9 +77,36 @@
|
||||
"stream_packet_size": "Packet size",
|
||||
"stream_min_fec": "FEC floor",
|
||||
"stream_bitrate": "Bitrate",
|
||||
"activity_title": "Recent activity",
|
||||
"activity_empty": "Nothing yet — events show up here as they happen on the host.",
|
||||
"activity_client_connected": "Connected",
|
||||
"activity_client_disconnected": "Disconnected",
|
||||
"activity_session_started": "Session started",
|
||||
"activity_session_ended": "Session ended",
|
||||
"activity_stream_started": "Stream started",
|
||||
"activity_stream_stopped": "Stream stopped",
|
||||
"activity_game_running": "Game running",
|
||||
"activity_game_exited": "Game exited",
|
||||
"activity_pairing_pending": "Pairing requested",
|
||||
"activity_pairing_completed": "Paired",
|
||||
"activity_pairing_denied": "Pairing denied",
|
||||
"activity_display_created": "Display created",
|
||||
"activity_display_released": "Display released",
|
||||
"activity_library_changed": "Library changed",
|
||||
"activity_update_available": "Update available",
|
||||
"activity_update_applied": "Update applied",
|
||||
"activity_plugins_changed": "Plugins changed",
|
||||
"activity_store_changed": "Store changed",
|
||||
"activity_host_started": "Host started",
|
||||
"activity_host_stopping": "Host stopping",
|
||||
"action_stop_session": "Stop session",
|
||||
"action_request_idr": "Request keyframe",
|
||||
"action_unpair": "Unpair",
|
||||
"connect_title": "Connect a device",
|
||||
"connect_help": "Type the address into a punktfunk client, or open the link on a device that already has one installed — it opens straight onto this host. Pair from the Pairing page.",
|
||||
"connect_address": "Host address",
|
||||
"connect_link": "Deep link",
|
||||
"connect_copy": "Copy",
|
||||
"host_identity": "Identity",
|
||||
"host_hostname": "Hostname",
|
||||
"host_os": "Operating system",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Punktfunk",
|
||||
"short_name": "Punktfunk",
|
||||
"description": "Management console for a punktfunk streaming host.",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#6c5bf3",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon.svg",
|
||||
"type": "image/svg+xml",
|
||||
"sizes": "any",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -147,6 +147,9 @@ export function isPublicPath(pathname: string): boolean {
|
||||
if (pathname.startsWith("/_auth/")) return true;
|
||||
if (pathname.startsWith("/assets/")) return true;
|
||||
if (pathname === "/favicon.ico" || pathname === "/robots.txt") return true;
|
||||
// The web manifest must be fetchable to install the app, and it says nothing a logged-out
|
||||
// visitor cannot already see from the login page (name, colours, the brand mark).
|
||||
if (pathname === "/manifest.webmanifest") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
+68
-2
@@ -20,7 +20,7 @@
|
||||
// that (nitro-entry/bun-https.mjs) so we don't sever our own stream.
|
||||
// - An `event: dropped` frame means we fell off the ring and must resync — invalidate everything.
|
||||
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients";
|
||||
import { getGetDisplayStateQueryKey } from "@/api/gen/display/display";
|
||||
import { getGetStatusQueryKey } from "@/api/gen/host/host";
|
||||
@@ -104,6 +104,55 @@ function resyncAll(qc: QueryClient): void {
|
||||
qc.invalidateQueries({ refetchType: "all" });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The activity log.
|
||||
//
|
||||
// The same frames that drive invalidation are also, in themselves, the answer to "what has this
|
||||
// host been doing?" — a question the console could not answer at all. Nothing else records this:
|
||||
// the REST snapshots describe the present, and the host's own log is a developer artifact, not a
|
||||
// narrative. So keep a small in-memory ring alongside the cache work.
|
||||
//
|
||||
// Deliberately NOT persisted and deliberately bounded: it is a live tail for someone watching, not
|
||||
// an audit trail, and a page load starts fresh from whatever the ring replays.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** One thing that happened, as the feed renders it. */
|
||||
export interface ActivityEntry {
|
||||
/** The host's monotonic sequence number — stable, and a good React key. */
|
||||
seq: number;
|
||||
/** Unix ms, from the host's clock (never the browser's). */
|
||||
ts_ms: number;
|
||||
kind: string;
|
||||
/** The event payload, shape depending on `kind` (see the EventKind schema). */
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ACTIVITY_MAX = 200;
|
||||
let activity: ActivityEntry[] = [];
|
||||
const activityListeners = new Set<() => void>();
|
||||
|
||||
function pushActivity(entry: ActivityEntry): void {
|
||||
// Guard against a replayed frame after a reconnect (`Last-Event-ID` can re-deliver the cursor).
|
||||
if (activity.some((e) => e.seq === entry.seq)) return;
|
||||
activity = [entry, ...activity].slice(0, ACTIVITY_MAX);
|
||||
for (const l of activityListeners) l();
|
||||
}
|
||||
|
||||
/** The activity tail, newest first. Re-renders as frames arrive. */
|
||||
export function useActivity(): ActivityEntry[] {
|
||||
return useSyncExternalStore(
|
||||
(cb) => {
|
||||
activityListeners.add(cb);
|
||||
return () => activityListeners.delete(cb);
|
||||
},
|
||||
() => activity,
|
||||
// The server has no stream, so SSR renders an empty feed and hydrates into the live one.
|
||||
() => EMPTY_ACTIVITY,
|
||||
);
|
||||
}
|
||||
|
||||
const EMPTY_ACTIVITY: ActivityEntry[] = [];
|
||||
|
||||
/** Every kind we act on. A kind the host adds later simply has no listener — never a mis-handle. */
|
||||
const KINDS = [
|
||||
"client.connected",
|
||||
@@ -153,7 +202,9 @@ function attach(): void {
|
||||
if (source) return;
|
||||
source = new EventSource("/api/v1/events");
|
||||
for (const kind of KINDS) {
|
||||
source.addEventListener(kind, () => {
|
||||
source.addEventListener(kind, (ev) => {
|
||||
// Record it first: the feed should show an event even for a kind we invalidate nothing for.
|
||||
recordActivity(kind, ev);
|
||||
if (!client) return;
|
||||
// The installed set changed — but the runner is probably still restarting, so keep
|
||||
// checking for a while rather than trusting this one refetch (see boostPluginPolling).
|
||||
@@ -170,6 +221,21 @@ function attach(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** Parse one SSE frame into the activity ring. A malformed frame is dropped, never thrown. */
|
||||
function recordActivity(kind: string, ev: Event): void {
|
||||
const raw = (ev as MessageEvent<string>).data;
|
||||
if (typeof raw !== "string") return;
|
||||
try {
|
||||
const data = JSON.parse(raw) as Record<string, unknown>;
|
||||
const seq = typeof data.seq === "number" ? data.seq : Number.NaN;
|
||||
const ts = typeof data.ts_ms === "number" ? data.ts_ms : Number.NaN;
|
||||
if (!Number.isFinite(seq) || !Number.isFinite(ts)) return;
|
||||
pushActivity({ seq, ts_ms: ts, kind, data });
|
||||
} catch {
|
||||
// A frame we cannot parse is not worth breaking the stream over.
|
||||
}
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
refs -= 1;
|
||||
if (refs > 0) return;
|
||||
|
||||
@@ -26,11 +26,19 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
||||
{ charSet: "utf-8" },
|
||||
{ name: "viewport", content: "width=device-width, initial-scale=1" },
|
||||
{ name: "color-scheme", content: "dark light" },
|
||||
{ name: "theme-color", content: "#6c5bf3" },
|
||||
{ name: "apple-mobile-web-app-capable", content: "yes" },
|
||||
{ name: "apple-mobile-web-app-title", content: "Punktfunk" },
|
||||
{ title: "Punktfunk" },
|
||||
],
|
||||
links: [
|
||||
{ rel: "stylesheet", href: appCss },
|
||||
{ rel: "icon", type: "image/svg+xml", href: "/favicon.svg" },
|
||||
// Installable on a phone — this console is used from a couch as often as from a desk,
|
||||
// and a home-screen launcher beats retyping a LAN IP. Standalone display, no service
|
||||
// worker: an offline shell for a console whose every screen is live host state would
|
||||
// only ever show stale numbers convincingly.
|
||||
{ rel: "manifest", href: "/manifest.webmanifest" },
|
||||
],
|
||||
}),
|
||||
component: RootComponent,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Activity as ActivityIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { type ActivityEntry, useActivity } from "@/api/events";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { fmtDateTime } from "@/lib/format";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* What this host has been doing — the event stream, rendered.
|
||||
*
|
||||
* The console could describe the present (a status snapshot) but never the recent past: a client
|
||||
* that connected and left while you were on another page left no trace anywhere you could look.
|
||||
* The stream was already open for cache invalidation, so this costs one ring buffer.
|
||||
*
|
||||
* In-memory and bounded, so it starts empty on a page load and fills as things happen. That is the
|
||||
* honest shape for a live tail — pretending to be a durable log would need the host to keep one.
|
||||
*/
|
||||
export const ActivityCard: FC = () => {
|
||||
const entries = useActivity();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ActivityIcon className="size-4" />
|
||||
{m.activity_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{entries.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{m.activity_empty()}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y">
|
||||
{entries.map((e) => (
|
||||
<li
|
||||
key={e.seq}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 py-2 first:pt-0 last:pb-0"
|
||||
>
|
||||
<Badge variant={toneFor(e.kind)}>{kindLabel(e.kind)}</Badge>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{describe(e)}
|
||||
</span>
|
||||
<time
|
||||
dateTime={new Date(e.ts_ms).toISOString()}
|
||||
className="shrink-0 text-xs tabular-nums text-muted-foreground"
|
||||
>
|
||||
{fmtDateTime(e.ts_ms)}
|
||||
</time>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/** The subject of an event, in one line — whatever the payload actually names. */
|
||||
function describe(e: ActivityEntry): string {
|
||||
const d = e.data;
|
||||
const client = pick(d.client, "name") ?? pick(d.session, "client");
|
||||
const stream = d.stream as Record<string, unknown> | undefined;
|
||||
const parts = [
|
||||
client,
|
||||
typeof stream?.app === "string" ? stream.app : undefined,
|
||||
typeof d.reason === "string" ? d.reason : undefined,
|
||||
typeof d.game === "string" ? d.game : undefined,
|
||||
].filter((x): x is string => typeof x === "string" && x.length > 0);
|
||||
// An event whose payload names nothing (host.started, library.changed) is still worth a row —
|
||||
// the kind badge carries the whole meaning, so leave the line blank rather than inventing text.
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** Read a string field off a nested ref object, tolerating anything unexpected. */
|
||||
function pick(obj: unknown, key: string): string | undefined {
|
||||
if (!obj || typeof obj !== "object") return undefined;
|
||||
const v = (obj as Record<string, unknown>)[key];
|
||||
if (typeof v === "string") return v;
|
||||
// `SessionRef.client` is itself a ClientRef.
|
||||
if (v && typeof v === "object") {
|
||||
const name = (v as Record<string, unknown>).name;
|
||||
return typeof name === "string" ? name : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Colour by what the event means, not by its domain — good news green, losses muted, denials red. */
|
||||
function toneFor(
|
||||
kind: string,
|
||||
): "success" | "destructive" | "secondary" | "outline" {
|
||||
if (kind === "pairing.denied") return "destructive";
|
||||
if (kind.endsWith(".connected") || kind.endsWith(".started"))
|
||||
return "success";
|
||||
if (kind === "pairing.completed") return "success";
|
||||
if (kind.endsWith(".disconnected") || kind.endsWith(".ended"))
|
||||
return "outline";
|
||||
if (kind.endsWith(".stopped") || kind.endsWith(".exited")) return "outline";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
/** Translated label per kind, falling back to the raw kind so a new host event still shows. */
|
||||
const KIND_LABEL: Record<string, () => string> = {
|
||||
"client.connected": () => m.activity_client_connected(),
|
||||
"client.disconnected": () => m.activity_client_disconnected(),
|
||||
"session.started": () => m.activity_session_started(),
|
||||
"session.ended": () => m.activity_session_ended(),
|
||||
"stream.started": () => m.activity_stream_started(),
|
||||
"stream.stopped": () => m.activity_stream_stopped(),
|
||||
"game.running": () => m.activity_game_running(),
|
||||
"game.exited": () => m.activity_game_exited(),
|
||||
"pairing.pending": () => m.activity_pairing_pending(),
|
||||
"pairing.completed": () => m.activity_pairing_completed(),
|
||||
"pairing.denied": () => m.activity_pairing_denied(),
|
||||
"display.created": () => m.activity_display_created(),
|
||||
"display.released": () => m.activity_display_released(),
|
||||
"library.changed": () => m.activity_library_changed(),
|
||||
"update.available": () => m.activity_update_available(),
|
||||
"update.applied": () => m.activity_update_applied(),
|
||||
"plugins.changed": () => m.activity_plugins_changed(),
|
||||
"store.changed": () => m.activity_store_changed(),
|
||||
"host.started": () => m.activity_host_started(),
|
||||
"host.stopping": () => m.activity_host_stopping(),
|
||||
};
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
return KIND_LABEL[kind]?.() ?? kind;
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { fmtNumber } from "@/lib/format";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { ActivityCard } from "./Activity";
|
||||
import { RunningGames } from "./RunningGames";
|
||||
|
||||
export const DashboardView: FC<{
|
||||
@@ -181,6 +182,9 @@ export const DashboardView: FC<{
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Below the session card: the past, under the present. */}
|
||||
<ActivityCard />
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Check, Copy, Smartphone } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { HostInfo } from "@/api/gen/model/hostInfo";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* "Get a device onto this host" — the address to type, and the deep link that skips typing it.
|
||||
*
|
||||
* The console knew the host's identity and local address all along and never offered either in a
|
||||
* form you could hand to a phone: pairing meant reading an IP off the Host page and retyping it on
|
||||
* a couch. `punktfunk://connect/<unique_id>` is the shipped client grammar
|
||||
* (clients/shared/deeplink-vectors.json — the Rust, Swift and Kotlin parsers all test against it),
|
||||
* so a client that is already installed opens straight onto this host.
|
||||
*
|
||||
* No QR code: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none.
|
||||
* The link is short enough to send over any chat app, which is what people actually do.
|
||||
*/
|
||||
export const ConnectCard: FC<{ host: HostInfo }> = ({ host }) => {
|
||||
const deepLink = `punktfunk://connect/${host.uniqueid}`;
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Smartphone className="size-4" />
|
||||
{m.connect_title()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="max-w-prose text-sm text-muted-foreground">
|
||||
{m.connect_help()}
|
||||
</p>
|
||||
<CopyRow label={m.connect_address()} value={host.local_ip} />
|
||||
<CopyRow label={m.connect_link()} value={deepLink} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
/** One labelled, monospaced value with a copy button — the point of the card. */
|
||||
const CopyRow: FC<{ label: string; value: string }> = ({ label, value }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
// Revert the affordance rather than leaving a permanent tick, which would stop reading as
|
||||
// feedback the second time you press it.
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// Clipboard denied (insecure origin, or the user said no) — the value is on screen and
|
||||
// selectable, so there is nothing worth interrupting them about.
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="min-w-0 flex-1 truncate rounded-md bg-muted px-3 py-2 font-mono text-xs">
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label={m.connect_copy()}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="size-4 text-[var(--success)]" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { ConnectCard } from "./ConnectCard";
|
||||
|
||||
export const HostView: FC<{
|
||||
host: Loadable<HostInfo>;
|
||||
@@ -27,6 +28,7 @@ export const HostView: FC<{
|
||||
<h1 className="text-2xl font-semibold">{m.nav_host()}</h1>
|
||||
|
||||
{conflicts}
|
||||
{h && <ConnectCard host={h} />}
|
||||
|
||||
<QueryState
|
||||
isLoading={host.isLoading}
|
||||
|
||||
Reference in New Issue
Block a user