Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function call
created: 2026-10-15T04:30:36Z
norm: '1'
sig: 3b9d360bb018f164
body_hash: ed032412ce991b6b
raw_hash: 17ca2b8a23ffb09d
vouched: 2026-10-22T14:33:26Z
body_hash: fd35da8da75facb4
raw_hash: 366b10d619b52eba
vouched: 2026-10-22T20:38:32Z
lines: 97-125
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5JNCCJHPCRSGY5EFTASKNDP
anchor: function server
created: 2026-10-22T20:38:30Z
norm: '1'
sig: 128e3397d58bcc17
body_hash: 9d1be79086813df2
raw_hash: b33a79c8a848f2a2
lines: 240-243
---

a served directory's Mac is found by the address it publishes; a deployed directory matches none and keeps the generic error
17 changes: 7 additions & 10 deletions apps/app/src/directory-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { host } from "./host";
import { useDiagnostics } from "./host-settings";
import { parse } from "./invite";
import { CREATE_DIRECTORY, DIRECTORY_CHANGED, People, useRoster } from "./onboarding";
import { Confirm, Group, Page, Problem, Row, Status, Value } from "./settings-layout";
import { TEAM_SETUP } from "./team-setup";
import { Confirm, Group, Page, Problem, Row, Status, Value, when } from "./settings-layout";
import { DirectoryHosts, TEAM_SETUP } from "./team-setup";

const SETUP = "https://github.com/githubnext/ace/blob/main/docs/setup.md#team-directory";

