rebrand to OffScale, and fix what the first extraction missed
Offscale was the first plugin extracted and it was done before we knew what "extracted" meant. Music, done last, is the standard. This brings offscale to it. ── The rebrand ── The plugin was `offscale` to the platform and `headscale` to itself: sidecar name and handles, the port announcement, the API proxy name, the React components, every hook, the react-query keys, the panel ids and appTypes, and the Postgres table. Now all of those say offscale. The line drawn, and it is deliberate: OffScale is Officer's tooling layer, and Headscale is the server it manages. So every IDENTIFIER is offscale, while a message like `headscale unreachable`, the `headscale apikeys create` hint and the ACL assistant's prompt still say Headscale — because they are talking about the remote server, and renaming them would make the code lie about what it reached. 495 occurrences became 180, and the 180 are all of that second kind. ── The live bug this uncovered ── `headscaleSectionPath` built links to `/headscale/<section>`. The shell has no such route — plugin routes come from `plugin.route`, which is `/offscale` — and it redirects unknown paths to the home page. So every section link in the nav, the console and the server picker silently went home. The extraction moved the route and left the link builder behind. Also live: ServersView told the user to run `pm2 start ecosystem.config.cjs --only officer-headscale`, a process that has not existed since the sidecar was renamed. ── The correctness fix music already had ── api/router.ts hardcoded `prefix: '/api/offscale'`. The proxy strips `prefix.length` characters, so a literal is correct only for a first-party publisher; published by anyone else this mounts at `/api/p/<publisher>/offscale` and forwards the wrong subpath. Derived from `mountPrefix()` now, as music does. ── The rest ── - assets/icon.png — the OffScale artwork, 256px to match music's. The tile stops being a glyph badge. - First tests: 21 of them, over the version floor and the protobuf normalisers. Those are the two places a Headscale release actually breaks this, and they had no coverage at all. `meetsFloor` has a real trap pinned now — comparing minor first would refuse 1.0 as older than 0.29. - OFFSCALE_API.md — the contract was a 45-line comment inside sidecar/index.ts, which is not linkable and not published. Now a document, as MUSIC_API.md is. - web/panels.ts re-exported three components. A plugin cannot export components; that was residue of the platform importing them before extraction. - Comments pointed at src/servers/api/headscale/ and src/servers/sidecar/headscale/, neither of which has existed since the extraction. The crypto purpose moved headscale → offscale too, and the secret-store row was renamed rather than left to create a fresh key — the material is preserved, so this is reversible. Free to do only because offscale_servers had 0 rows; with one stored API key it would have been a migration.
This commit is contained in:
+4
-4
@@ -1,8 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Loader2, TerminalSquare } from 'lucide-react';
|
||||
import { headscaleSectionPath } from './shared';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
import { offscaleSectionPath } from './shared';
|
||||
import { useOffscaleServers } from './useOffscaleServers';
|
||||
import { TerminalView } from 'officerdev';
|
||||
import { Button } from './Cards';
|
||||
|
||||
@@ -31,7 +31,7 @@ const Centred = ({ children }: { children: React.ReactNode }) => (
|
||||
);
|
||||
|
||||
export const ConsoleView = () => {
|
||||
const { active, isLoading } = useHeadscaleServers();
|
||||
const { active, isLoading } = useOffscaleServers();
|
||||
|
||||
// The terminal reports its connection state; nothing in this section acts on it yet, but TerminalView wants
|
||||
// a stable callback and an inline arrow would remount its effect on every render.
|
||||
@@ -67,7 +67,7 @@ export const ConsoleView = () => {
|
||||
the Headscale hostname — the console is most useful exactly when that name has stopped answering.
|
||||
</p>
|
||||
</div>
|
||||
<Link to={headscaleSectionPath('servers')}>
|
||||
<Link to={offscaleSectionPath('servers')}>
|
||||
<Button variant="primary">Go to Servers</Button>
|
||||
</Link>
|
||||
</Centred>
|
||||
|
||||
@@ -13,13 +13,13 @@ import {
|
||||
} from 'lucide-react';
|
||||
import type { CompanionAction, CompanionContainer, CompanionHealthBody, CompanionVerdict } from './shared';
|
||||
import { timeAgo } from './format';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
import { useOffscaleServers } from './useOffscaleServers';
|
||||
import {
|
||||
useCompanionAction,
|
||||
useCompanionHealth,
|
||||
useCompanionLogStream,
|
||||
useCompanionLogs,
|
||||
} from './useHeadscaleCompanion';
|
||||
} from './useOffscaleCompanion';
|
||||
import { Card, SectionHeader, Button, Dot, ErrorNote } from './Cards';
|
||||
import { ViewShell } from './ViewShell';
|
||||
|
||||
@@ -261,7 +261,7 @@ const Lifecycle = ({ running }: { running: boolean | null }) => {
|
||||
};
|
||||
|
||||
export const DiagnosticsView = () => {
|
||||
const { active } = useHeadscaleServers();
|
||||
const { active } = useOffscaleServers();
|
||||
const query = useCompanionHealth();
|
||||
const result = query.data;
|
||||
|
||||
|
||||
+14
-14
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react';
|
||||
import type { HeadscaleInvite, HeadscaleInviteCreated, InviteStatus } from './shared';
|
||||
import type { OffscaleInvite, OffscaleInviteCreated, InviteStatus } from './shared';
|
||||
import { INVITE_TTL_DEFAULT_SECONDS } from './shared';
|
||||
import { useHeadscaleInvites } from './useHeadscaleInvites';
|
||||
import { useHeadscaleUsers } from './useHeadscaleData';
|
||||
import { headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import { useOffscaleInvites } from './useOffscaleInvites';
|
||||
import { useOffscaleUsers } from './useOffscaleData';
|
||||
import { offscaleErrorMessage } from './useOffscaleServers';
|
||||
import { fullDate, timeAgo, timeUntil } from './format';
|
||||
import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards';
|
||||
import { EmptyBody, ViewShell } from './ViewShell';
|
||||
@@ -76,7 +76,7 @@ const InviteQr = ({ url }: { url: string }) => {
|
||||
return <canvas ref={canvas} className="h-[220px] w-[220px] rounded-lg bg-white p-1" />;
|
||||
};
|
||||
|
||||
type InviteLinkPanelProps = { invite: HeadscaleInviteCreated; onDismiss: () => void };
|
||||
type InviteLinkPanelProps = { invite: OffscaleInviteCreated; onDismiss: () => void };
|
||||
|
||||
const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => {
|
||||
const [showQr, setShowQr] = useState(true);
|
||||
@@ -138,11 +138,11 @@ const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
type CreateInviteFormProps = { onCreated: (invite: HeadscaleInviteCreated) => void; onClose: () => void };
|
||||
type CreateInviteFormProps = { onCreated: (invite: OffscaleInviteCreated) => void; onClose: () => void };
|
||||
|
||||
const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
|
||||
const { users } = useHeadscaleUsers();
|
||||
const { create } = useHeadscaleInvites();
|
||||
const { users } = useOffscaleUsers();
|
||||
const { create } = useOffscaleInvites();
|
||||
const [user, setUser] = useState('');
|
||||
const [note, setNote] = useState('');
|
||||
const [ttl, setTtl] = useState(INVITE_TTL_DEFAULT_SECONDS);
|
||||
@@ -171,7 +171,7 @@ const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
|
||||
onCreated(invite);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(headscaleErrorMessage(err));
|
||||
setError(offscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -266,17 +266,17 @@ const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
type InviteRowProps = { invite: HeadscaleInvite; onError: (message: string) => void };
|
||||
type InviteRowProps = { invite: OffscaleInvite; onError: (message: string) => void };
|
||||
|
||||
const InviteRow = ({ invite, onError }: InviteRowProps) => {
|
||||
const { revoke } = useHeadscaleInvites();
|
||||
const { revoke } = useOffscaleInvites();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
await revoke.mutateAsync(invite.id);
|
||||
} catch (err) {
|
||||
onError(headscaleErrorMessage(err));
|
||||
onError(offscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -338,9 +338,9 @@ const InviteRow = ({ invite, onError }: InviteRowProps) => {
|
||||
};
|
||||
|
||||
export const InvitesView = () => {
|
||||
const { invites, unavailable, isLoading, error } = useHeadscaleInvites();
|
||||
const { invites, unavailable, isLoading, error } = useOffscaleInvites();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [created, setCreated] = useState<HeadscaleInviteCreated | null>(null);
|
||||
const [created, setCreated] = useState<OffscaleInviteCreated | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const pending = invites.filter((i) => i.status === 'pending').length;
|
||||
|
||||
+11
-11
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react';
|
||||
import type { HeadscalePreAuthKey } from './shared';
|
||||
import { useHeadscaleKeys, useHeadscaleUsers } from './useHeadscaleData';
|
||||
import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import type { OffscalePreAuthKey } from './shared';
|
||||
import { useOffscaleKeys, useOffscaleUsers } from './useOffscaleData';
|
||||
import { useOffscaleServers, offscaleErrorMessage } from './useOffscaleServers';
|
||||
import { timeAgo, timeUntil, fullDate } from './format';
|
||||
import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards';
|
||||
import { ViewShell, EmptyBody } from './ViewShell';
|
||||
@@ -102,8 +102,8 @@ const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => (
|
||||
type CreateKeyFormProps = { onCreated: (secret: string) => void; onClose: () => void };
|
||||
|
||||
const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
|
||||
const { users } = useHeadscaleUsers();
|
||||
const { create } = useHeadscaleKeys();
|
||||
const { users } = useOffscaleUsers();
|
||||
const { create } = useOffscaleKeys();
|
||||
const [userId, setUserId] = useState('');
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
@@ -133,7 +133,7 @@ const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
|
||||
if (result.key.key) onCreated(result.key.key);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(headscaleErrorMessage(err));
|
||||
setError(offscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,10 +203,10 @@ const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
type KeyRowProps = { entry: HeadscalePreAuthKey; onError: (message: string) => void };
|
||||
type KeyRowProps = { entry: OffscalePreAuthKey; onError: (message: string) => void };
|
||||
|
||||
const KeyRow = ({ entry, onError }: KeyRowProps) => {
|
||||
const { expire, remove } = useHeadscaleKeys();
|
||||
const { expire, remove } = useOffscaleKeys();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const busy = expire.isPending || remove.isPending;
|
||||
|
||||
@@ -214,7 +214,7 @@ const KeyRow = ({ entry, onError }: KeyRowProps) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
onError(headscaleErrorMessage(err));
|
||||
onError(offscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -271,8 +271,8 @@ const KeyRow = ({ entry, onError }: KeyRowProps) => {
|
||||
};
|
||||
|
||||
export const KeysView = () => {
|
||||
const { keys, isLoading, error } = useHeadscaleKeys();
|
||||
const { active } = useHeadscaleServers();
|
||||
const { keys, isLoading, error } = useOffscaleKeys();
|
||||
const { active } = useOffscaleServers();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState<StatusFilter>('active');
|
||||
|
||||
+11
-11
@@ -14,9 +14,9 @@ import {
|
||||
ArrowRightLeft,
|
||||
Tag as TagIcon,
|
||||
} from 'lucide-react';
|
||||
import type { HeadscaleNode } from './shared';
|
||||
import { useHeadscaleNodes, useHeadscaleUsers } from './useHeadscaleData';
|
||||
import { headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import type { OffscaleNode } from './shared';
|
||||
import { useOffscaleNodes, useOffscaleUsers } from './useOffscaleData';
|
||||
import { offscaleErrorMessage } from './useOffscaleServers';
|
||||
import { timeAgo, timeUntil, fullDate } from './format';
|
||||
import { Card, Button, Dot, Badge, ErrorNote } from './Cards';
|
||||
import { ViewShell, EmptyBody } from './ViewShell';
|
||||
@@ -72,7 +72,7 @@ const parseTags = (text: string): string[] => {
|
||||
/** Set comparison, not sequence: Headscale is free to store the tags in an order the owner didn't type. */
|
||||
const sameTags = (a: string[], b: string[]) => a.length === b.length && a.every((t) => b.includes(t));
|
||||
|
||||
type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: string) => void };
|
||||
type OwnershipProps = { node: OffscaleNode; busy: boolean; onError: (message: string) => void };
|
||||
|
||||
/**
|
||||
* Owner and tags — the two things that decide which ACL rules apply to a node, which is why they sit
|
||||
@@ -83,8 +83,8 @@ type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: s
|
||||
* shared with the Users section, so an expanded card is usually a cache hit anyway.
|
||||
*/
|
||||
const Ownership = ({ node, busy, onError }: OwnershipProps) => {
|
||||
const { setTags, moveToUser } = useHeadscaleNodes();
|
||||
const { users } = useHeadscaleUsers();
|
||||
const { setTags, moveToUser } = useOffscaleNodes();
|
||||
const { users } = useOffscaleUsers();
|
||||
|
||||
const [owner, setOwner] = useState(node.user?.id ?? '');
|
||||
const [draftTags, setDraftTags] = useState(node.tags.join(' '));
|
||||
@@ -99,7 +99,7 @@ const Ownership = ({ node, busy, onError }: OwnershipProps) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
onError(headscaleErrorMessage(err));
|
||||
onError(offscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -185,10 +185,10 @@ const Ownership = ({ node, busy, onError }: OwnershipProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void };
|
||||
type NodeCardProps = { node: OffscaleNode; onError: (message: string) => void };
|
||||
|
||||
const NodeCard = ({ node, onError }: NodeCardProps) => {
|
||||
const { rename, toggleRoute, expire, remove } = useHeadscaleNodes();
|
||||
const { rename, toggleRoute, expire, remove } = useOffscaleNodes();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [draftName, setDraftName] = useState(node.name);
|
||||
@@ -200,7 +200,7 @@ const NodeCard = ({ node, onError }: NodeCardProps) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
onError(headscaleErrorMessage(err));
|
||||
onError(offscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -373,7 +373,7 @@ const NodeCard = ({ node, onError }: NodeCardProps) => {
|
||||
};
|
||||
|
||||
export const NodesView = () => {
|
||||
const { nodes, isLoading, error } = useHeadscaleNodes();
|
||||
const { nodes, isLoading, error } = useOffscaleNodes();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { NavLink } from 'react-router';
|
||||
import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, TerminalSquare } from 'lucide-react';
|
||||
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
import { OFFSCALE_SECTIONS, offscaleSectionPath, type OffscaleSectionId } from './shared';
|
||||
import { useOffscaleServers } from './useOffscaleServers';
|
||||
|
||||
// Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel
|
||||
// above (HeadscaleServerPicker) — that one mutates, this one navigates, which is why they are separate.
|
||||
// above (OffscaleServerPicker) — that one mutates, this one navigates, which is why they are separate.
|
||||
//
|
||||
// Sections are real links to /headscale/<section>, not channel writes — so they cmd-click into a new tab,
|
||||
// survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than
|
||||
// being derived in JS, per the navigation audit's Phase 4.
|
||||
|
||||
const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
|
||||
const ICONS: Record<OffscaleSectionId, LucideIcon> = {
|
||||
servers: Server,
|
||||
nodes: Laptop,
|
||||
users: Users,
|
||||
@@ -36,13 +36,13 @@ const SectionBody = ({ icon: Icon, label, selected }: SectionBodyProps) => (
|
||||
</>
|
||||
);
|
||||
|
||||
export const HeadscaleNav = () => {
|
||||
const { active } = useHeadscaleServers();
|
||||
export const OffscaleNav = () => {
|
||||
const { active } = useOffscaleServers();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
<nav className="flex flex-col gap-0.5 px-2 py-3">
|
||||
{HEADSCALE_SECTIONS.map(({ id, label }) => {
|
||||
{OFFSCALE_SECTIONS.map(({ id, label }) => {
|
||||
// Without an active server the domain sections have nothing to act on, so they are rendered as
|
||||
// plain text rather than as anchors — a disabled <a> is not a thing, and a link that goes nowhere
|
||||
// useful is worse than no link.
|
||||
@@ -56,7 +56,7 @@ export const HeadscaleNav = () => {
|
||||
return (
|
||||
<NavLink
|
||||
key={id}
|
||||
to={headscaleSectionPath(id)}
|
||||
to={offscaleSectionPath(id)}
|
||||
className={({ isActive }) =>
|
||||
`${ROW} ${
|
||||
isActive
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Check, Network, Plus } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { headscaleSectionPath } from './shared';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
import { offscaleSectionPath } from './shared';
|
||||
import { useOffscaleServers } from './useOffscaleServers';
|
||||
|
||||
// Top-left panel of the /headscale workspace: which server everything else acts on.
|
||||
//
|
||||
// It is its own panel rather than a block inside HeadscaleNav because the two answer different questions —
|
||||
// It is its own panel rather than a block inside OffscaleNav because the two answer different questions —
|
||||
// "which server" and "which section" — and only one of them is navigation. Activating a server is a mutation
|
||||
// (a DB write that re-scopes every other query), so these stay buttons with no URL of their own, while the
|
||||
// section list below is real links.
|
||||
@@ -13,8 +13,8 @@ import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
// Every registered server is listed, including when there is only one: the panel's whole job is to say what
|
||||
// the rest of the screen is talking to, and a picker that hides itself at one server makes that invisible.
|
||||
|
||||
export const HeadscaleServerPicker = () => {
|
||||
const { servers, active, activate, isLoading } = useHeadscaleServers();
|
||||
export const OffscaleServerPicker = () => {
|
||||
const { servers, active, activate, isLoading } = useOffscaleServers();
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-muted/30">
|
||||
@@ -47,7 +47,7 @@ export const HeadscaleServerPicker = () => {
|
||||
|
||||
{servers.length === 0 && !isLoading && (
|
||||
<Link
|
||||
to={headscaleSectionPath('servers')}
|
||||
to={offscaleSectionPath('servers')}
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0" />
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useHeadscaleSection } from './useHeadscaleSection';
|
||||
import { useOffscaleSection } from './useOffscaleSection';
|
||||
import { ServersView } from './ServersView';
|
||||
import { NodesView } from './NodesView';
|
||||
import { UsersView } from './UsersView';
|
||||
@@ -13,8 +13,8 @@ import { ConsoleView } from './ConsoleView';
|
||||
// Every section except `servers` acts on whichever server is active; each handles the "none selected" case
|
||||
// itself through ViewShell, so there is no gating to do here.
|
||||
|
||||
export const HeadscaleView = () => {
|
||||
const section = useHeadscaleSection();
|
||||
export const OffscaleView = () => {
|
||||
const section = useOffscaleSection();
|
||||
|
||||
switch (section) {
|
||||
case 'nodes':
|
||||
@@ -1,15 +1,15 @@
|
||||
import { Network } from 'lucide-react';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
import { HEADSCALE_SECTIONS } from './shared';
|
||||
import { useHeadscaleSection } from './useHeadscaleSection';
|
||||
import { useOffscaleServers } from './useOffscaleServers';
|
||||
import { OFFSCALE_SECTIONS } from './shared';
|
||||
import { useOffscaleSection } from './useOffscaleSection';
|
||||
|
||||
// Panel header for the right (headscale-view) panel. Shows the section and, crucially, which server it is
|
||||
// acting on — with several registered, "delete this node" is only safe if the target is unambiguous.
|
||||
|
||||
export const HeadscaleViewHeader = () => {
|
||||
const section = useHeadscaleSection();
|
||||
const { active } = useHeadscaleServers();
|
||||
const label = HEADSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
|
||||
export const OffscaleViewHeader = () => {
|
||||
const section = useOffscaleSection();
|
||||
const { active } = useOffscaleServers();
|
||||
const label = OFFSCALE_SECTIONS.find((s) => s.id === section)?.label ?? 'Headscale';
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check, Loader2, Sparkles, Wand2, X } from 'lucide-react';
|
||||
import { useHeadscalePolicyAssist, assistFailure } from './useHeadscalePolicy';
|
||||
import { useOffscalePolicyAssist, assistFailure } from './useOffscalePolicy';
|
||||
import { collapseUnchanged, diffCounts, diffLines } from './diff';
|
||||
import { Button, Card, ErrorNote } from './Cards';
|
||||
|
||||
@@ -71,7 +71,7 @@ type PolicyAssistantProps = {
|
||||
};
|
||||
|
||||
export const PolicyAssistant = ({ policy, onApply, disabled }: PolicyAssistantProps) => {
|
||||
const assist = useHeadscalePolicyAssist();
|
||||
const assist = useOffscalePolicyAssist();
|
||||
const [prompt, setPrompt] = useState('');
|
||||
// The proposal lives here, not in the mutation's `data`, so that discarding it is a real state change and
|
||||
// a second ask doesn't briefly show the previous answer against the new base.
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AlertTriangle, FileLock2, Loader2, Pencil, RotateCcw, Save, ShieldCheck } from 'lucide-react';
|
||||
import { timeAgo } from './format';
|
||||
import { useHeadscalePolicy, policySaveFailure, type PolicySaveFailure } from './useHeadscalePolicy';
|
||||
import { useOffscalePolicy, policySaveFailure, type PolicySaveFailure } from './useOffscalePolicy';
|
||||
import { PolicyAssistant } from './PolicyAssistant';
|
||||
import { Card, SectionHeader, Button, ErrorNote } from './Cards';
|
||||
import { ViewShell } from './ViewShell';
|
||||
@@ -71,7 +71,7 @@ const Rejected = ({ message }: { message: string }) => (
|
||||
);
|
||||
|
||||
export const PolicyView = () => {
|
||||
const { policy, isLoading, error, save } = useHeadscalePolicy();
|
||||
const { policy, isLoading, error, save } = useOffscalePolicy();
|
||||
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
+10
-10
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Loader2, Terminal, Check, X } from 'lucide-react';
|
||||
import type { HeadscaleServer, HeadscaleSshTest } from './shared';
|
||||
import { MIN_HEADSCALE_VERSION } from './shared';
|
||||
import { useHeadscaleServers, useHeadscaleSshTest, headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import type { OffscaleServer, OffscaleSshTest } from './shared';
|
||||
import { MIN_OFFSCALE_VERSION } from './shared';
|
||||
import { useOffscaleServers, useOffscaleSshTest, offscaleErrorMessage } from './useOffscaleServers';
|
||||
import { Card, Button, Field, ErrorNote } from './Cards';
|
||||
|
||||
// Register / edit one Headscale server. The sidecar validates before it saves — reachable, >= 0.29, and the
|
||||
@@ -28,11 +28,11 @@ function urlHost(url: string): string | null {
|
||||
/** Strip any `user@` so `root@1.2.3.4` still matches the control-server hostname. */
|
||||
const sshTarget = (host: string) => host.trim().split('@').pop()?.toLowerCase() ?? '';
|
||||
|
||||
type ServerFormProps = { server?: HeadscaleServer | null; onClose: () => void };
|
||||
type ServerFormProps = { server?: OffscaleServer | null; onClose: () => void };
|
||||
|
||||
export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
||||
const { register, update } = useHeadscaleServers();
|
||||
const sshTest = useHeadscaleSshTest();
|
||||
const { register, update } = useOffscaleServers();
|
||||
const sshTest = useOffscaleSshTest();
|
||||
const editing = !!server;
|
||||
|
||||
const [name, setName] = useState(server?.name ?? '');
|
||||
@@ -40,7 +40,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [sshHost, setSshHost] = useState(server?.sshHost ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sshResult, setSshResult] = useState<HeadscaleSshTest | null>(null);
|
||||
const [sshResult, setSshResult] = useState<OffscaleSshTest | null>(null);
|
||||
|
||||
const mutation = editing ? update : register;
|
||||
const pending = mutation.isPending;
|
||||
@@ -64,7 +64,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
||||
try {
|
||||
setSshResult(await sshTest.mutateAsync(sshHost.trim()));
|
||||
} catch (err) {
|
||||
setSshResult({ ok: false, error: headscaleErrorMessage(err), ms: 0 });
|
||||
setSshResult({ ok: false, error: offscaleErrorMessage(err), ms: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(headscaleErrorMessage(err));
|
||||
setError(offscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -119,7 +119,7 @@ export const ServerForm = ({ server, onClose }: ServerFormProps) => {
|
||||
value={url}
|
||||
onChange={edit(setUrl)}
|
||||
placeholder="https://headscale.example.com"
|
||||
hint={`The control server's base URL. Officer requires Headscale ${MIN_HEADSCALE_VERSION} or newer.`}
|
||||
hint={`The control server's base URL. Officer requires Headscale ${MIN_OFFSCALE_VERSION} or newer.`}
|
||||
autoFocus={!editing}
|
||||
/>
|
||||
<Field
|
||||
|
||||
+15
-15
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Loader2, Server, Check, Activity, Pencil, Trash2 } from 'lucide-react';
|
||||
import type { HeadscaleServer, HeadscaleHealth } from './shared';
|
||||
import { MIN_HEADSCALE_VERSION } from './shared';
|
||||
import { useHeadscaleServers, useHeadscaleHealth, headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import type { OffscaleServer, OffscaleHealth } from './shared';
|
||||
import { MIN_OFFSCALE_VERSION } from './shared';
|
||||
import { useOffscaleServers, useOffscaleHealth, offscaleErrorMessage } from './useOffscaleServers';
|
||||
import { Card, SectionHeader, Button, Dot, Badge, ErrorNote } from './Cards';
|
||||
import { ServerForm } from './ServerForm';
|
||||
|
||||
@@ -27,8 +27,8 @@ function timeAgo(iso: string): string {
|
||||
}
|
||||
|
||||
type ServerRowProps = {
|
||||
server: HeadscaleServer;
|
||||
health: HeadscaleHealth | undefined;
|
||||
server: OffscaleServer;
|
||||
health: OffscaleHealth | undefined;
|
||||
testing: boolean;
|
||||
busy: boolean;
|
||||
onActivate: () => void;
|
||||
@@ -70,7 +70,7 @@ const ServerRow = ({ server, health, testing, busy, onActivate, onTest, onEdit,
|
||||
{health?.ok && health.supported === 'unknown' && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-[11px] leading-snug text-amber-300">
|
||||
This server reports its version as “{health.version}”, so Officer cannot confirm it is{' '}
|
||||
{MIN_HEADSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older
|
||||
{MIN_OFFSCALE_VERSION} or newer. Self-built images do this; features may behave unexpectedly on older
|
||||
builds.
|
||||
</div>
|
||||
)}
|
||||
@@ -121,7 +121,7 @@ const EmptyState = ({ onRegister }: { onRegister: () => void }) => (
|
||||
<div className="text-base font-semibold text-zinc-100">No Headscale servers yet</div>
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Register a server with its URL and an API key to manage its nodes, users and pre-auth keys from here. Officer
|
||||
supports Headscale {MIN_HEADSCALE_VERSION} and newer.
|
||||
supports Headscale {MIN_OFFSCALE_VERSION} and newer.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={onRegister}>
|
||||
@@ -132,11 +132,11 @@ const EmptyState = ({ onRegister }: { onRegister: () => void }) => (
|
||||
);
|
||||
|
||||
export const ServersView = () => {
|
||||
const { servers, isLoading, error, refetch, activate, remove } = useHeadscaleServers();
|
||||
const healthProbe = useHeadscaleHealth();
|
||||
const { servers, isLoading, error, refetch, activate, remove } = useOffscaleServers();
|
||||
const healthProbe = useOffscaleHealth();
|
||||
|
||||
const [formFor, setFormFor] = useState<'new' | HeadscaleServer | null>(null);
|
||||
const [health, setHealth] = useState<Record<number, HeadscaleHealth>>({});
|
||||
const [formFor, setFormFor] = useState<'new' | OffscaleServer | null>(null);
|
||||
const [health, setHealth] = useState<Record<number, OffscaleHealth>>({});
|
||||
// Several probes are in flight at once now, so this is a set of ids rather than the one id it used to be.
|
||||
const [testingIds, setTestingIds] = useState<readonly number[]>([]);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
@@ -150,7 +150,7 @@ export const ServersView = () => {
|
||||
} catch (err) {
|
||||
// A probe that throws is still an answer about the server: record it as a red dot rather than as a
|
||||
// page-level error, which would blame the whole screen for one unreachable box.
|
||||
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: headscaleErrorMessage(err), ms: 0 } }));
|
||||
setHealth((prev) => ({ ...prev, [id]: { ok: false, error: offscaleErrorMessage(err), ms: 0 } }));
|
||||
} finally {
|
||||
setTestingIds((prev) => prev.filter((t) => t !== id));
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export const ServersView = () => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
setActionError(headscaleErrorMessage(err));
|
||||
setActionError(offscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -203,8 +203,8 @@ export const ServersView = () => {
|
||||
<ErrorNote>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>
|
||||
Could not reach the Headscale sidecar: {headscaleErrorMessage(error)}. If it is not running, start it
|
||||
with <code className="font-mono">pm2 start ecosystem.config.cjs --only officer-headscale</code>.
|
||||
Could not reach the Headscale sidecar: {offscaleErrorMessage(error)}. If it is not running, start it
|
||||
with <code className="font-mono">pm2 start ecosystem.config.cjs --only officer-offscale</code>.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+9
-9
@@ -1,8 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { Users, Plus, Pencil, Trash2, Check, X, Loader2 } from 'lucide-react';
|
||||
import type { HeadscaleUserWithCounts } from './shared';
|
||||
import { useHeadscaleUsers } from './useHeadscaleData';
|
||||
import { headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import type { OffscaleUserWithCounts } from './shared';
|
||||
import { useOffscaleUsers } from './useOffscaleData';
|
||||
import { offscaleErrorMessage } from './useOffscaleServers';
|
||||
import { timeAgo } from './format';
|
||||
import { Card, Button, Field, Badge, ErrorNote, Dot } from './Cards';
|
||||
import { ViewShell, EmptyBody } from './ViewShell';
|
||||
@@ -12,10 +12,10 @@ import { ViewShell, EmptyBody } from './ViewShell';
|
||||
// The node count next to each user is the point of this screen: Headscale refuses to delete a user that
|
||||
// still owns nodes, and without the count that refusal arrives as a surprise after the confirm click.
|
||||
|
||||
type UserRowProps = { user: HeadscaleUserWithCounts; onError: (message: string) => void };
|
||||
type UserRowProps = { user: OffscaleUserWithCounts; onError: (message: string) => void };
|
||||
|
||||
const UserRow = ({ user, onError }: UserRowProps) => {
|
||||
const { rename, remove } = useHeadscaleUsers();
|
||||
const { rename, remove } = useOffscaleUsers();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [draft, setDraft] = useState(user.name);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
@@ -26,7 +26,7 @@ const UserRow = ({ user, onError }: UserRowProps) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
onError(headscaleErrorMessage(err));
|
||||
onError(offscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,7 +112,7 @@ const UserRow = ({ user, onError }: UserRowProps) => {
|
||||
};
|
||||
|
||||
const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
|
||||
const { create } = useHeadscaleUsers();
|
||||
const { create } = useOffscaleUsers();
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -124,7 +124,7 @@ const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
|
||||
await create.mutateAsync({ name: name.trim(), email: email.trim() || undefined });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(headscaleErrorMessage(err));
|
||||
setError(offscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,7 +163,7 @@ const CreateUserForm = ({ onClose }: { onClose: () => void }) => {
|
||||
};
|
||||
|
||||
export const UsersView = () => {
|
||||
const { users, isLoading, error } = useHeadscaleUsers();
|
||||
const { users, isLoading, error } = useOffscaleUsers();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Loader2, ServerOff } from 'lucide-react';
|
||||
import { NO_ACTIVE_SERVER } from './shared';
|
||||
import { headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import { offscaleErrorMessage } from './useOffscaleServers';
|
||||
import { ErrorNote } from './Cards';
|
||||
|
||||
// The loading / no-server / failed states every domain section shares.
|
||||
@@ -56,7 +56,7 @@ export const ViewShell = ({ isLoading, error, label, children }: ViewShellProps)
|
||||
return (
|
||||
<div className="p-4">
|
||||
<ErrorNote>
|
||||
Could not load {label}: {headscaleErrorMessage(error)}
|
||||
Could not load {label}: {offscaleErrorMessage(error)}
|
||||
</ErrorNote>
|
||||
</div>
|
||||
);
|
||||
|
||||
+4
-4
@@ -8,15 +8,15 @@ export const defaultLayout: LayoutNode = {
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'headscale-sidebar',
|
||||
id: 'offscale-sidebar',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'headscale-servers', appType: 'headscale-servers' }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'headscale-nav', appType: 'headscale-nav' }, size: 70 },
|
||||
{ node: { type: 'panel', id: 'offscale-servers', appType: 'offscale-servers' }, size: 30 },
|
||||
{ node: { type: 'panel', id: 'offscale-nav', appType: 'offscale-nav' }, size: 70 },
|
||||
],
|
||||
},
|
||||
size: 22,
|
||||
},
|
||||
{ node: { type: 'panel', id: 'headscale-view', appType: 'headscale-view' }, size: 78 },
|
||||
{ node: { type: 'panel', id: 'offscale-view', appType: 'offscale-view' }, size: 78 },
|
||||
],
|
||||
};
|
||||
|
||||
+13
-11
@@ -1,27 +1,29 @@
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { PanelLeft, LayoutGrid, Network } from 'lucide-react';
|
||||
import { HeadscaleNav } from './HeadscaleNav';
|
||||
import { HeadscaleServerPicker } from './HeadscaleServerPicker';
|
||||
import { HeadscaleView } from './HeadscaleView';
|
||||
import { HeadscaleViewHeader } from './HeadscaleViewHeader';
|
||||
import { OffscaleNav } from './OffscaleNav';
|
||||
import { OffscaleServerPicker } from './OffscaleServerPicker';
|
||||
import { OffscaleView } from './OffscaleView';
|
||||
import { OffscaleViewHeader } from './OffscaleViewHeader';
|
||||
|
||||
export { HeadscaleNav, HeadscaleServerPicker, HeadscaleView };
|
||||
// No component re-exports. `panels.ts` declares PANELS — the shell mounts them by key and there is
|
||||
// no way to export a component from a plugin, which is the rule this file exists to keep. The three
|
||||
// re-exports that were here were residue of the platform importing them directly before extraction.
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'headscale-servers',
|
||||
key: 'offscale-servers',
|
||||
name: 'Headscale servers',
|
||||
icon: Network,
|
||||
component: HeadscaleServerPicker,
|
||||
component: OffscaleServerPicker,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
{ key: 'headscale-nav', name: 'Headscale', icon: PanelLeft, component: HeadscaleNav, availableOnPanel: false },
|
||||
{ key: 'offscale-nav', name: 'Headscale', icon: PanelLeft, component: OffscaleNav, availableOnPanel: false },
|
||||
{
|
||||
key: 'headscale-view',
|
||||
key: 'offscale-view',
|
||||
name: 'Headscale',
|
||||
icon: LayoutGrid,
|
||||
component: HeadscaleView,
|
||||
header: HeadscaleViewHeader,
|
||||
component: OffscaleView,
|
||||
header: OffscaleViewHeader,
|
||||
availableOnPanel: false,
|
||||
},
|
||||
];
|
||||
|
||||
+24
-24
@@ -1,10 +1,10 @@
|
||||
// Shared types/constants for the /headscale workspace panels. Everything here mirrors the wire shapes the
|
||||
// officer-headscale sidecar returns under /api/headscale/_officer/* — deliberately NOT Headscale's own API
|
||||
// officer-offscale sidecar returns under /api/offscale/_officer/* — deliberately NOT Headscale's own API
|
||||
// shapes. The sidecar absorbs Headscale's quirks (uint64-as-string ids, zero-date sentinels, the version
|
||||
// floor), so these types are stable across Headscale releases and the browser never learns the upstream
|
||||
// version. See src/servers/sidecar/headscale/routes.ts.
|
||||
// version. See ../sidecar/routes.ts, and ../OFFSCALE_API.md for the published contract.
|
||||
|
||||
export const HEADSCALE_SECTIONS = [
|
||||
export const OFFSCALE_SECTIONS = [
|
||||
{ id: 'servers', label: 'Servers' },
|
||||
{ id: 'nodes', label: 'Nodes' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
@@ -15,19 +15,19 @@ export const HEADSCALE_SECTIONS = [
|
||||
{ id: 'console', label: 'Console' },
|
||||
] as const;
|
||||
|
||||
export type HeadscaleSectionId = (typeof HEADSCALE_SECTIONS)[number]['id'];
|
||||
export type OffscaleSectionId = (typeof OFFSCALE_SECTIONS)[number]['id'];
|
||||
|
||||
/** Where /headscale lands, and where an unrecognised section redirects to. */
|
||||
export const DEFAULT_HEADSCALE_SECTION: HeadscaleSectionId = 'servers';
|
||||
export const DEFAULT_OFFSCALE_SECTION: OffscaleSectionId = 'servers';
|
||||
|
||||
export const isHeadscaleSection = (value: string | undefined): value is HeadscaleSectionId =>
|
||||
HEADSCALE_SECTIONS.some((s) => s.id === value);
|
||||
export const isOffscaleSection = (value: string | undefined): value is OffscaleSectionId =>
|
||||
OFFSCALE_SECTIONS.some((s) => s.id === value);
|
||||
|
||||
/** The one place the section URL is spelled, so the nav, the guard and any deep link cannot drift apart. */
|
||||
export const headscaleSectionPath = (id: HeadscaleSectionId) => `/headscale/${id}`;
|
||||
export const offscaleSectionPath = (id: OffscaleSectionId) => `/offscale/${id}`;
|
||||
|
||||
/** A registered Headscale server. The API key is never included — it stays encrypted in Postgres. */
|
||||
export type HeadscaleServer = {
|
||||
export type OffscaleServer = {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
@@ -44,7 +44,7 @@ export type HeadscaleServer = {
|
||||
};
|
||||
|
||||
/** Result of GET /_officer/servers/:id/health — reachable AND the stored key still works. */
|
||||
export type HeadscaleHealth = {
|
||||
export type OffscaleHealth = {
|
||||
ok: boolean;
|
||||
version?: string;
|
||||
/** `'unknown'` for self-built servers reporting the literal 'dev'. */
|
||||
@@ -54,10 +54,10 @@ export type HeadscaleHealth = {
|
||||
};
|
||||
|
||||
/** Result of POST /_officer/ssh-test — can we open a shell there with the keys already on this box. */
|
||||
export type HeadscaleSshTest = { ok: boolean; error?: string; ms: number };
|
||||
export type OffscaleSshTest = { ok: boolean; error?: string; ms: number };
|
||||
|
||||
/** Officer's supported floor, restated for UI copy. The sidecar is the enforcer; this is only a label. */
|
||||
export const MIN_HEADSCALE_VERSION = '0.29';
|
||||
export const MIN_OFFSCALE_VERSION = '0.29';
|
||||
|
||||
// ── Access policy ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -66,7 +66,7 @@ export const MIN_HEADSCALE_VERSION = '0.29';
|
||||
* whether it is stored in the database or read from a file — so this carries no "is it editable" flag,
|
||||
* because there is nothing on the server that reports one. Only an attempted save finds out.
|
||||
*/
|
||||
export type HeadscalePolicy = {
|
||||
export type OffscalePolicy = {
|
||||
policy: string;
|
||||
/** Null when Headscale has never recorded one, which includes every file-backed policy. */
|
||||
updatedAt: string | null;
|
||||
@@ -81,7 +81,7 @@ export const POLICY_REJECTED = 'policy_rejected';
|
||||
// The Officer Companion is a service deployed next to a Headscale server that can see the container the
|
||||
// admin API is served from: whether it is running, what it logged, and start/stop/restart. It is optional
|
||||
// and per-server, so `available: false` is a first-class state rather than an error — the admin API on the
|
||||
// same domain is independent and may still work. Contract: COMMS/HEADSCALE_COMPANION_API.md.
|
||||
// same domain is independent and may still work. Contract: COMMS/OFFSCALE_COMPANION_API.md.
|
||||
|
||||
/** Never available for a companion that is missing — the reason says which flavour of missing. */
|
||||
type Unavailable = { available: false; reason: string };
|
||||
@@ -128,7 +128,7 @@ export type CompanionActionResult =
|
||||
// ── Domain objects ────────────────────────────────────────────────────────────────────────────────
|
||||
// Ids are strings because Headscale's are uint64 — never parse them to numbers.
|
||||
|
||||
export type HeadscaleUser = {
|
||||
export type OffscaleUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string | null;
|
||||
@@ -138,13 +138,13 @@ export type HeadscaleUser = {
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
export type HeadscaleUserWithCounts = HeadscaleUser & { nodeCount: number; onlineCount: number };
|
||||
export type OffscaleUserWithCounts = OffscaleUser & { nodeCount: number; onlineCount: number };
|
||||
|
||||
export type HeadscaleNode = {
|
||||
export type OffscaleNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
user: HeadscaleUser | null;
|
||||
user: OffscaleUser | null;
|
||||
ipAddresses: string[];
|
||||
online: boolean;
|
||||
lastSeen: string | null;
|
||||
@@ -162,13 +162,13 @@ export type HeadscaleNode = {
|
||||
isExitNode: boolean;
|
||||
};
|
||||
|
||||
export type HeadscalePreAuthKey = {
|
||||
export type OffscalePreAuthKey = {
|
||||
id: string;
|
||||
/** Non-null ONLY on the creation response — the sidecar strips the secret from every list. */
|
||||
key: string | null;
|
||||
/** A never-usable label for telling keys apart in a list, e.g. `hskey-auth-a1b2c3-***`. */
|
||||
keyDisplay: string;
|
||||
user: HeadscaleUser | null;
|
||||
user: OffscaleUser | null;
|
||||
reusable: boolean;
|
||||
ephemeral: boolean;
|
||||
used: boolean;
|
||||
@@ -189,7 +189,7 @@ export const NO_ACTIVE_SERVER = 'no_active_server';
|
||||
export type InviteStatus = 'pending' | 'claimed' | 'expired' | 'revoked';
|
||||
|
||||
/** What the admin list returns. It carries no claim token and no key — by design, at every status. */
|
||||
export type HeadscaleInvite = {
|
||||
export type OffscaleInvite = {
|
||||
id: string;
|
||||
user: string;
|
||||
note?: string | null;
|
||||
@@ -206,7 +206,7 @@ export type HeadscaleInvite = {
|
||||
* The create response — the ONLY time the link exists. Its fragment holds the claim token, so it is held in
|
||||
* component state, shown once, and never written to a cache, a query key or a log.
|
||||
*/
|
||||
export type HeadscaleInviteCreated = HeadscaleInvite & { url: string };
|
||||
export type OffscaleInviteCreated = OffscaleInvite & { url: string };
|
||||
|
||||
export type InviteCreateInput = {
|
||||
user: string;
|
||||
@@ -216,8 +216,8 @@ export type InviteCreateInput = {
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type InvitesListResult = { available: true; invites: HeadscaleInvite[] } | Unavailable;
|
||||
export type InviteCreateResult = { available: true; invite: HeadscaleInviteCreated } | Unavailable;
|
||||
export type InvitesListResult = { available: true; invites: OffscaleInvite[] } | Unavailable;
|
||||
export type InviteCreateResult = { available: true; invite: OffscaleInviteCreated } | Unavailable;
|
||||
|
||||
/** Spec §4.1. The floor is Officer's: a sub-minute invite cannot be sent to anybody in time. */
|
||||
export const INVITE_TTL_MIN_SECONDS = 60;
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { CompanionAction, CompanionActionResult, CompanionHealthResult, Com
|
||||
// decryptable only there. The browser never sees it and never talks to the companion directly.
|
||||
|
||||
const BASE = '/offscale/_officer/companion';
|
||||
const HEALTH_KEY = ['headscale', 'companion', 'health'] as const;
|
||||
const HEALTH_KEY = ['offscale', 'companion', 'health'] as const;
|
||||
|
||||
/**
|
||||
* The container's health, polled.
|
||||
@@ -32,7 +32,7 @@ export function useCompanionHealth() {
|
||||
export function useCompanionLogs(tail: number, enabled: boolean) {
|
||||
const { get } = useClient();
|
||||
return useQuery({
|
||||
queryKey: ['headscale', 'companion', 'logs', tail],
|
||||
queryKey: ['offscale', 'companion', 'logs', tail],
|
||||
queryFn: () => get<CompanionLogsResult>(`${BASE}/logs?tail=${tail}`),
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
@@ -1,30 +1,30 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { HeadscaleNode, HeadscaleUserWithCounts, HeadscalePreAuthKey } from './shared';
|
||||
import type { OffscaleNode, OffscaleUserWithCounts, OffscalePreAuthKey } from './shared';
|
||||
|
||||
// Queries for the domain sections. All three act on whichever server is active, so they live under the
|
||||
// same ['headscale'] key prefix that switching servers invalidates wholesale (see useHeadscaleServers).
|
||||
// same ['offscale'] key prefix that switching servers invalidates wholesale (see useOffscaleServers).
|
||||
//
|
||||
// Mutations invalidate broadly rather than patching caches: deleting a user changes node counts, approving
|
||||
// a route changes subnetRoutes, expiring a key changes nothing else but costs one cheap refetch. The lists
|
||||
// are small and the correctness is worth more than the round trip.
|
||||
|
||||
const NODES_KEY = ['headscale', 'nodes'] as const;
|
||||
const USERS_KEY = ['headscale', 'users'] as const;
|
||||
const KEYS_KEY = ['headscale', 'keys'] as const;
|
||||
const NODES_KEY = ['offscale', 'nodes'] as const;
|
||||
const USERS_KEY = ['offscale', 'users'] as const;
|
||||
const KEYS_KEY = ['offscale', 'keys'] as const;
|
||||
|
||||
const EMPTY_NODES: HeadscaleNode[] = [];
|
||||
const EMPTY_USERS: HeadscaleUserWithCounts[] = [];
|
||||
const EMPTY_KEYS: HeadscalePreAuthKey[] = [];
|
||||
const EMPTY_NODES: OffscaleNode[] = [];
|
||||
const EMPTY_USERS: OffscaleUserWithCounts[] = [];
|
||||
const EMPTY_KEYS: OffscalePreAuthKey[] = [];
|
||||
|
||||
export function useHeadscaleNodes() {
|
||||
export function useOffscaleNodes() {
|
||||
const { get, post, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] });
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['offscale'] });
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: NODES_KEY,
|
||||
queryFn: () => get<{ nodes: HeadscaleNode[] }>('/offscale/_officer/nodes'),
|
||||
queryFn: () => get<{ nodes: OffscaleNode[] }>('/offscale/_officer/nodes'),
|
||||
// Online/lastSeen go stale fast, and this is a screen you sit on while waiting for a machine to join.
|
||||
refetchInterval: 20_000,
|
||||
staleTime: 10_000,
|
||||
@@ -78,14 +78,14 @@ export function useHeadscaleNodes() {
|
||||
};
|
||||
}
|
||||
|
||||
export function useHeadscaleUsers() {
|
||||
export function useOffscaleUsers() {
|
||||
const { get, post, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['headscale'] });
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: ['offscale'] });
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: USERS_KEY,
|
||||
queryFn: () => get<{ users: HeadscaleUserWithCounts[] }>('/offscale/_officer/users'),
|
||||
queryFn: () => get<{ users: OffscaleUserWithCounts[] }>('/offscale/_officer/users'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -123,14 +123,14 @@ export type CreateKeyInput = {
|
||||
aclTags: string[];
|
||||
};
|
||||
|
||||
export function useHeadscaleKeys() {
|
||||
export function useOffscaleKeys() {
|
||||
const { get, post, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: KEYS_KEY });
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: KEYS_KEY,
|
||||
queryFn: () => get<{ keys: HeadscalePreAuthKey[] }>('/offscale/_officer/keys'),
|
||||
queryFn: () => get<{ keys: OffscalePreAuthKey[] }>('/offscale/_officer/keys'),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
@@ -138,7 +138,7 @@ export function useHeadscaleKeys() {
|
||||
// (not merged into the list cache) so the view can show it once and deliberately drop it.
|
||||
const create = useMutation({
|
||||
mutationFn: (input: CreateKeyInput) =>
|
||||
post<{ key: HeadscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
|
||||
post<{ key: OffscalePreAuthKey; secretShownOnce: boolean }>('/offscale/_officer/keys', input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, InvitesListResult } from './shared';
|
||||
import type { OffscaleInviteCreated, InviteCreateInput, InviteCreateResult, InvitesListResult } from './shared';
|
||||
|
||||
// Device invites for the active server. Everything goes through the headscale sidecar, which proxies the
|
||||
// server's own companion — see src/servers/sidecar/headscale/invites.ts for why the records live there and
|
||||
// server's own companion — see ../sidecar/invites.ts for why the records live there and
|
||||
// not here.
|
||||
//
|
||||
// The create result is deliberately NOT merged into the list cache. It is the one response that contains the
|
||||
@@ -11,11 +11,11 @@ import type { HeadscaleInviteCreated, InviteCreateInput, InviteCreateResult, Inv
|
||||
// drops it. The list is refetched instead, which returns the same invite without its token.
|
||||
|
||||
const BASE = '/offscale/_officer/enroll/invites';
|
||||
const INVITES_KEY = ['headscale', 'invites'] as const;
|
||||
const INVITES_KEY = ['offscale', 'invites'] as const;
|
||||
|
||||
const EMPTY: InvitesListResult = { available: true, invites: [] };
|
||||
|
||||
export function useHeadscaleInvites() {
|
||||
export function useOffscaleInvites() {
|
||||
const { get, post, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: INVITES_KEY });
|
||||
@@ -30,7 +30,7 @@ export function useHeadscaleInvites() {
|
||||
});
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (input: InviteCreateInput): Promise<HeadscaleInviteCreated> => {
|
||||
mutationFn: async (input: InviteCreateInput): Promise<OffscaleInviteCreated> => {
|
||||
const result = await post<InviteCreateResult>(BASE, input);
|
||||
// An unavailable companion is a 200 here, like every companion route — turn it into a rejection so the
|
||||
// form shows it where the admin is looking rather than rendering an empty link panel.
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { HeadscalePolicy } from './shared';
|
||||
import type { OffscalePolicy } from './shared';
|
||||
import { POLICY_READ_ONLY, POLICY_REJECTED } from './shared';
|
||||
|
||||
// The active server's ACL policy. One document, one query, one mutation — the interesting part is entirely
|
||||
// in how a failed save is classified, because the two failures need opposite reactions from the owner:
|
||||
// "your document is wrong, here is where" versus "this server does not accept written policies at all".
|
||||
|
||||
const POLICY_KEY = ['headscale', 'policy'] as const;
|
||||
const POLICY_KEY = ['offscale', 'policy'] as const;
|
||||
const PATH = '/offscale/_officer/policy';
|
||||
|
||||
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
||||
@@ -45,23 +45,23 @@ export function assistFailure(err: unknown): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a revised policy in English. Separate from `useHeadscalePolicy` because it is a different kind of
|
||||
* Ask for a revised policy in English. Separate from `useOffscalePolicy` because it is a different kind of
|
||||
* thing: no cache, no query key, nothing to invalidate — one request, one answer, discarded on reload.
|
||||
*/
|
||||
export function useHeadscalePolicyAssist() {
|
||||
export function useOffscalePolicyAssist() {
|
||||
const { post } = useClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: { prompt: string; policy: string }) => post<PolicyProposal>(`${PATH}/assist`, input),
|
||||
});
|
||||
}
|
||||
|
||||
export function useHeadscalePolicy() {
|
||||
export function useOffscalePolicy() {
|
||||
const { get, put } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: POLICY_KEY,
|
||||
queryFn: () => get<HeadscalePolicy>(PATH),
|
||||
queryFn: () => get<OffscalePolicy>(PATH),
|
||||
// No polling and a long staleTime: this is a document someone is editing. A background refetch that
|
||||
// replaced the textarea under a half-written rule would be the worst thing this screen could do.
|
||||
staleTime: 5 * 60_000,
|
||||
@@ -69,7 +69,7 @@ export function useHeadscalePolicy() {
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (policy: string) => put<HeadscalePolicy>(PATH, { policy }),
|
||||
mutationFn: (policy: string) => put<OffscalePolicy>(PATH, { policy }),
|
||||
// Seed the cache from the response rather than invalidating: a refetch here would race the editor's
|
||||
// own state and could show the pre-save document for a frame.
|
||||
onSuccess: (data) => qc.setQueryData(POLICY_KEY, data),
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { DEFAULT_HEADSCALE_SECTION, isHeadscaleSection, type HeadscaleSectionId } from './shared';
|
||||
import { DEFAULT_OFFSCALE_SECTION, isOffscaleSection, type OffscaleSectionId } from './shared';
|
||||
|
||||
// The URL is the source of truth for which section is open — not a panel channel. See docs/navigation-audit.md:
|
||||
// selection held in a channel means the id lives only in an onClick closure, so the section can't be linked to,
|
||||
// opened in a new tab, or reached with the back button. HeadscaleScreen redirects anything unrecognised, so the
|
||||
// opened in a new tab, or reached with the back button. OffscaleScreen redirects anything unrecognised, so the
|
||||
// fallback here is only for the instant before that lands.
|
||||
|
||||
export function useHeadscaleSection(): HeadscaleSectionId {
|
||||
export function useOffscaleSection(): OffscaleSectionId {
|
||||
const { section } = useParams();
|
||||
return isHeadscaleSection(section) ? section : DEFAULT_HEADSCALE_SECTION;
|
||||
return isOffscaleSection(section) ? section : DEFAULT_OFFSCALE_SECTION;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './shared';
|
||||
import type { OffscaleServer, OffscaleHealth, OffscaleSshTest } from './shared';
|
||||
|
||||
// The registered-servers cache. Every panel in the /headscale workspace reads this one query, so switching
|
||||
// the active server anywhere updates the whole screen at once.
|
||||
@@ -9,8 +9,8 @@ import type { HeadscaleServer, HeadscaleHealth, HeadscaleSshTest } from './share
|
||||
// means a POST can fail for perfectly ordinary reasons — a typo'd URL, a revoked key. Those are not
|
||||
// exceptional here, so the mutations surface their message rather than swallowing it.
|
||||
|
||||
const SERVERS_KEY = ['headscale', 'servers'] as const;
|
||||
const EMPTY: HeadscaleServer[] = [];
|
||||
const SERVERS_KEY = ['offscale', 'servers'] as const;
|
||||
const EMPTY: OffscaleServer[] = [];
|
||||
|
||||
const BASE = '/offscale/_officer/servers';
|
||||
|
||||
@@ -18,7 +18,7 @@ const BASE = '/offscale/_officer/servers';
|
||||
* Readable message from a useClient rejection. It throws `{status, message}` where `message` is the raw
|
||||
* body text — JSON `{error}` from our sidecar, but plain text from the platform's own 401/503 paths.
|
||||
*/
|
||||
export function headscaleErrorMessage(err: unknown): string {
|
||||
export function offscaleErrorMessage(err: unknown): string {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw !== 'string' || !raw) return 'Something went wrong';
|
||||
try {
|
||||
@@ -34,25 +34,25 @@ export type RegisterServerInput = { name?: string; url: string; apiKey: string;
|
||||
/** `sshHost: ''` clears the console target; omitting it leaves whatever is stored alone. */
|
||||
export type UpdateServerInput = { id: number; name?: string; url?: string; apiKey?: string; sshHost?: string };
|
||||
|
||||
export function useHeadscaleServers() {
|
||||
export function useOffscaleServers() {
|
||||
const { get, post, patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: SERVERS_KEY,
|
||||
queryFn: () => get<{ servers: HeadscaleServer[] }>(BASE),
|
||||
queryFn: () => get<{ servers: OffscaleServer[] }>(BASE),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: SERVERS_KEY });
|
||||
|
||||
const register = useMutation({
|
||||
mutationFn: (input: RegisterServerInput) => post<{ server: HeadscaleServer }>(BASE, input),
|
||||
mutationFn: (input: RegisterServerInput) => post<{ server: OffscaleServer }>(BASE, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, ...rest }: UpdateServerInput) => patch<{ server: HeadscaleServer }>(`${BASE}/${id}`, rest),
|
||||
mutationFn: ({ id, ...rest }: UpdateServerInput) => patch<{ server: OffscaleServer }>(`${BASE}/${id}`, rest),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -62,9 +62,9 @@ export function useHeadscaleServers() {
|
||||
});
|
||||
|
||||
const activate = useMutation({
|
||||
mutationFn: (id: number) => post<{ server: HeadscaleServer }>(`${BASE}/${id}/activate`),
|
||||
mutationFn: (id: number) => post<{ server: OffscaleServer }>(`${BASE}/${id}/activate`),
|
||||
// Deleting or switching reshuffles which server is active, and every domain query is scoped to it.
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['headscale'] }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['offscale'] }),
|
||||
});
|
||||
|
||||
const servers = query.data?.servers ?? EMPTY;
|
||||
@@ -86,17 +86,17 @@ export function useHeadscaleServers() {
|
||||
* Try to open a shell on a console target with the keys already on this machine. Takes the host rather than a
|
||||
* server id so the form can test a value before it is saved — which is when a typo is still cheap to fix.
|
||||
*/
|
||||
export function useHeadscaleSshTest() {
|
||||
export function useOffscaleSshTest() {
|
||||
const { post } = useClient();
|
||||
return useMutation({
|
||||
mutationFn: (host: string) => post<HeadscaleSshTest>('/offscale/_officer/ssh-test', { host }),
|
||||
mutationFn: (host: string) => post<OffscaleSshTest>('/offscale/_officer/ssh-test', { host }),
|
||||
});
|
||||
}
|
||||
|
||||
/** On-demand reachability probe for one server. Never automatic — it costs two upstream round trips. */
|
||||
export function useHeadscaleHealth() {
|
||||
export function useOffscaleHealth() {
|
||||
const { get } = useClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => get<HeadscaleHealth>(`${BASE}/${id}/health`),
|
||||
mutationFn: (id: number) => get<OffscaleHealth>(`${BASE}/${id}/health`),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user