ankka-console 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,13 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * The front page: who you are, as the control plane sees you, and the organizations you can see —
4
- * yours with your role in each, or every one of them for a platform administrator.
4
+ * yours with your role in each, or every one of them for a platform administrator — and what the
5
+ * installation is: its version, and its cloud when it names one (feature 044).
5
6
  */
6
- import { useLoaderData } from "react-router";
7
- import { guard, pageData, useConsoleContext, withShell } from "../context.js";
7
+ import { redirect, useLoaderData } from "react-router";
8
+ import { act, guard, pageData, text, useConsoleContext, withShell } from "../context.js";
9
+ import { ConsoleForm, Submit, useConsole, when } from "../ui/console.js";
10
+ import { Refused } from "../ui/refused.js";
8
11
  import { ConsoleErrorBoundary } from "../ui/errors.js";
9
12
  import { ConsoleLink } from "../ui/console.js";
10
13
  import { Page } from "../ui/shell.js";
@@ -13,19 +16,45 @@ export const meta = () => [{ title: "Organizations · ankka" }];
13
16
  export async function loader({ context }) {
14
17
  const ctx = useConsoleContext(context);
15
18
  return guard(ctx, async () => {
16
- const [whoami, organizations] = await Promise.all([ctx.client.whoami(), ctx.client.listOrganizations()]);
19
+ const [whoami, organizations, installation, hold] = await Promise.all([
20
+ ctx.client.whoami(),
21
+ ctx.client.listOrganizations(),
22
+ ctx.client.installation(),
23
+ // Whether the control plane is held (feature 041); one that cannot say leaves it out.
24
+ ctx.client.restoreHold().catch(() => undefined),
25
+ ]);
17
26
  const shell = {
18
27
  area: "organizations",
19
28
  crumbs: [{ label: "Organizations" }],
20
29
  primary: { label: "Create an organization", to: "organizations/new", operation: "organization.create" },
21
30
  };
22
- return { console: withShell(await pageData(ctx), shell), whoami, organizations };
31
+ return { console: withShell(await pageData(ctx), shell), whoami, organizations, installation, hold };
32
+ });
33
+ }
34
+ export async function action({ request, context }) {
35
+ const ctx = useConsoleContext(context);
36
+ const form = await request.formData();
37
+ const intent = text(form, "intent");
38
+ return act(ctx, intent, form, async () => {
39
+ if (intent !== "release")
40
+ throw new Response(`unknown operation '${intent}'`, { status: 400 });
41
+ await ctx.client.releaseRestoreHold();
42
+ return redirect(ctx.href(""));
23
43
  });
24
44
  }
25
45
  const roleWords = { owner: "Owner", member: "Member" };
