headscale: acl policy editor, node owner and tags
policy: /_officer/policy GET/PUT. the text goes up byte for byte and headscale's verdict comes back verbatim — it owns the only parser that resolves groups, tags and hosts, so a second one here would disagree with what actually enforces. a file-backed policy is still served over GET and only refuses on PUT, so writability cannot be read: the first save finds out, and a refusal becomes a persistent read-only banner rather than a rejection the owner would go hunting for a syntax error over. nodes: move between users, and a tag editor for the setTags route that had no ui. both sit together in the expanded card because both decide which policy rules apply to a node, and a move says so before it happens.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { NavLink } from 'react-router';
|
||||
import { Network, Server, Laptop, Users, KeyRound, Activity, TerminalSquare, Check } from 'lucide-react';
|
||||
import { Network, Server, Laptop, Users, KeyRound, ShieldCheck, Activity, TerminalSquare, Check } from 'lucide-react';
|
||||
import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared';
|
||||
import { useHeadscaleServers } from './useHeadscaleServers';
|
||||
|
||||
@@ -18,6 +18,7 @@ const ICONS: Record<HeadscaleSectionId, LucideIcon> = {
|
||||
nodes: Laptop,
|
||||
users: Users,
|
||||
keys: KeyRound,
|
||||
policy: ShieldCheck,
|
||||
diagnostics: Activity,
|
||||
console: TerminalSquare,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ServersView } from './ServersView';
|
||||
import { NodesView } from './NodesView';
|
||||
import { UsersView } from './UsersView';
|
||||
import { KeysView } from './KeysView';
|
||||
import { PolicyView } from './PolicyView';
|
||||
import { DiagnosticsView } from './DiagnosticsView';
|
||||
import { ConsoleView } from './ConsoleView';
|
||||
|
||||
@@ -21,6 +22,8 @@ export const HeadscaleView = () => {
|
||||
return <UsersView />;
|
||||
case 'keys':
|
||||
return <KeysView />;
|
||||
case 'policy':
|
||||
return <PolicyView />;
|
||||
case 'diagnostics':
|
||||
return <DiagnosticsView />;
|
||||
case 'console':
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { Laptop, Globe, Trash2, Pencil, TimerReset, Check, X, Search, Copy, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
Laptop,
|
||||
Globe,
|
||||
Trash2,
|
||||
Pencil,
|
||||
TimerReset,
|
||||
Check,
|
||||
X,
|
||||
Search,
|
||||
Copy,
|
||||
ChevronRight,
|
||||
UserRound,
|
||||
ArrowRightLeft,
|
||||
Tag as TagIcon,
|
||||
} from 'lucide-react';
|
||||
import type { HeadscaleNode } from './shared';
|
||||
import { useHeadscaleNodes } from './useHeadscaleData';
|
||||
import { useHeadscaleNodes, useHeadscaleUsers } from './useHeadscaleData';
|
||||
import { headscaleErrorMessage } from './useHeadscaleServers';
|
||||
import { timeAgo, timeUntil, fullDate } from './format';
|
||||
import { Card, Button, Dot, Badge, ErrorNote } from './Cards';
|
||||
@@ -41,6 +55,134 @@ const RouteRow = ({ route, approved, busy, onToggle }: RouteRowProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Tags as Headscale stores them: every one prefixed `tag:`. Typing the prefix every time is noise, so the
|
||||
* editor accepts either form and normalizes here — which is also how the dirty check stays honest, since
|
||||
* `web` and `tag:web` are the same tag and neither should look like an edit.
|
||||
*/
|
||||
const parseTags = (text: string): string[] => {
|
||||
const parts = text
|
||||
.split(/[\s,]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
return [...new Set(parts.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)))];
|
||||
};
|
||||
|
||||
const sameTags = (a: string[], b: string[]) => a.length === b.length && a.every((t, i) => t === b[i]);
|
||||
|
||||
type OwnershipProps = { node: HeadscaleNode; 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
|
||||
* together behind the disclosure rather than next to Rename.
|
||||
*
|
||||
* Mounted only while the card is expanded: it needs the user list, and fetching every user to render a
|
||||
* collapsed row would be a request per screenful for a control nobody is looking at. The query key is
|
||||
* 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 [owner, setOwner] = useState(node.user?.id ?? '');
|
||||
const [draftTags, setDraftTags] = useState(node.tags.join(' '));
|
||||
|
||||
const pending = setTags.isPending || moveToUser.isPending;
|
||||
const nextTags = parseTags(draftTags);
|
||||
const tagsDirty = !sameTags(nextTags, node.tags);
|
||||
const ownerDirty = !!owner && owner !== node.user?.id;
|
||||
const target = users.find((u) => u.id === owner);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
onError(headscaleErrorMessage(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-zinc-500">Owner and tags</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<UserRound className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
<select
|
||||
value={owner}
|
||||
onChange={(ev) => setOwner(ev.target.value)}
|
||||
disabled={busy || pending}
|
||||
className="min-w-0 flex-1 cursor-pointer rounded-md border border-white/10 bg-black/40 px-2 py-1 text-xs text-zinc-200 outline-none focus:border-primary/50 disabled:opacity-40"
|
||||
>
|
||||
{!node.user && <option value="">no owner</option>}
|
||||
{users.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{ownerDirty && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => void run(() => moveToUser.mutateAsync({ id: node.id, userId: owner }))}
|
||||
disabled={busy || pending}
|
||||
>
|
||||
<ArrowRightLeft className="h-3.5 w-3.5" />
|
||||
Move
|
||||
</Button>
|
||||
<Button onClick={() => setOwner(node.user?.id ?? '')} disabled={busy || pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Said before the move, not after: the node keeps its address and its tags, but the rules that let
|
||||
anything reach it are written per user, so it can go dark to everything that used to see it. */}
|
||||
{ownerDirty && (
|
||||
<p className="text-[11px] leading-snug text-amber-400/90">
|
||||
Moving this node to <span className="font-medium">{target?.name ?? 'another user'}</span> changes which policy
|
||||
rules apply to it. Its addresses and tags stay, but anything reaching it through a rule written for{' '}
|
||||
{node.user?.name ?? 'its current owner'} will stop.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<TagIcon className="h-3.5 w-3.5 shrink-0 text-zinc-500" />
|
||||
<input
|
||||
value={draftTags}
|
||||
onChange={(ev) => setDraftTags(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter' && tagsDirty) void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags }));
|
||||
if (ev.key === 'Escape') setDraftTags(node.tags.join(' '));
|
||||
}}
|
||||
placeholder="tag:server tag:eu — space separated"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="min-w-0 flex-1 rounded-md border border-white/10 bg-black/40 px-2 py-1 font-mono text-[11px] text-zinc-200 outline-none placeholder:text-zinc-600 focus:border-primary/50"
|
||||
/>
|
||||
{tagsDirty && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => void run(() => setTags.mutateAsync({ id: node.id, tags: nextTags }))}
|
||||
disabled={busy || pending}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Apply tags
|
||||
</Button>
|
||||
<Button onClick={() => setDraftTags(node.tags.join(' '))} disabled={busy || pending}>
|
||||
Revert
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] leading-snug text-zinc-600">
|
||||
Tags are what the access policy targets. A tag no rule mentions does nothing; removing one a rule depends on
|
||||
cuts the node off from it. The <span className="text-zinc-500">tag:</span> prefix is added for you.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void };
|
||||
|
||||
const NodeCard = ({ node, onError }: NodeCardProps) => {
|
||||
@@ -183,6 +325,8 @@ const NodeCard = ({ node, onError }: NodeCardProps) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Ownership node={node} busy={busy} onError={onError} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { AlertTriangle, FileLock2, Loader2, RotateCcw, Save, ShieldCheck } from 'lucide-react';
|
||||
import { timeAgo } from './format';
|
||||
import { useHeadscalePolicy, policySaveFailure, type PolicySaveFailure } from './useHeadscalePolicy';
|
||||
import { Card, SectionHeader, Button, ErrorNote } from './Cards';
|
||||
import { ViewShell } from './ViewShell';
|
||||
|
||||
// The tailnet's ACL document. A plain textarea on purpose — this is HuJSON, where the comments and the
|
||||
// hand-kept alignment are half the document's value to whoever maintains it, and a rich editor that
|
||||
// reformats or a client-side parser that disagrees with Headscale would both destroy more than they add.
|
||||
//
|
||||
// Validation is entirely Headscale's. It has the only parser that counts: it resolves groups, tags and
|
||||
// host aliases, and it is what will actually enforce the result. Officer sends the text up untouched and
|
||||
// shows the verdict verbatim — including the line and column, which is the whole reason to show it at all.
|
||||
//
|
||||
// Two failures, deliberately styled differently. A REJECTED document is a normal part of editing and stays
|
||||
// inline next to the save button. A READ-ONLY server means this screen cannot do its job at all and says so
|
||||
// at the top, permanently, because the owner needs to go and edit a file on the server instead.
|
||||
|
||||
/** Ctrl/Cmd-S while the textarea has focus. An ACL is long enough that reaching for the button breaks flow. */
|
||||
function useSaveShortcut(onSave: () => void, enabled: boolean) {
|
||||
const handler = useRef(onSave);
|
||||
handler.current = onSave;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
if ((ev.metaKey || ev.ctrlKey) && ev.key.toLowerCase() === 's') {
|
||||
ev.preventDefault();
|
||||
handler.current();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [enabled]);
|
||||
}
|
||||
|
||||
const ReadOnlyBanner = ({ message }: { message: string }) => (
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/30 bg-amber-500/10 p-3 text-xs leading-relaxed text-amber-200">
|
||||
<FileLock2 className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-amber-100">This server's policy is read-only</div>
|
||||
<p className="mt-1 text-amber-200/80">
|
||||
Headscale said: <span className="font-mono">{message}</span>
|
||||
</p>
|
||||
<p className="mt-1.5 text-amber-200/70">
|
||||
It is reading its policy from a file on disk rather than from its database, so the API refuses writes — a save
|
||||
here would be overwritten on the next restart anyway. Edit the file on the server (the Console section is one
|
||||
way in) and reload it there. Everything below is still the live document, and still readable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Rejected = ({ message }: { message: string }) => (
|
||||
<div className="flex items-start gap-2.5 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-xs leading-relaxed text-red-300">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div>
|
||||
<div className="font-medium text-red-200">Headscale rejected this policy</div>
|
||||
{/* Verbatim, monospaced: it usually carries a line and column, and re-wording it would throw that away. */}
|
||||
<pre className="mt-1 font-mono text-[11px] whitespace-pre-wrap text-red-300/90">{message}</pre>
|
||||
<p className="mt-1.5 text-red-300/70">Nothing was saved — the tailnet is still running the previous policy.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const PolicyView = () => {
|
||||
const { policy, isLoading, error, save } = useHeadscalePolicy();
|
||||
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [failure, setFailure] = useState<PolicySaveFailure | null>(null);
|
||||
// Sticky for the session: once a server has refused a write, every later save would refuse identically,
|
||||
// and re-discovering that by pressing save again is not information.
|
||||
const [readOnly, setReadOnly] = useState<string | null>(null);
|
||||
const [savedAt, setSavedAt] = useState<number | null>(null);
|
||||
|
||||
// The fetched document seeds the editor once. After that the draft owns the text — a refetch must never
|
||||
// reach in and replace what someone is typing.
|
||||
const text = draft ?? policy?.policy ?? '';
|
||||
const dirty = draft !== null && draft !== (policy?.policy ?? '');
|
||||
|
||||
const submit = async () => {
|
||||
if (!dirty || readOnly || save.isPending) return;
|
||||
setFailure(null);
|
||||
try {
|
||||
await save.mutateAsync(text);
|
||||
setDraft(null);
|
||||
setSavedAt(Date.now());
|
||||
} catch (err) {
|
||||
const parsed = policySaveFailure(err);
|
||||
setFailure(parsed);
|
||||
if (parsed.kind === 'readOnly') setReadOnly(parsed.message);
|
||||
}
|
||||
};
|
||||
|
||||
useSaveShortcut(() => void submit(), dirty && !readOnly);
|
||||
|
||||
const revert = () => {
|
||||
setDraft(null);
|
||||
setFailure(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<ViewShell isLoading={isLoading} error={error} label="the access policy">
|
||||
<div className="mx-auto flex max-w-4xl flex-col gap-3">
|
||||
<SectionHeader
|
||||
title="Access policy"
|
||||
subtitle="HuJSON — JSON with comments and trailing commas. Headscale validates it on save; nothing is stored unless it passes."
|
||||
action={
|
||||
<div className="flex items-center gap-2">
|
||||
{dirty && !readOnly && (
|
||||
<Button onClick={revert} disabled={save.isPending}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Revert
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onClick={() => void submit()} disabled={!dirty || !!readOnly || save.isPending}>
|
||||
{save.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Save className="h-3.5 w-3.5" />}
|
||||
{save.isPending ? 'Validating…' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{readOnly && <ReadOnlyBanner message={readOnly} />}
|
||||
{failure?.kind === 'rejected' && <Rejected message={failure.message} />}
|
||||
{failure?.kind === 'unknown' && <ErrorNote>{failure.message}</ErrorNote>}
|
||||
|
||||
<Card>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(ev) => {
|
||||
setDraft(ev.target.value);
|
||||
setFailure(null);
|
||||
setSavedAt(null);
|
||||
}}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
placeholder={'{\n "acls": [\n { "action": "accept", "src": ["*"], "dst": ["*:*"] },\n ],\n}'}
|
||||
className="block h-[28rem] w-full resize-y bg-black/40 p-4 font-mono text-[12px] leading-relaxed text-zinc-200 outline-none placeholder:text-zinc-700"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-white/10 px-3 py-2 text-[11px] text-zinc-500">
|
||||
<span>
|
||||
{text.split('\n').length} lines · {text.length} characters
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-3">
|
||||
{savedAt !== null && !dirty && (
|
||||
<span className="flex items-center gap-1 text-emerald-400">
|
||||
<ShieldCheck className="h-3.5 w-3.5" />
|
||||
Saved and accepted
|
||||
</span>
|
||||
)}
|
||||
{dirty && <span className="text-amber-400">Unsaved changes</span>}
|
||||
{policy?.updatedAt && <span>Last changed {timeAgo(policy.updatedAt)}</span>}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="px-1 text-[11px] leading-relaxed text-zinc-600">
|
||||
This document decides which node may reach which. A policy that saves cleanly can still cut a machine off —
|
||||
Headscale checks that the document is valid, not that it is what you meant. Ctrl/Cmd-S saves.
|
||||
</p>
|
||||
</div>
|
||||
</ViewShell>
|
||||
);
|
||||
};
|
||||
@@ -9,6 +9,7 @@ export const HEADSCALE_SECTIONS = [
|
||||
{ id: 'nodes', label: 'Nodes' },
|
||||
{ id: 'users', label: 'Users' },
|
||||
{ id: 'keys', label: 'Pre-auth keys' },
|
||||
{ id: 'policy', label: 'Access policy' },
|
||||
{ id: 'diagnostics', label: 'Diagnostics' },
|
||||
{ id: 'console', label: 'Console' },
|
||||
] as const;
|
||||
@@ -57,6 +58,24 @@ export type HeadscaleSshTest = { 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';
|
||||
|
||||
// ── Access policy ─────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The tailnet's ACL document, in HuJSON (JSON with comments and trailing commas). Headscale serves it
|
||||
* 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 = {
|
||||
policy: string;
|
||||
/** Null when Headscale has never recorded one, which includes every file-backed policy. */
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
/** Headscale refused the write outright — this server's policy is read-only over the API. */
|
||||
export const POLICY_READ_ONLY = 'policy_read_only';
|
||||
/** Headscale parsed the document and rejected it. The message is a syntax position or a bad reference. */
|
||||
export const POLICY_REJECTED = 'policy_rejected';
|
||||
|
||||
// ── Companion API ─────────────────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
|
||||
@@ -42,6 +42,13 @@ export function useHeadscaleNodes() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
// Re-owning a node. Takes the target user's id, not its name — Headscale's ids are uint64-as-string.
|
||||
const moveToUser = useMutation({
|
||||
mutationFn: ({ id, userId }: { id: string; userId: string }) =>
|
||||
post(`/headscale/_officer/nodes/${id}/user`, { userId }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
// Single-route toggle: the sidecar reads the current approved set and writes it back with one change,
|
||||
// because Headscale's approve_routes replaces the whole set.
|
||||
const toggleRoute = useMutation({
|
||||
@@ -66,6 +73,7 @@ export function useHeadscaleNodes() {
|
||||
error: query.error,
|
||||
rename,
|
||||
setTags,
|
||||
moveToUser,
|
||||
toggleRoute,
|
||||
expire,
|
||||
remove,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import type { HeadscalePolicy } 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 PATH = '/headscale/_officer/policy';
|
||||
|
||||
/** What a rejected save means. `rejected` carries Headscale's own message; `readOnly` ends the editing. */
|
||||
export type PolicySaveFailure = { kind: 'rejected' | 'readOnly' | 'unknown'; message: string };
|
||||
|
||||
/** useClient throws `{status, message}` with the raw body text — dig the sidecar's `{error, code}` out. */
|
||||
export function policySaveFailure(err: unknown): PolicySaveFailure {
|
||||
const raw = (err as { message?: unknown } | null)?.message;
|
||||
if (typeof raw !== 'string' || !raw) return { kind: 'unknown', message: 'The policy could not be saved' };
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown; code?: unknown };
|
||||
const message = typeof parsed.error === 'string' && parsed.error ? parsed.error : 'The policy could not be saved';
|
||||
if (parsed.code === POLICY_READ_ONLY) return { kind: 'readOnly', message };
|
||||
if (parsed.code === POLICY_REJECTED) return { kind: 'rejected', message };
|
||||
return { kind: 'unknown', message };
|
||||
} catch {
|
||||
return { kind: 'unknown', message: raw.slice(0, 300) };
|
||||
}
|
||||
}
|
||||
|
||||
export function useHeadscalePolicy() {
|
||||
const { get, put } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: POLICY_KEY,
|
||||
queryFn: () => get<HeadscalePolicy>(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,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (policy: string) => put<HeadscalePolicy>(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),
|
||||
});
|
||||
|
||||
return { policy: query.data ?? null, isLoading: query.isLoading, error: query.error, refetch: query.refetch, save };
|
||||
}
|
||||
Reference in New Issue
Block a user