Expand Down Expand Up @@ -107,6 +107,11 @@ export function TeamSettings({ active }: { active: boolean }) {
</>
)}
</Group>
{checked?.directory.url && (
<Group title="In the directory">
<DirectoryHosts directory={checked.directory} self={checked.tailscale.name} />
</Group>
)}
{checks.open && <TeamPeople />}
{checked?.directory.url && checked.directory.synced && !served(checked.directory) && (
<TeamServices services={checked.directory.services} />
Expand Down Expand Up @@ -194,14 +199,6 @@ function shared(shares: Share[]): string {
return [...projects].map(([name, people]) => `${name} with ${people.join(", ")}`).join("; ");
}

/** A time today, or a date and time when older, so a stale sync doesn't read as recent. */
function when(time: number) {
const date = new Date(time);
return date.toDateString() === new Date().toDateString()
? date.toLocaleTimeString()
: date.toLocaleString();
}

/** `row` keeps each result beside the control that caused it. */
type Action = { row?: "directory" | "secret"; busy?: string; error?: string; message?: string };

Expand Down
8 changes: 8 additions & 0 deletions apps/app/src/settings-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ export function Confirm({ children, action, busy, onConfirm, onCancel }: {
);
}

/** A time today, or a date and time when older, so a stale sync doesn't read as recent. */
export function when(time: number) {
const date = new Date(time);
return date.toDateString() === new Date().toDateString()
? date.toLocaleTimeString()
: date.toLocaleString();
}

export function Status({ children }: { children: ReactNode }) {
return <p role="status" className="text-xs/relaxed text-muted-foreground">{children}</p>;
}
Expand Down
67 changes: 63 additions & 4 deletions apps/app/src/team-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import {
toast,
} from "@ace/ui";
import { IconArrowUpRight, IconCheck, IconCopy, IconLoader, IconUsers } from "@ace/ui/icons";
import { TEAM_ASK_EXPIRES, type TeamPeer } from "@ace/host/protocol";
import { type Diagnostics, TEAM_ASK_EXPIRES, type TeamPeer } from "@ace/host/protocol";

import { host } from "./host";
import { useDiagnostics } from "./host-settings";
import { type Invite as Team, link, page, parse } from "./invite";
import { Row, Status, Value, when } from "./settings-layout";

const DOCS = "https://github.com/githubnext/ace/blob/main/docs/setup.md";
export const TEAM_SETUP = "ace:team-setup";
Expand Down Expand Up @@ -68,6 +69,11 @@ export function TeamSetup({ github, invite, inviting, onClose }: {
const [provider, setProvider] = useState<Provider>("cloudflare");
const [joined, setJoined] = useState<Team>();
const [error, setError] = useState("");
const checks = useDiagnostics(step === "fork");
const checked = checks.value;
const directory = checked?.directory.url ? checked.directory : undefined;
// Until the host answers, the fork can't tell a first person from one already in a team.
const pending = checks.open && !checked && !checks.error;

useEffect(() => {
if (!inviting) return;
Expand All @@ -81,7 +87,31 @@ export function TeamSetup({ github, invite, inviting, onClose }: {
<Dialog open onOpenChange={next => next || onClose()}>
<DialogContent className="gap-4 sm:max-w-[34rem]">
{STEPS[step] && !inviting && <Steps current={step} />}
{step === "fork" && (
{step === "fork" && directory && (
<>
<Checks github={github} />
<Header
title="Your team"
description={directory.serving
? "This Mac serves your team's directory. Teammates on the tailnet join it on their own."
: `This Mac is in your team's directory at ${new URL(directory.url!).host}.`}
/>
<div className="flex flex-col divide-y divide-border/60 rounded-md border border-border/70 px-3">
<DirectoryHosts directory={directory} self={checked?.tailscale.name} />
</div>
<Nearby onJoined={onClose} team={new URL(directory.url!).host} />
<Footer
back={
<Button variant="ghost" onClick={() => setStep("directory")}>
List channels elsewhere…
</Button>
}
next={<Button onClick={onClose}>Done</Button>}
/>
</>
)}
{step === "fork" && pending && <Checks github={github} />}
{step === "fork" && !pending && !directory && (
<>
<Checks github={github} />
<Header
Expand Down Expand Up @@ -433,8 +463,9 @@ function useTeamPeers(): TeamPeer[] {
const CLOCK = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });

/** Ask a teammate's host to let this one in; their owner approves in their Ace. */
function Nearby({ onJoined }: { onJoined: () => void }) {
const peers = useTeamPeers();
function Nearby({ onJoined, team }: { onJoined: () => void; team?: string }) {
// A teammate already in this host's directory has no other team to let it into.
const peers = useTeamPeers().filter((peer) => peer.team !== team);
// By host: the peer list is read again every few seconds, which replaces its objects.
const [asking, setAsking] = useState<{ host: string; who: string; lapses: number }>();
const [error, setError] = useState("");
Expand Down Expand Up @@ -722,3 +753,31 @@ export function Copyable(
</div>
);
}

/** Hosts that have joined the directory, and when each last published its channels there. */
export function DirectoryHosts(
{ directory, self }: { directory: Diagnostics["directory"]; self?: string },
) {
if (!directory.hosts?.length) {
return (
<div className="py-3">
<Status>
{directory.synced ? "No hosts have joined yet." : "Waiting for the directory…"}
</Status>
</div>
);
}
return (
<>
{directory.hosts?.map((host) => (
<Row
key={host.name}
label={host.name === self ? `${host.name} (this Mac)` : host.name}
description={host.github || host.login}
>
<Value>Synced {when(host.seen)}</Value>
</Row>
))}
</>
);
}
3 changes: 2 additions & 1 deletion apps/host/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export async function diagnostics(sync?: boolean): Promise<Diagnostics> {
Promise.all([git(), shell()]),
tailnet.diagnostics(started),
]);
const { hosted, app, compute } = directory.read();
const { hosted, app, compute, hosts } = directory.read();
return {
tools,
tailscale: config.loopback ? { ...tailscale, loopback: true } : tailscale,
Expand All @@ -62,6 +62,7 @@ export async function diagnostics(sync?: boolean): Promise<Diagnostics> {
services: { hosted, app, compute },
serving: served.isServing(),
shares: served.shares(),
hosts: hosts.map(({ address: _, ...host }) => host),
},
shell: { ...environment },
web: web.address,
Expand Down
23 changes: 22 additions & 1 deletion apps/host/src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,16 +211,37 @@ async function call(
});
} catch (cause) {
if (signal.aborted) throw cause;
throw new Error(`Could not reach ${base}. Check the URL and your connection.`, { cause });
const host = server(base);
throw new Error(
host
? `${host.name}, which serves the directory, is offline. Channels stay listed as they were.`
: `Could not reach ${base}. Check the URL and your connection.`,
{ cause },
);
}
if (response.status === 401) throw new Error(await rejection(response, "The directory"));
if (!response.ok) {
const host = server(base);
if (host && response.status === 404) {
throw new Error(
`${host.name} stopped serving the directory. Channels stay listed as they were.`,
);
}
const reason = await refusal(response, "The directory");
throw new Error(response.status < 500 ? `${reason} Check the URL.` : reason);
}
return response;
}

/**
* The teammate's host serving a tailnet directory, found by the address it publishes, so a failure
* names their Mac rather than blaming this one's connection. A deployed directory has none.
*/
function server(base: string): Host | undefined {
const address = new URL(base).hostname.replace(/^\[|\]$/g, "");
return known.hosts.find((host) => host.address === address);
}

type Options = {
self(): Omit<Host, "seen">;
channels(): Promise<Listing[]>;
Expand Down
5 changes: 5 additions & 0 deletions apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export type Diagnostics = {
serving: boolean;
/** The project shares in the directory this host serves, which stopping it deletes. */
shares: Share[];
/** Hosts in the directory's last answer, and when each last published to it; older hosts omit it. */
hosts?: DirectoryHost[];
};
/** Whether the helper loaded the login shell's environment; `pending` while it retries. */
shell: { state: "adopted" | "pending" | "failed"; error?: string };
Expand All @@ -77,6 +79,9 @@ export type Diagnostics = {
http?: string;
};

/** A host in the team's directory; `seen` is when it last published there. */
export type DirectoryHost = { name: string; login: string; github?: string; seen: number };

/** `offline`: the channel's host is unreachable. Hosted channels are never offline. */
export type ChannelState = "running" | "dormant" | "archived" | "offline";

Expand Down
4 changes: 3 additions & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ Create it in the app: the sidebar's Team directory card, an offline channel, or
offers **Create directory**. **This Mac** serves it from this host on the tailnet, with no account
or secret; a Mac that stays on (a Mac mini) is the best place for it. Every other host on the
tailnet finds it in this host's hello and joins it on its own, unless it left a directory before.
**Stop serving…** in `Settings → Team` turns it off and deletes its shares.
**Stop serving…** in `Settings → Team` turns it off and deletes its shares. `Settings → Team` and
**Team setup** list the hosts in the directory and when each last synced; when the Mac serving it is
offline or stops serving, teammates' sync errors name that Mac.

### Deployed directory

Expand Down