26
46
  export default function Front() {
27
- const { whoami, organizations } = useLoaderData();
47
+ const { whoami, organizations, installation, hold } = useLoaderData();
48
+ const cloud = installation.cloud ?? null;
49
+ const backups = installation.backups ?? null;
28
50
  const hostActions = useHostActions("organization.create");
29
- return (_jsxs(Page, { inspector: hostActions ? (_jsx("div", { className: "ac-ops", children: _jsx(HostActions, { operation: "organization.create" }) })) : null, children: [_jsx("h1", { children: "Organizations" }), _jsxs("p", { className: "ac-lede", children: ["Signed in as ", _jsx("strong", { children: whoami.name ?? whoami.email ?? whoami.subject }), whoami.email && whoami.name ? ` (${whoami.email})` : null, whoami.platformAdmin ? ", a platform administrator: you see every organization in this installation." : "."] }), organizations.length === 0 ? (_jsxs("p", { className: "ac-empty", children: ["You are not a member of any organization yet. Create one, or ask an owner to invite ", whoami.email ?? "you", "."] })) : (_jsx("div", { className: "ac-card ac-table-wrap", children: _jsxs("table", { className: "ac-table", children: [_jsx("caption", { className: "ac-visually-hidden", children: "Organizations you can see" }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Name" }), _jsx("th", { scope: "col", children: "Id" }), _jsx("th", { scope: "col", children: "Your role" }), _jsx("th", { scope: "col", className: "ac-num", children: "Projects" }), _jsx("th", { scope: "col", children: "State" })] }) }), _jsx("tbody", { children: organizations.map((o) => (_jsxs("tr", { children: [_jsx("td", { children: _jsx(ConsoleLink, { to: `organizations/${encodeURIComponent(o.id)}`, children: o.name }) }), _jsx("td", { children: o.id }), _jsx("td", { children: o.role ? roleWords[o.role] : "Administrator (not a member)" }), _jsx("td", { className: "ac-num", children: o.projects }), _jsx("td", { children: o.disabled ? "Disabled" : "Active" })] }, o.id))) })] }) }))] }));
51
+ const { shows } = useConsole();
52
+ return (_jsxs(Page, { inspector: hostActions ? (_jsx("div", { className: "ac-ops", children: _jsx(HostActions, { operation: "organization.create" }) })) : null, children: [_jsx("h1", { children: "Organizations" }), _jsxs("p", { className: "ac-lede", children: ["Signed in as ", _jsx("strong", { children: whoami.name ?? whoami.email ?? whoami.subject }), whoami.email && whoami.name ? ` (${whoami.email})` : null, whoami.platformAdmin ? ", a platform administrator: you see every organization in this installation." : "."] }), hold?.held ? (_jsxs("section", { className: "ac-card", "aria-labelledby": "hold-title", "data-held": "yes", children: [_jsx("h2", { id: "hold-title", children: "The control plane is held" }), _jsxs("p", { children: ["Its database was restored to ", hold.targetTime ? when(hold.targetTime) : "an earlier moment", ", and it changes nothing in the cluster until a platform administrator releases it."] }), hold.services.length > 0 ? (_jsx("ul", { className: "ac-topics", "aria-label": "Services that differ", children: hold.services.map((d) => (_jsxs("li", { "data-differs": `${d.project}/${d.service}`, children: [d.project, "/", d.service, ": recorded ", d.recordedImage ?? "nothing", ", the cluster runs ", d.clusterImage ?? "nothing"] }, `${d.project}/${d.service}`))) })) : null, hold.unknownProjects.length > 0 ? _jsxs("p", { children: ["Projects the database does not know: ", hold.unknownProjects.join(", "), "."] }) : null, whoami.platformAdmin && shows("installation.release") ? (_jsx(ConsoleForm, { intent: "release", children: _jsx(Submit, { intent: "release", children: "Release the control plane" }) })) : null, _jsx(Refused, { intent: "release" })] })) : hold?.releasedAt ? (_jsxs("p", { "data-held": "released", children: ["The control plane was released after its database's restore, ", when(hold.releasedAt), hold.releasedBy ? ` by ${hold.releasedBy}` : "", "."] })) : null, backups ? (_jsx("p", { className: "ac-hint", "data-backups": backups.backupTarget, children: backups.notBackedUp ??
53
+ `Backups are kept at least ${backups.retentionDays} days${backups.sharesFailureDomain ? ", in the cluster they back up" : ", with a copy outside the cluster"}.` })) : null, organizations.length === 0 ? (_jsxs("p", { className: "ac-empty", children: ["You are not a member of any organization yet. Create one, or ask an owner to invite ", whoami.email ?? "you", "."] })) : (_jsx("div", { className: "ac-card ac-table-wrap", children: _jsxs("table", { className: "ac-table", children: [_jsx("caption", { className: "ac-visually-hidden", children: "Organizations you can see" }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Name" }), _jsx("th", { scope: "col", children: "Id" }), _jsx("th", { scope: "col", children: "Your role" }), _jsx("th", { scope: "col", className: "ac-num", children: "Projects" }), _jsx("th", { scope: "col", children: "State" })] }) }), _jsx("tbody", { children: organizations.map((o) => (_jsxs("tr", { children: [_jsx("td", { children: _jsx(ConsoleLink, { to: `organizations/${encodeURIComponent(o.id)}`, children: o.name }) }), _jsx("td", { children: o.id }), _jsx("td", { children: o.role ? roleWords[o.role] : "Administrator (not a member)" }), _jsx("td", { className: "ac-num", children: o.projects }), _jsx("td", { children: o.disabled ? "Disabled" : "Active" })] }, o.id))) })] }) })), _jsxs("section", { className: "ac-card", "aria-labelledby": "installation", children: [_jsx("h2", { id: "installation", children: "This installation" }), _jsxs("dl", { className: "ac-facts", children: [_jsx("dt", { children: "Platform version" }), _jsx("dd", { children: installation.platformVersion }), _jsx("dt", { children: "Cloud" }), _jsx("dd", { children: cloud ? `${cloud.provider}, account ${cloud.account}` : "None: everything is served by the installation itself" }), cloud ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Location" }), _jsx("dd", { children: cloud.location })] })) : null, cloud?.kmsKey ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Wrapping key" }), _jsx("dd", { children: cloud.kmsKey })] })) : null, installation.secrets ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Secrets kept in" }), _jsx("dd", { children: secretBackendWords[installation.secrets.backend] ?? installation.secrets.backend }), _jsx("dt", { children: "Record of reads kept for" }), _jsx("dd", { children: installation.secrets.recordRetention }), _jsx("dt", { children: "Access log" }), _jsx("dd", { children: installation.secrets.auditLog })] })) : null] })] })] }));
30
54
  }
55
+ /** The installation's secret store (feature 038), in words; an unknown backend is shown as it is named. */
56
+ const secretBackendWords = {
57
+ postgres: "Postgres: each service's own database",
58
+ "secret-manager": "Google Secret Manager",
59
+ };
31
60
  export const ErrorBoundary = ConsoleErrorBoundary;
@@ -29,6 +29,19 @@ export declare function loader({ request, params, context }: LoaderFunctionArgs)
29
29
  processPort: number | undefined;
30
30
  broker: string | undefined;
31
31
  undeclaredTopics: string[] | undefined;
