swarm-ui: read the agent's grant once for the internet and permissions controls, and save one permission change at a time
This commit is contained in:
parent
5e23734667
commit
9afe21819f
3 changed files with 73 additions and 36 deletions
|
|
@ -17,7 +17,11 @@ import type { AgentActions } from "../roster/AgentActions.js";
|
||||||
import { AgentInference } from "./AgentInference.js";
|
import { AgentInference } from "./AgentInference.js";
|
||||||
import { AgentInstructions } from "./AgentInstructions.js";
|
import { AgentInstructions } from "./AgentInstructions.js";
|
||||||
import { AgentLimits } from "./AgentLimits.js";
|
import { AgentLimits } from "./AgentLimits.js";
|
||||||
import { AgentInternet, AgentPermissions } from "./AgentPermissions.js";
|
import {
|
||||||
|
AgentInternet,
|
||||||
|
AgentPermissions,
|
||||||
|
useGrant,
|
||||||
|
} from "./AgentPermissions.js";
|
||||||
import { AllowedDomains } from "./AllowedDomains.js";
|
import { AllowedDomains } from "./AllowedDomains.js";
|
||||||
import { AgentStateRow } from "./AgentStateRow.js";
|
import { AgentStateRow } from "./AgentStateRow.js";
|
||||||
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
|
import { AgentTermPreview } from "../terminal/AgentTermPreview.js";
|
||||||
|
|
@ -184,9 +188,21 @@ function SettingsRows({ agent }: { agent: string }) {
|
||||||
<dd>
|
<dd>
|
||||||
<AgentLimits key={agent} agent={agent} />
|
<AgentLimits key={agent} agent={agent} />
|
||||||
</dd>
|
</dd>
|
||||||
|
<GrantRows key={agent} agent={agent} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The internet and permissions rows read and write the one stored grant, so
|
||||||
|
// they share one read of it: a save in one is seen by the other. Keyed by
|
||||||
|
// agent, so switching agents remounts it instead of showing the last grant.
|
||||||
|
function GrantRows({ agent }: { agent: string }) {
|
||||||
|
const g = useGrant(agent);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
<dt>internet</dt>
|
<dt>internet</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<AgentInternet key={agent} agent={agent} />
|
{g.grant === null ? g.loading : <AgentInternet agent={agent} g={g} />}
|
||||||
</dd>
|
</dd>
|
||||||
<dt>allowed domains</dt>
|
<dt>allowed domains</dt>
|
||||||
<dd>
|
<dd>
|
||||||
|
|
@ -194,7 +210,14 @@ function SettingsRows({ agent }: { agent: string }) {
|
||||||
</dd>
|
</dd>
|
||||||
<dt>permissions</dt>
|
<dt>permissions</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<AgentPermissions key={agent} agent={agent} />
|
{g.grant === null ? (
|
||||||
|
<span class="ui-agent-limits-muted">—</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{g.readFailure}
|
||||||
|
<AgentPermissions agent={agent} g={g} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -60,38 +60,48 @@ function permissionsUrl(agent: string): string {
|
||||||
return `/api/agents/${encodeURIComponent(agent)}/permissions`;
|
return `/api/agents/${encodeURIComponent(agent)}/permissions`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The stored grant of one agent, and what changes it.
|
// The stored grant of one agent, and what changes it. One instance serves
|
||||||
function useGrant(agent: string) {
|
// every control of the grant, so a save from one is seen by the others.
|
||||||
|
export function useGrant(agent: string) {
|
||||||
const [grant, setGrant] = useState<Grant | null>(null);
|
const [grant, setGrant] = useState<Grant | null>(null);
|
||||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
// Bumped after a save or a clear, so the grant is fetched again.
|
// Bumped by a retry, so the grant is fetched again.
|
||||||
const [reload, setReload] = useState(0);
|
const [reload, setReload] = useState(0);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
// The refusal of the last save, kept apart from a failed read.
|
// The refusal of the last save, kept apart from a failed read.
|
||||||
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
|
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// Reads the grant; false when the read failed.
|
||||||
let cancelled = false;
|
async function load(isCancelled: () => boolean): Promise<boolean> {
|
||||||
(async () => {
|
try {
|
||||||
const r = await fetch(permissionsUrl(agent));
|
const r = await fetch(permissionsUrl(agent));
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
if (!cancelled) setError(await readApiError(r));
|
if (!isCancelled()) setError(await readApiError(r));
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const body = (await r.json()) as Grant;
|
const body = (await r.json()) as Grant;
|
||||||
if (cancelled) return;
|
if (isCancelled()) return false;
|
||||||
setGrant(body);
|
setGrant(body);
|
||||||
setError(null);
|
setError(null);
|
||||||
})().catch((e: unknown) => {
|
return true;
|
||||||
if (!cancelled) setError({ detail: String(e) });
|
} catch (e: unknown) {
|
||||||
});
|
if (!isCancelled()) setError({ detail: String(e) });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
void load(() => cancelled);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [agent, reload]);
|
}, [agent, reload]);
|
||||||
|
|
||||||
// Sends one change and reads the grant again; true when the route took it.
|
// Sends one change and reads the grant again before it lets go of `busy`,
|
||||||
|
// so the next change builds on what was stored; true when the route took it.
|
||||||
async function send(init: RequestInit): Promise<boolean> {
|
async function send(init: RequestInit): Promise<boolean> {
|
||||||
|
if (busy) return false;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
try {
|
try {
|
||||||
|
|
@ -100,7 +110,7 @@ function useGrant(agent: string) {
|
||||||
setSaveError(await readApiError(r));
|
setSaveError(await readApiError(r));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
setReload((n) => n + 1);
|
await load(() => false);
|
||||||
return true;
|
return true;
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setSaveError({ detail: String(e) });
|
setSaveError({ detail: String(e) });
|
||||||
|
|
@ -129,14 +139,21 @@ function useGrant(agent: string) {
|
||||||
return { grant, busy, saveError, readFailure, loading, send };
|
return { grant, busy, saveError, readFailure, loading, send };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentPermissions({ agent }: { agent: string }) {
|
export type GrantState = ReturnType<typeof useGrant>;
|
||||||
const { grant, busy, saveError, readFailure, loading, send } =
|
|
||||||
useGrant(agent);
|
export function AgentPermissions({
|
||||||
|
agent,
|
||||||
|
g,
|
||||||
|
}: {
|
||||||
|
agent: string;
|
||||||
|
g: GrantState;
|
||||||
|
}) {
|
||||||
|
const { grant, busy, saveError, loading, send } = g;
|
||||||
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
|
const [catalogue, setCatalogue] = useState<Catalogue | null>(null);
|
||||||
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
|
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
// The boxes as ticked; they follow the stored grant once it is read again.
|
// The boxes as ticked while a change is being saved; null shows the stored grant.
|
||||||
const [selected, setSelected] = useState<string[] | null>(null);
|
const [selected, setSelected] = useState<string[] | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -157,14 +174,9 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// The stored grant changing (a save went through) resets the boxes to it.
|
|
||||||
const stored = grant?.permissions;
|
|
||||||
useEffect(() => {
|
|
||||||
setSelected(null);
|
|
||||||
}, [stored]);
|
|
||||||
|
|
||||||
if (grant === null) return loading;
|
if (grant === null) return loading;
|
||||||
|
|
||||||
|
const stored = grant.permissions;
|
||||||
const ticked =
|
const ticked =
|
||||||
selected ??
|
selected ??
|
||||||
(catalogue
|
(catalogue
|
||||||
|
|
@ -172,12 +184,12 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
||||||
: []);
|
: []);
|
||||||
|
|
||||||
async function toggle(value: string) {
|
async function toggle(value: string) {
|
||||||
if (!catalogue) return;
|
if (!catalogue || busy) return;
|
||||||
const next = togglePermission(ticked, value);
|
const next = togglePermission(ticked, value);
|
||||||
setSelected(next);
|
setSelected(next);
|
||||||
// The default set sends no list, so the agent follows the default.
|
// The default set sends no list, so the agent follows the default.
|
||||||
const list = requestPermissions(next, catalogue.default);
|
const list = requestPermissions(next, catalogue.default);
|
||||||
const ok = await send(
|
await send(
|
||||||
list === undefined
|
list === undefined
|
||||||
? { method: "DELETE" }
|
? { method: "DELETE" }
|
||||||
: {
|
: {
|
||||||
|
|
@ -186,13 +198,12 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
||||||
body: JSON.stringify({ permissions: list }),
|
body: JSON.stringify({ permissions: list }),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// A refused change puts the boxes back as stored.
|
// The grant was read again by now, or the change refused: show what is stored.
|
||||||
if (!ok) setSelected(null);
|
setSelected(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="ui-agent-limits">
|
<div class="ui-agent-limits">
|
||||||
{readFailure}
|
|
||||||
<BadgePopover
|
<BadgePopover
|
||||||
label="permissions"
|
label="permissions"
|
||||||
value={grantSummary(grant.permissions)}
|
value={grantSummary(grant.permissions)}
|
||||||
|
|
@ -212,6 +223,7 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
||||||
options={catalogue.all}
|
options={catalogue.all}
|
||||||
filter={{ values: ticked, negate: false }}
|
filter={{ values: ticked, negate: false }}
|
||||||
onToggle={(v) => void toggle(v)}
|
onToggle={(v) => void toggle(v)}
|
||||||
|
disabled={busy}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -236,15 +248,13 @@ export function AgentPermissions({ agent }: { agent: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AgentInternet({ agent }: { agent: string }) {
|
export function AgentInternet({ agent, g }: { agent: string; g: GrantState }) {
|
||||||
const { grant, busy, saveError, readFailure, loading, send } =
|
const { grant, busy, saveError, loading, send } = g;
|
||||||
useGrant(agent);
|
|
||||||
if (grant === null) return loading;
|
if (grant === null) return loading;
|
||||||
|
|
||||||
const mode = internetMode(grant.unrestricted_internet, grant.internet_log);
|
const mode = internetMode(grant.unrestricted_internet, grant.internet_log);
|
||||||
return (
|
return (
|
||||||
<div class="ui-agent-limits">
|
<div class="ui-agent-limits">
|
||||||
{readFailure}
|
|
||||||
<BadgePopover
|
<BadgePopover
|
||||||
label="internet"
|
label="internet"
|
||||||
value={internetSummary(grant.unrestricted_internet, grant.internet_log)}
|
value={internetSummary(grant.unrestricted_internet, grant.internet_log)}
|
||||||
|
|
|
||||||
|
|
@ -30,10 +30,13 @@ export function MultiselectFilterOptions({
|
||||||
options,
|
options,
|
||||||
filter,
|
filter,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
options: string[];
|
options: string[];
|
||||||
filter: MultiselectFilterState;
|
filter: MultiselectFilterState;
|
||||||
onToggle: (value: string) => void;
|
onToggle: (value: string) => void;
|
||||||
|
/** Greys the boxes out, e.g. while a change is being saved. */
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div class="ui-multiselect-filter-options">
|
<div class="ui-multiselect-filter-options">
|
||||||
|
|
@ -41,6 +44,7 @@ export function MultiselectFilterOptions({
|
||||||
<label key={v} class="ui-multiselect-filter-checkbox">
|
<label key={v} class="ui-multiselect-filter-checkbox">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
disabled={disabled}
|
||||||
checked={filter.values.includes(v)}
|
checked={filter.values.includes(v)}
|
||||||
onChange={() => onToggle(v)}
|
onChange={() => onToggle(v)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue