Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions ts/packages/web-console/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ The browser client for wire-mesh: a client of *any* node, not just the hub. It c
- **Identity** — the console holds a persisted Web Crypto (P-256) identity (`src/adapters/web-crypto-identity.ts`'s `createPersistedWebCryptoIdentity`, backed by `src/adapters/indexeddb-storage.ts`), stable across reloads and shared across every connection the console makes.
- **Capability tokens** — `MeshSession.setToken` accepts a capability token to present on subsequent `manage-request`s (`sendManageRequest`), gating anything the connected node or a relayed peer requires authorization for.
- **Reconnect** — a dropped connection retries with backoff (opt-in `ReconnectPolicy`; `main.ts` wires a default exponential backoff, capped, for every session it creates), surfaced as an explicit `"reconnecting"` status rather than silently going dark.
- **Peer directory**: every `gossip` frame the node sends is folded into a directory table (device, addresses, and when the peer advertised itself by its own clock), latest advert per device winning.
- **Frame log** — a live, ordered feed of every frame that crossed the connection in either direction, plus a *Send ping* button.
- **Peer directory**: every `gossip` frame the node sends is folded into a directory table (device, addresses, and when the peer advertised itself by its own clock), latest advert per device winning. On a phone-width screen each row stacks into a labelled card (`stackedTable` in `src/App.css.ts`).
- **Naming**: a peer is named wherever it appears by one convention (`src/peer-names.ts`): a petname this viewer gave the device, else the display name the device signed about itself (always shown as the peer's own claim, in italics under "calls itself" with the short id beside it, since a stranger chooses the name and two peers can claim the same one; flagged when it copies a petname this viewer holds), else a short id with a button that copies the full device-id. Petnames live in this console's own storage (`src/name-store.ts`) and are never gossiped. A self display name travels in the `agent/self` extension of a verified peer-advert, the key agent-comms publishes its agent name under and the one the public hub's advert policy carries, and this console publishes its own the same way.
- **Trust**: an address that carries certificate pins is assessed against `src/certificate-memory.ts` before it is dialled (`src/certificate-trust.ts`): nothing remembered asks for a first-use confirmation showing the certificate fingerprint, a remembered set sharing nothing with the presented one raises a change warning that must be accepted, and an overlapping set (a rotation) connects silently, dialled with only the presented certificates that are already remembered. A connected node that announces certificates unlike the remembered ones is reported and then pinned: the announcement arrived on a session this console had already pinned, so it is applied, with a dismissable notice. Each discovered-peer row names the connection whose directory gossiped it, and the device the gossip claimed for that connection's address is labelled as a claim, since nothing on the connection authenticates it.
- **Activity and health**: the connection panel narrates the session as sentences (`src/activity.ts`) with the raw frame log behind a toggle. Round-trip time is probed without a button press and drawn as a sparkline, a dropped connection shows when the next reconnect attempt is due, and directory rows show when each peer advertised itself, by its own clock.
- **First run and search**: a device that has not dismissed it sees a short walkthrough (connect, who is here, why discovered peers wait for an explicit connect, what works offline), reopenable from the header. Search covers the messages and readable notices of every conversation (`src/search.ts`) and opens the conversation a result belongs to.

## How it maps onto core's ports

Expand Down
54 changes: 52 additions & 2 deletions ts/packages/web-console/src/App.css.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,58 @@
// The one piece of layout Mantine's own style props don't cover cleanly: capping the console's own content width and centring it on a wide viewport, so the peer directory and frame log tables don't stretch edge-to-edge on a desktop monitor.
// The layout Mantine's own style props don't cover cleanly: capping the console's own content width and centring it on a wide viewport, so the peer tables don't stretch edge-to-edge on a desktop monitor, and turning a peer table into a stack of labelled cards on a phone.

import { style } from "@vanilla-extract/css";
import { globalStyle, style } from "@vanilla-extract/css";

export const appShell = style({
maxWidth: "64rem",
marginInline: "auto",
// A long address or device-id with no break opportunity would otherwise widen the page past a phone's viewport.
overflowWrap: "anywhere",
});

/** Mantine's `xs` breakpoint (the same 36em vite.config.ts hands the PostCSS pipeline), below which a peer table no longer fits a row of columns. */
const PHONE_MEDIA = "screen and (max-width: 36em)";

/** A table whose rows become cards on a phone: the header row is hidden and each cell shows its column's name (its `data-label`) above its value. Use it through `StackedTable`, which also states each element's ARIA role, because changing `display` on table elements makes Safari drop their table semantics. */
export const stackedTable = style({});

globalStyle(`${stackedTable} thead`, {
"@media": {
[PHONE_MEDIA]: {
position: "absolute",
width: 1,
height: 1,
overflow: "hidden",
clip: "rect(0 0 0 0)",
},
},
});

globalStyle(`${stackedTable} tr`, {
"@media": {
[PHONE_MEDIA]: {
display: "block",
paddingBlock: "0.5rem",
},
},
});

globalStyle(`${stackedTable} td`, {
"@media": {
[PHONE_MEDIA]: {
display: "block",
border: "none",
paddingBlock: "0.125rem",
},
},
});

globalStyle(`${stackedTable} td::before`, {
"@media": {
[PHONE_MEDIA]: {
content: "attr(data-label)",
display: "block",
fontSize: "0.75rem",
opacity: 0.7,
},
},
});
30 changes: 29 additions & 1 deletion ts/packages/web-console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ import { useCertificateTrust } from "./hooks/use-certificate-trust.js";
import { PeerNamesProvider } from "./components/PeerNamesProvider.js";
import { PeerName } from "./components/PeerName.js";
import { SelfNameField } from "./components/SelfNameField.js";
import { OnboardingIntro } from "./components/OnboardingIntro.js";
import { SearchPanel } from "./components/SearchPanel.js";
import { useIntro } from "./hooks/use-intro.js";
import type { PreferencesStore } from "./preferences-store.js";
import type { NameStore } from "./name-store.js";
import { ConversationList } from "./components/ConversationList.js";
import { RoomPanel } from "./components/RoomPanel.js";
Expand All @@ -60,6 +64,8 @@ export interface AppProps {
certificateMemory: CertificateMemory;
/** The petnames this viewer has given peers and this console's own display name, kept in the console's own storage. */
nameStore: NameStore;
/** Interface preferences kept in the console's own storage, such as whether the first-run intro has been dismissed. */
preferences: PreferencesStore;
/** Attempts same-device node auto-discovery once, on mount. Defaults to the real `discoverLocalNode` (a no-op when this console is served from a loopback origin, a real localhost probe otherwise); tests inject a fake to avoid depending on `location`/`fetch`. */
discoverLocalNode?: () => Promise<string | undefined>;
// Seeds the Node field. Left as a prop (rather than App reading location/import.meta.env itself) so App stays the plain, testable component its own header comment describes; main.tsx computes the real value via default-hub-address.ts.
Expand Down Expand Up @@ -97,6 +103,7 @@ export function App({
messageStore,
certificateMemory,
nameStore,
preferences,
discoverLocalNode = discoverLocalNodeDefault,
defaultAddress = DEFAULT_ADDRESS,
}: Readonly<AppProps>): React.JSX.Element {
Expand All @@ -105,6 +112,7 @@ export function App({
const [connections, setConnections] = useState<ConnectionEntry[]>([]);
const [discovered, setDiscovered] = useState<PendingExpansion[]>([]);
const [failures, setFailures] = useState<ConnectionFailure[]>([]);
const intro = useIntro(preferences);
const trust = useCertificateTrust(certificateMemory, clock);
const roomMessaging = useRoomMessaging(identity, clock, messageStore);
const [selectedPath, setSelectedPath] = useState<string | undefined>();
Expand Down Expand Up @@ -387,7 +395,23 @@ export function App({
return (
<PeerNamesProvider store={nameStore}>
<Stack className={appShell} p="md" gap="lg">
<Title order={1}>wire-mesh console</Title>
<Group justify="space-between">
<Title order={1}>wire-mesh console</Title>
{intro.ready && !intro.visible && (
<Button size="xs" variant="subtle" onClick={intro.show}>
How this works
</Button>
)}
</Group>
{intro.error !== undefined && (
<Alert
color="red"
title="The first-run intro's setting is unavailable"
>
{intro.error}
</Alert>
)}
{intro.visible && <OnboardingIntro onDismiss={intro.dismiss} />}
<SelfNameField />
<form onSubmit={handleSubmit}>
<Group align="flex-end" wrap="wrap">
Expand Down Expand Up @@ -463,6 +487,10 @@ export function App({
{failure.reason}. Messages go through the hub instead.
</Alert>
))}
<SearchPanel
conversations={roomMessaging.conversations}
onSelect={setSelectedPath}
/>
<ConversationList
conversations={roomMessaging.conversations}
selected={selectedConversation?.roomPath}
Expand Down
87 changes: 39 additions & 48 deletions ts/packages/web-console/src/components/ConnectionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
// One open relay/hub connection's own UI: status line, ping/disconnect controls, peer directory, and frame log. All behaviour lives in wire-mesh-core's own mesh-session domain module; this component only renders whatever useMeshSessionEvents last reported and forwards clicks back onto the session.

import {
Alert,
Button,
Card,
Group,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { Alert, Button, Card, Group, Text, Tooltip } from "@mantine/core";
import type {
MeshSession,
ReconnectPolicy,
Expand All @@ -30,6 +22,8 @@ import type { NodeCertificateChange } from "../hooks/use-certificate-trust.js";
import { ActivityLog } from "./ActivityLog.js";
import { ConnectionHealth } from "./ConnectionHealth.js";
import { PeerLabel } from "./PeerLabel.js";
import { StackedTable } from "./StackedTable.js";
import type { StackedColumn } from "./StackedTable.js";

const HEX_RADIX = 16;
const MS_PER_SECOND = 1000;
Expand Down Expand Up @@ -91,6 +85,13 @@ export interface ConnectionPanelProps {
/** How often elapsed times and the reconnect countdown refresh. */
const CLOCK_TICK_MS = 1000;

const DIRECTORY_COLUMNS: readonly StackedColumn[] = [
{ label: "device" },
{ label: "addresses" },
{ label: "advertised" },
{ label: "actions", headerless: true },
];

export function ConnectionPanel({
address,
session,
Expand Down Expand Up @@ -197,45 +198,35 @@ export function ConnectionPanel({
No gossip received yet.
</Text>
) : (
<Table striped>
<Table.Thead>
<Table.Tr>
<Table.Th>device</Table.Th>
<Table.Th>addresses</Table.Th>
<Table.Th>advertised</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{directory.map((entry) => (
<Table.Tr key={deviceHex(entry.device)}>
<Table.Td>
<PeerLabel deviceHex={deviceHex(entry.device)} />
</Table.Td>
<Table.Td>{entry.advert.addresses.join(", ")}</Table.Td>
<Table.Td>
<Tooltip label="As the peer advertised it, by its own clock">
<span>
{formatAgo(
now - entry.advert["snapshot-seconds"] * MS_PER_SECOND,
)}
</span>
</Tooltip>
</Table.Td>
<Table.Td>
<Button
size="xs"
onClick={() => {
onMessagePeer(entry.device);
}}
>
Message
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
<StackedTable
columns={DIRECTORY_COLUMNS}
rows={directory.map((entry) => ({
key: deviceHex(entry.device),
cells: [
<PeerLabel key="device" deviceHex={deviceHex(entry.device)} />,
entry.advert.addresses.join(", "),
<Tooltip
key="advertised"
label="As the peer advertised it, by its own clock"
>
<span>
{formatAgo(
now - entry.advert["snapshot-seconds"] * MS_PER_SECOND,
)}
</span>
</Tooltip>,
<Button
key="actions"
size="xs"
onClick={() => {
onMessagePeer(entry.device);
}}
>
Message
</Button>,
],
}))}
/>
)}

<ActivityLog activity={activity} frameLog={frameLog} />
Expand Down
5 changes: 1 addition & 4 deletions ts/packages/web-console/src/components/ConversationList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { Badge, Group, NavLink, Stack, Text } from "@mantine/core";
import { participantLabel, type ConversationView } from "../conversations.js";
import { usePeerNames } from "../hooks/use-peer-names.js";
import { labelText } from "../peer-names.js";

export interface ConversationListProps {
conversations: readonly ConversationView[];
Expand All @@ -29,9 +28,7 @@ export function ConversationList({
<NavLink
key={conversation.roomPath}
active={conversation.roomPath === selected}
label={participantLabel(conversation, (hex) =>
labelText(names.labelOf(hex)),
)}
label={participantLabel(conversation, names)}
description={
conversation.status === "connected" ? undefined : "offline"
}
Expand Down
Loading
Loading