32
+ secretStore: {
33
+ backend: string;
34
+ keyRead: boolean;
35
+ move: {
36
+ phase: string;
37
+ outcome: string;
38
+ names: {
39
+ name: string;
40
+ state: string;
41
+ }[];
42
+ detail: string | undefined;
43
+ } | undefined;
44
+ } | undefined;
32
45
  topicChecks: {
33
46
  topic: string | undefined;
34
47
  service: string;
@@ -50,10 +63,54 @@ export declare function loader({ request, params, context }: LoaderFunctionArgs)
50
63
  contract: string | undefined;
51
64
  lag: number | undefined;
52
65
  failing: string | undefined;
66
+ gap: {
67
+ partitions: {
68
+ partition: number;
69
+ beginning: number;
70
+ earliestRetained: string | undefined;
71
+ }[];
72
+ compacted: boolean;
73
+ gone: boolean;
74
+ readAt: string | undefined;
75
+ } | undefined;
76
+ }[] | undefined;
77
+ warnings: {
78
+ kind: string;
79
+ component: string;
80
+ topic: string;
81
+ message: string;
53
82
  }[] | undefined;
54
83
  objectStorage: string | undefined;
55
84
  bucket: string | undefined;
56
85
  bucketAddress: string | undefined;
86
+ objectStore: string | undefined;
87
+ bucketLocation: string | undefined;
88
+ softDeleteDays: number | undefined;
89
+ storageMove: string | undefined;
90
+ databaseCluster: string | undefined;
91
+ grants: string | undefined;
92
+ crossProjectTopics: {
93
+ project: string;
94
+ topic: string;
95
+ right: string;
96
+ status: string;
97
+ }[] | undefined;
98
+ customHostnames: {
99
+ hostname: string;
100
+ state: string;
101
+ reason: string | undefined;
102
+ record: {
103
+ name: string;
104
+ kind: string;
105
+ value: string;
106
+ } | undefined;
107
+ note: string | undefined;
108
+ }[];
109
+ proofRecord: {
110
+ name: string;
111
+ kind: string;
112
+ value: string;
113
+ } | undefined;
57
114
  };
58
115
  project: {
59
116
  id: string;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * An organization's machines (feature 040): programs outside the installation that hold a grant —
3
+ * an affiliate network reading a topic, say — each with a client id and a secret it trades for a
4
+ * token. A new machine's secret is shown exactly once, as a deploy token's is: the registration
5
+ * redirects (so a reload can never register twice), carrying the secret across in a sealed cookie
6
+ * that this page reads and clears on its next render.
7
+ */
8
+ import { type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "react-router";
9
+ import { ConsoleErrorBoundary } from "../ui/errors.tsx";
10
+ export declare const meta: MetaFunction;
11
+ export declare function loader({ request, params, context }: LoaderFunctionArgs): Promise<{
12
+ console: import("../context.ts").ConsolePageData;
13
+ organization: {
14
+ id: string;
15
+ name: string;
16
+ projects: number;
17
+ disabled: boolean;
18
+ role: NonNullable<"member" | "owner"> | undefined;
19
+ quota: {
20
+ projects: number | undefined;
21
+ services: number | undefined;
22
+ instances: number | undefined;
23
+ } | undefined;
24
+ usage: {
25
+ projects: number;
26
+ services: number;
27
+ instances: number;
28
+ };
29
+ };
30
+ machines: {
31
+ name: string;
32
+ clientId: string;
33
+ registeredBy: string | undefined;
34
+ registeredAt: string | undefined;
35
+ byteRates: {
36
+ produceBytesPerSecond: number;
37
+ consumeBytesPerSecond: number;
38
+ requestPercentage: number;
39
+ } | undefined;
40
+ }[];
41
+ grants: {
42
+ id: string;
43
+ grantingProject: string;
44
+ grantingOrganization: string;
45
+ grantee: string;
46
+ target: {
47
+ kind: string;
48
+ service: string | undefined;
49
+ method: string | undefined;
50
+ path: string | undefined;
51
+ topic: string | undefined;
52
+ right: string | undefined;
53
+ decrypt: boolean;
54
+ };
55
+ state: string;
56
+ changes: {
57
+ change: string;
58
+ by: string | undefined;
59
+ at: string | undefined;
60
+ }[];
61
+ topic: {
62
+ partitions: number;
63
+ compacted: boolean;
64
+ retention: string | undefined;
65
+ copies: number | undefined;
66
+ } | undefined;
67
+ }[];
68
+ registered: {
69
+ name: string;
70
+ clientId: string;
71
+ clientSecret: string;
72
+ tokenUrl: string;
73
+ brokerBootstrap: string | undefined;
74
+ } | null;
75
+ }>;
76
+ export declare function action({ request, params, context }: ActionFunctionArgs): Promise<import("react-router").UNSAFE_DataWithResponseInit<import("../context.ts").ActionRefusal> | Response>;
77
+ export default function Machines(): import("react").JSX.Element;
78
+ export declare const ErrorBoundary: typeof ConsoleErrorBoundary;
@@ -0,0 +1,78 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * An organization's machines (feature 040): programs outside the installation that hold a grant —
4
+ * an affiliate network reading a topic, say — each with a client id and a secret it trades for a
5
+ * token. A new machine's secret is shown exactly once, as a deploy token's is: the registration
6
+ * redirects (so a reload can never register twice), carrying the secret across in a sealed cookie
7
+ * that this page reads and clears on its next render.
8
+ */
9
+ import { redirect, useLoaderData } from "react-router";
10
+ import { act, guard, organizationShell, pageData, text, useConsoleContext } from "../context.js";
11
+ import { targetText } from "../ui/grants.js";
12
+ import { ConsoleErrorBoundary } from "../ui/errors.js";
13
+ import { ConsoleForm, Field, Submit, useConsole, when } from "../ui/console.js";
14
+ import { Page, SectionTitle } from "../ui/shell.js";
15
+ import { Refused, useRefusal } from "../ui/refused.js";
16
+ import { HostActions } from "../extensions/render.js";
17
+ export const meta = () => [{ title: "Machines · ankka" }];
18
+ export async function loader({ request, params, context }) {
19
+ const ctx = useConsoleContext(context);
20
+ const id = params.organizationId;
21
+ return guard(ctx, async () => {
22
+ const [organization, machines, grants, projects, page] = await Promise.all([
23
+ ctx.client.getOrganization(id),
24
+ ctx.client.machines(id),
25
+ ctx.client.organizationGrants(id),
26
+ ctx.client.listProjects(),
27
+ pageData(ctx),
28
+ ]);
29
+ const registered = await ctx.runtime.machineFlash.take(request, ctx.headers);
30
+ // Shown from the one-time cookie alone: the listing is a projection and may not show the new machine yet.
31
+ return {
32
+ console: organizationShell(page, organization, projects, "machines", [{ label: "Machines" }]),
33
+ organization,
34
+ machines,
35
+ grants,
36
+ registered,
37
+ };
38
+ });
39
+ }
40
+ export async function action({ request, params, context }) {
41
+ const ctx = useConsoleContext(context);
42
+ const id = params.organizationId;
43
+ const form = await request.formData();
44
+ const intent = text(form, "intent");
45
+ const self = ctx.href(`organizations/${encodeURIComponent(id)}/machines`);
46
+ return act(ctx, intent, form, async () => {
47
+ if (intent === "register") {
48
+ const registered = await ctx.client.registerMachine(id, text(form, "machineName"));
49
+ await ctx.runtime.machineFlash.write(registered, ctx.headers);
50
+ return redirect(self);
51
+ }
52
+ if (intent === "delete") {
53
+ await ctx.client.deleteMachine(id, text(form, "machineName"));
54
+ return redirect(self);
55
+ }
56
+ if (intent === "byte-rates") {
57
+ await ctx.client.setMachineByteRates(id, text(form, "rateMachine"), {
58
+ produceBytesPerSecond: Number(text(form, "produceBytesPerSecond")),
59
+ consumeBytesPerSecond: Number(text(form, "consumeBytesPerSecond")),
60
+ requestPercentage: Number(text(form, "requestPercentage")),
61
+ });
62
+ return redirect(self);
63
+ }
64
+ throw new Response(`unknown operation '${intent}'`, { status: 400 });
65
+ });
66
+ }
67
+ export default function Machines() {
68
+ const { organization: o, machines, grants, registered } = useLoaderData();
69
+ const { shows, shell } = useConsole();
70
+ const manages = shell.organization?.manages ?? false;
71
+ const registerRefusal = useRefusal("register");
72
+ const ratesRefusal = useRefusal("byte-rates");
73
+ const inspector = manages ? (_jsxs(_Fragment, { children: [shows("machine.register") ? (_jsxs("section", { "aria-labelledby": "register-machine", className: "ac-form", children: [_jsx(SectionTitle, { children: _jsx("span", { id: "register-machine", children: "Register a machine" }) }), _jsxs(ConsoleForm, { intent: "register", className: "ac-inline", children: [_jsx(Field, { label: "Machine name", name: "machineName", required: true, placeholder: "affiliate-network", hint: "Lowercase letters, digits and '-'. A grant names it as machine:<organization>/<name>.", defaultValue: registerRefusal?.values.machineName, autoComplete: "off" }), _jsx(Submit, { intent: "register", primary: true, children: "Register machine" })] }), _jsx(Refused, { intent: "register" }), _jsx("div", { className: "ac-ops", children: _jsx(HostActions, { operation: "machine.register", entity: o }) })] })) : null, shows("machine.byte-rates") && machines.length > 0 ? (_jsxs("section", { "aria-labelledby": "byte-rates-title", className: "ac-form", children: [_jsx(SectionTitle, { children: _jsx("span", { id: "byte-rates-title", children: "Byte rates" }) }), _jsxs("details", { className: "ac-more", open: ratesRefusal !== undefined || undefined, children: [_jsx("summary", { children: "Limit a machine on the broker" }), _jsxs(ConsoleForm, { intent: "byte-rates", className: "ac-form", children: [_jsxs("div", { className: "ac-field", children: [_jsx("label", { htmlFor: "rateMachine", children: "Machine" }), _jsx("select", { id: "rateMachine", name: "rateMachine", defaultValue: ratesRefusal?.values.rateMachine ?? machines[0]?.name, children: machines.map((m) => (_jsx("option", { value: m.name, children: m.name }, m.name))) })] }), _jsx(Field, { label: "Produce bytes a second", name: "produceBytesPerSecond", type: "number", required: true, min: 1, defaultValue: ratesRefusal?.values.produceBytesPerSecond }), _jsx(Field, { label: "Consume bytes a second", name: "consumeBytesPerSecond", type: "number", required: true, min: 1, defaultValue: ratesRefusal?.values.consumeBytesPerSecond }), _jsx(Field, { label: "Request percentage", name: "requestPercentage", type: "number", required: true, min: 1, max: 100, hint: "The share of a broker thread's time its requests may take, 1 to 100.", defaultValue: ratesRefusal?.values.requestPercentage }), _jsx(Refused, { intent: "byte-rates" }), _jsx("div", { children: _jsx(Submit, { intent: "byte-rates", children: "Set byte rates" }) })] })] })] })) : null] })) : null;
74
+ return (_jsxs(Page, { inspector: inspector, children: [_jsxs("h1", { children: ["Machines of ", o.name] }), _jsx("p", { className: "ac-lede", children: "A machine is a program outside the installation that another project can grant a route, a method or a topic. It signs in with its client id and secret; owners register, limit and delete machines, and every member can see them." }), registered ? (_jsxs("div", { className: "ac-notice", role: "status", children: [_jsxs("p", { children: [_jsxs("strong", { children: ["Copy the client secret for ", registered.name, " now."] }), " It is shown this once and cannot be retrieved later."] }), _jsxs("dl", { className: "ac-facts", children: [_jsx("dt", { children: "Client id" }), _jsx("dd", { children: _jsx("code", { children: registered.clientId }) }), _jsx("dt", { children: "Token address" }), _jsx("dd", { children: _jsx("code", { children: registered.tokenUrl }) }), registered.brokerBootstrap ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Broker" }), _jsx("dd", { children: _jsx("code", { children: registered.brokerBootstrap }) })] })) : null] }), _jsx("code", { className: "ac-secret", "data-secret": true, children: registered.clientSecret })] })) : null, _jsxs("section", { className: "ac-card", "aria-label": "Machines", children: [machines.length === 0 ? (_jsx("p", { className: "ac-empty", children: "No machines registered." })) : (_jsx("div", { className: "ac-table-wrap", children: _jsxs("table", { className: "ac-table", children: [_jsx("caption", { className: "ac-visually-hidden", children: "Machines" }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Name" }), _jsx("th", { scope: "col", children: "Client id" }), _jsx("th", { scope: "col", children: "Registered" }), _jsx("th", { scope: "col", children: "Byte rates" }), _jsx("th", { scope: "col", children: _jsx("span", { className: "ac-visually-hidden", children: "Actions" }) })] }) }), _jsx("tbody", { children: machines.map((m) => (_jsxs("tr", { "data-machine": m.name, children: [_jsx("td", { children: m.name }), _jsx("td", { children: _jsx("code", { children: m.clientId }) }), _jsxs("td", { children: [m.registeredAt ? when(m.registeredAt) : "—", m.registeredBy ? ` by ${m.registeredBy}` : ""] }), _jsx("td", { "data-byte-rates": true, children: m.byteRates
75
+ ? `produce ${m.byteRates.produceBytesPerSecond} B/s, consume ${m.byteRates.consumeBytesPerSecond} B/s, ${m.byteRates.requestPercentage}% of requests`
76
+ : "The installation's default" }), _jsx("td", { children: manages && shows("machine.delete") ? (_jsxs(ConsoleForm, { intent: "delete", children: [_jsx("input", { type: "hidden", name: "machineName", value: m.name }), _jsx(Submit, { intent: "delete", className: "ac-button ac-button-small ac-button-danger", label: `Delete ${m.name}`, children: "Delete" })] })) : null })] }, m.name))) })] }) })), _jsx(Refused, { intent: "delete" })] }), _jsxs("section", { className: "ac-card", "aria-labelledby": "held", children: [_jsx("h2", { id: "held", children: "Grants held" }), grants.length === 0 ? (_jsx("p", { className: "ac-empty", children: "Nothing is granted to this organization's machines or services by another project." })) : (_jsx("div", { className: "ac-table-wrap", children: _jsxs("table", { className: "ac-table", "aria-describedby": "held", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "From" }), _jsx("th", { scope: "col", children: "Grantee" }), _jsx("th", { scope: "col", children: "Target" }), _jsx("th", { scope: "col", children: "State" })] }) }), _jsx("tbody", { children: grants.map((g) => (_jsxs("tr", { "data-held": g.id, children: [_jsxs("td", { children: [g.grantingProject, " ", _jsxs("span", { className: "ac-hint", children: ["of ", g.grantingOrganization] })] }), _jsx("td", { children: _jsx("code", { children: g.grantee }) }), _jsx("td", { children: targetText(g.target) }), _jsx("td", { children: g.state })] }, g.id))) })] }) }))] })] }));
77
+ }
78
+ export const ErrorBoundary = ConsoleErrorBoundary;
@@ -1,5 +1,6 @@
1
1
  /**
2
- * An organization: its projects, and what its owners and the platform administrator may do to it.
2
+ * An organization: its projects, the grants other organizations' projects offer it (feature 040), and
3
+ * what its owners and the platform administrator may do to it.
3
4
  */
4
5
  import { type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "react-router";
5
6
  import { ConsoleErrorBoundary } from "../ui/errors.tsx";
@@ -35,6 +36,33 @@ export declare function loader({ params, context }: LoaderFunctionArgs): Promise
35
36
  setBy: string | undefined;
36
37
  } | undefined;
37
38
  }[];
39
+ offered: {
40
+ id: string;
41
+ grantingProject: string;
42
+ grantingOrganization: string;
43
+ grantee: string;
44
+ target: {
45
+ kind: string;
46
+ service: string | undefined;
47
+ method: string | undefined;
48
+ path: string | undefined;
49
+ topic: string | undefined;
50
+ right: string | undefined;
51
+ decrypt: boolean;
52
+ };
53
+ state: string;
54
+ changes: {
55
+ change: string;
56
+ by: string | undefined;
57
+ at: string | undefined;
58
+ }[];
59
+ topic: {
60
+ partitions: number;
61
+ compacted: boolean;
62
+ retention: string | undefined;
63
+ copies: number | undefined;
64
+ } | undefined;
65
+ }[];
38
66
  ownerless: boolean;
39
67
  panels: import("../extensions/render.tsx").LoadedPanels;
40
68
  }>;
@@ -1,20 +1,27 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * An organization: its projects, and what its owners and the platform administrator may do to it.
3
+ * An organization: its projects, the grants other organizations' projects offer it (feature 040), and
4
+ * what its owners and the platform administrator may do to it.
4
5
  */
5
6
  import { redirect, useLoaderData } from "react-router";
6
7
  import { act, guard, organizationShell, pageData, text, useConsoleContext } from "../context.js";
7
8
  import { ConsoleErrorBoundary } from "../ui/errors.js";
8
- import { ConsoleForm, ConsoleLink, Field, Submit, useConsole } from "../ui/console.js";
9
+ import { ConsoleForm, ConsoleLink, Field, Submit, useConsole, when } from "../ui/console.js";
9
10
  import { Page } from "../ui/shell.js";
10
11
  import { Refused, useRefusal } from "../ui/refused.js";
11
12
  import { HostActions, loadPanels, Panels } from "../extensions/render.js";
13
+ import { targetText } from "../ui/grants.js";
12
14
  export const meta = ({ loaderData }) => [{ title: `${loaderData?.organization.name ?? "Organization"} · ankka` }];
13
15
  export async function loader({ params, context }) {
14
16
  const ctx = useConsoleContext(context);
15
17
  const id = params.organizationId;
16
18
  return guard(ctx, async () => {
17
- const [organization, projects, page] = await Promise.all([ctx.client.getOrganization(id), ctx.client.listProjects(), pageData(ctx)]);
19
+ const [organization, projects, offered, page] = await Promise.all([
20
+ ctx.client.getOrganization(id),
21
+ ctx.client.listProjects(),
22
+ ctx.client.organizationGrants(id),
23
+ pageData(ctx),
24
+ ]);
18
25
  // An organization whose owners have all left can be given one by an administrator.
19
26
  const ownerless = page.principal?.platformAdmin ? !(await ctx.client.members(id)).members.some((m) => m.role === "owner") : false;
20
27
  const own = projects.filter((p) => p.organizationId === id);
@@ -26,6 +33,7 @@ export async function loader({ params, context }) {
26
33
  }),
27
34
  organization,
28
35
  projects: own,
36
+ offered,
29
37
  ownerless,
30
38
  panels: await loadPanels(ctx, "organization", organization),
31
39
  };
@@ -66,14 +74,25 @@ export async function action({ request, params, context }) {
66
74
  case "repair":
67
75
  await ctx.client.repair(id, text(form, "subject"), "owner");
68
76
  return redirect(self);
77
+ case "grant-accept":
78
+ await ctx.client.answerGrant(id, text(form, "grantId"), "accept");
79
+ return redirect(self);
80
+ case "grant-decline":
81
+ await ctx.client.answerGrant(id, text(form, "grantId"), "decline");
82
+ return redirect(self);
83
+ case "grant-relinquish":
84
+ await ctx.client.answerGrant(id, text(form, "grantId"), "relinquish");
85
+ return redirect(self);
69
86
  default:
70
87
  throw new Response(`unknown operation '${intent}'`, { status: 400 });
71
88
  }
72
89
  });
73
90
  }
74
91
  const limit = (n, what) => (n === undefined ? `any number of ${what}` : `${n} ${what}`);
92
+ /** A grant in one phrase for a control's accessible name: what it opens, to whom, from where. */
93
+ const grantName = (g) => `${targetText(g.target)} to ${g.grantee} from ${g.grantingProject}`;
75
94
  export default function Organization() {
76
- const { organization: o, projects, ownerless, panels, console: page } = useLoaderData();
95
+ const { organization: o, projects, offered, ownerless, panels, console: page } = useLoaderData();
77
96
  const { shows } = useConsole();
78
97
  const admin = page.principal?.platformAdmin ?? false;
79
98
  const owner = o.role === "owner" || admin;
@@ -84,6 +103,9 @@ export default function Organization() {
84
103
  const inspector = (_jsxs(_Fragment, { children: [_jsx(HostActions, { operation: "project.create", entity: o }), admin ? (_jsxs("details", { className: "ac-more", open: adminRefused || undefined, children: [_jsx("summary", { children: "Platform administration" }), _jsx("div", { className: "ac-ops", children: o.disabled
85
104
  ? shows("organization.enable") && (_jsx(ConsoleForm, { intent: "enable", children: _jsx(Submit, { intent: "enable", children: "Enable organization" }) }))
86
105
  : shows("organization.disable") && (_jsx(ConsoleForm, { intent: "disable", children: _jsx(Submit, { intent: "disable", danger: true, children: "Disable organization" }) })) }), _jsx(Refused, { intent: "disable" }), _jsx(Refused, { intent: "enable" }), shows("organization.quota.set") ? (_jsxs(ConsoleForm, { intent: "quota-set", className: "ac-inline", "aria-label": "Quota", children: [_jsx(Field, { label: "Projects", name: "projects", type: "number", min: 0, defaultValue: o.quota?.projects, hint: "Empty for no limit" }), _jsx(Field, { label: "Services", name: "services", type: "number", min: 0, defaultValue: o.quota?.services }), _jsx(Field, { label: "Instances", name: "instances", type: "number", min: 0, defaultValue: o.quota?.instances }), _jsx(Submit, { intent: "quota-set", children: "Set quota" })] })) : null, _jsx(Refused, { intent: "quota-set" }), o.quota && shows("organization.quota.clear") ? (_jsx(ConsoleForm, { intent: "quota-clear", children: _jsx(Submit, { intent: "quota-clear", children: "Clear quota" }) })) : null, ownerless && shows("member.repair") ? (_jsxs(ConsoleForm, { intent: "repair", className: "ac-inline", children: [_jsx(Field, { label: "Subject to make owner", name: "subject", required: true, hint: "This organization has no owner. Name the subject of the person to make one." }), _jsx(Submit, { intent: "repair", children: "Add owner" })] })) : null, _jsx(Refused, { intent: "repair" })] })) : null, owner && (shows("organization.rename") || shows("organization.delete")) ? (_jsxs("details", { className: "ac-more", open: renameRefusal !== undefined || deleteRefusal !== undefined || undefined, children: [_jsx("summary", { children: "Rename or delete" }), shows("organization.rename") ? (_jsxs(ConsoleForm, { intent: "rename", className: "ac-inline", children: [_jsx(Field, { label: "New name", name: "name", required: true, defaultValue: renameRefusal?.values.name ?? o.name }), _jsx(Submit, { intent: "rename", children: "Rename" })] })) : null, _jsx(Refused, { intent: "rename" }), shows("organization.delete") ? (_jsxs("div", { className: "ac-danger-zone", children: [_jsxs("p", { children: ["Deleting ", o.name, " is permanent, and its id can never be used again. It must have no projects."] }), _jsx(ConsoleForm, { intent: "delete", children: _jsx(Submit, { intent: "delete", danger: true, children: "Delete organization" }) }), _jsx(Refused, { intent: "delete" })] })) : null, _jsx(HostActions, { operation: "organization.delete", entity: o })] })) : null] }));
87
- return (_jsxs(Page, { inspector: inspector, children: [_jsx("h1", { children: o.name }), _jsxs("dl", { className: "ac-card ac-facts", children: [_jsx("dt", { children: "Id" }), _jsx("dd", { children: o.id }), _jsx("dt", { children: "Your role" }), _jsx("dd", { children: o.role === "owner" ? "Owner" : o.role === "member" ? "Member" : "Administrator (not a member)" }), _jsx("dt", { children: "State" }), _jsx("dd", { children: o.disabled ? "Disabled: its services are suspended and every change is refused" : "Active" }), _jsx("dt", { children: "Holds" }), _jsxs("dd", { children: [o.usage.projects, " projects, ", o.usage.services, " services, ", o.usage.instances, " instances"] }), o.quota ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Quota" }), _jsxs("dd", { children: [limit(o.quota.projects, "projects"), ", ", limit(o.quota.services, "services"), ", ", limit(o.quota.instances, "instances")] })] })) : null] }), _jsxs("section", { className: "ac-card", children: [_jsx("h2", { children: "Projects" }), projects.length === 0 ? (_jsx("p", { className: "ac-empty", children: "No projects yet. A project groups services and gives them a namespace of their own." })) : (_jsx("div", { className: "ac-table-wrap", children: _jsxs("table", { className: "ac-table", children: [_jsxs("caption", { className: "ac-visually-hidden", children: ["Projects in ", o.name] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Name" }), _jsx("th", { scope: "col", children: "Id" }), _jsx("th", { scope: "col", className: "ac-num", children: "Services" }), _jsx("th", { scope: "col", children: "Registry" })] }) }), _jsx("tbody", { children: projects.map((p) => (_jsxs("tr", { children: [_jsx("td", { children: _jsx(ConsoleLink, { to: `projects/${encodeURIComponent(p.id)}`, children: p.name }) }), _jsx("td", { children: p.id }), _jsx("td", { className: "ac-num", children: p.services }), _jsx("td", { children: p.registry ? p.registry.server : "Public images only" })] }, p.id))) })] }) }))] }), _jsx(Panels, { kind: "organization", entity: o, loaded: panels })] }));
106
+ return (_jsxs(Page, { inspector: inspector, children: [_jsx("h1", { children: o.name }), _jsxs("dl", { className: "ac-card ac-facts", children: [_jsx("dt", { children: "Id" }), _jsx("dd", { children: o.id }), _jsx("dt", { children: "Your role" }), _jsx("dd", { children: o.role === "owner" ? "Owner" : o.role === "member" ? "Member" : "Administrator (not a member)" }), _jsx("dt", { children: "State" }), _jsx("dd", { children: o.disabled ? "Disabled: its services are suspended and every change is refused" : "Active" }), _jsx("dt", { children: "Holds" }), _jsxs("dd", { children: [o.usage.projects, " projects, ", o.usage.services, " services, ", o.usage.instances, " instances"] }), o.quota ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Quota" }), _jsxs("dd", { children: [limit(o.quota.projects, "projects"), ", ", limit(o.quota.services, "services"), ", ", limit(o.quota.instances, "instances")] })] })) : null] }), _jsxs("section", { className: "ac-card", children: [_jsx("h2", { children: "Projects" }), projects.length === 0 ? (_jsx("p", { className: "ac-empty", children: "No projects yet. A project groups services and gives them a namespace of their own." })) : (_jsx("div", { className: "ac-table-wrap", children: _jsxs("table", { className: "ac-table", children: [_jsxs("caption", { className: "ac-visually-hidden", children: ["Projects in ", o.name] }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Name" }), _jsx("th", { scope: "col", children: "Id" }), _jsx("th", { scope: "col", className: "ac-num", children: "Services" }), _jsx("th", { scope: "col", children: "Registry" })] }) }), _jsx("tbody", { children: projects.map((p) => (_jsxs("tr", { children: [_jsx("td", { children: _jsx(ConsoleLink, { to: `projects/${encodeURIComponent(p.id)}`, children: p.name }) }), _jsx("td", { children: p.id }), _jsx("td", { className: "ac-num", children: p.services }), _jsx("td", { children: p.registry ? p.registry.server : "Public images only" })] }, p.id))) })] }) }))] }), _jsxs("section", { className: "ac-card", "aria-labelledby": "offered", children: [_jsx("h2", { id: "offered", children: "Offered grants" }), offered.length === 0 ? (_jsx("p", { className: "ac-empty", children: "No other organization's project offers this organization's machines or services anything. A grant from another organization waits here until an owner accepts it." })) : (_jsx("div", { className: "ac-table-wrap", children: _jsxs("table", { className: "ac-table", "aria-describedby": "offered", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "From" }), _jsx("th", { scope: "col", children: "Grantee" }), _jsx("th", { scope: "col", children: "Target" }), _jsx("th", { scope: "col", children: "State" }), _jsx("th", { scope: "col", children: "Last change" }), _jsx("th", { scope: "col", children: _jsx("span", { className: "ac-visually-hidden", children: "Actions" }) })] }) }), _jsx("tbody", { children: offered.map((g) => {
107
+ const last = g.changes[g.changes.length - 1];
108
+ return (_jsxs("tr", { "data-offered": g.id, "data-state": g.state, children: [_jsxs("td", { children: [g.grantingProject, " ", _jsxs("span", { className: "ac-hint", children: ["of ", g.grantingOrganization] })] }), _jsx("td", { children: _jsx("code", { children: g.grantee }) }), _jsx("td", { children: targetText(g.target) }), _jsx("td", { children: g.state }), _jsx("td", { children: last ? (_jsxs(_Fragment, { children: [last.change, last.at ? ` ${when(last.at)}` : "", last.by ? ` by ${last.by}` : ""] })) : ("—") }), _jsxs("td", { children: [owner && g.state === "pending" && shows("grant.answer") ? (_jsxs("div", { className: "ac-ops", children: [_jsxs(ConsoleForm, { intent: "grant-accept", className: "ac-inline", children: [_jsx("input", { type: "hidden", name: "grantId", value: g.id }), _jsx(Submit, { intent: "grant-accept", label: `Accept ${grantName(g)}`, children: "Accept" })] }), _jsxs(ConsoleForm, { intent: "grant-decline", className: "ac-inline", children: [_jsx("input", { type: "hidden", name: "grantId", value: g.id }), _jsx(Submit, { intent: "grant-decline", label: `Decline ${grantName(g)}`, children: "Decline" })] })] })) : null, owner && g.state === "accepted" && shows("grant.relinquish") ? (_jsxs(ConsoleForm, { intent: "grant-relinquish", className: "ac-inline", children: [_jsx("input", { type: "hidden", name: "grantId", value: g.id }), _jsx(Submit, { intent: "grant-relinquish", danger: true, label: `Relinquish ${grantName(g)}`, children: "Relinquish" })] })) : null] })] }, g.id));
109
+ }) })] }) })), _jsx(Refused, { intent: "grant-accept" }), _jsx(Refused, { intent: "grant-decline" }), _jsx(Refused, { intent: "grant-relinquish" })] }), _jsx(Panels, { kind: "organization", entity: o, loaded: panels })] }));
88
110
  }
89
111
  export const ErrorBoundary = ConsoleErrorBoundary;