ankka-console 0.10.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.
Files changed (75) hide show
  1. package/dist/client/control-plane.d.ts +87 -1
  2. package/dist/client/control-plane.js +201 -1
  3. package/dist/client/schemas.d.ts +1835 -0
  4. package/dist/client/schemas.js +595 -0
  5. package/dist/context.d.ts +98 -0
  6. package/dist/context.js +73 -0
  7. package/dist/extensions/render.d.ts +2 -0
  8. package/dist/extensions/render.js +5 -0
  9. package/dist/extensions/types.d.ts +1 -1
  10. package/dist/extensions/types.js +29 -0
  11. package/dist/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
  12. package/dist/fonts/inter-latin-wght-normal.woff2 +0 -0
  13. package/dist/index.d.ts +9 -2
  14. package/dist/index.js +7 -1
  15. package/dist/routes/auth.sign-out.js +8 -3
  16. package/dist/routes/front.d.ts +62 -2
  17. package/dist/routes/front.js +45 -10
  18. package/dist/routes/logs.d.ts +92 -0
  19. package/dist/routes/logs.js +15 -14
  20. package/dist/routes/machines.d.ts +78 -0
  21. package/dist/routes/machines.js +78 -0
  22. package/dist/routes/members.js +11 -8
  23. package/dist/routes/organization-new.js +7 -4
  24. package/dist/routes/organization.d.ts +29 -1
  25. package/dist/routes/organization.js +38 -9
  26. package/dist/routes/project-new.js +8 -4
  27. package/dist/routes/project.d.ts +347 -2
  28. package/dist/routes/project.js +182 -9
  29. package/dist/routes/service-apply.d.ts +5 -2
  30. package/dist/routes/service-apply.js +17 -14
  31. package/dist/routes/service-history.d.ts +32 -0
  32. package/dist/routes/service-history.js +82 -0
  33. package/dist/routes/service-topology.d.ts +210 -0
  34. package/dist/routes/service-topology.js +54 -0
  35. package/dist/routes/service.d.ts +91 -10
  36. package/dist/routes/service.js +87 -23
  37. package/dist/routes/stream.service.js +18 -1
  38. package/dist/routes/tokens.js +8 -6
  39. package/dist/routes.js +3 -0
  40. package/dist/runtime.d.ts +3 -1
  41. package/dist/runtime.js +1 -0
  42. package/dist/styles.css +2 -481
  43. package/dist/testing/fake-control-plane.d.ts +334 -1
  44. package/dist/testing/fake-control-plane.js +1194 -15
  45. package/dist/testing/index.d.ts +1 -1
  46. package/dist/testing/scenarios.js +6 -0
  47. package/dist/theme.css +66 -0
  48. package/dist/ui/console.d.ts +19 -6
  49. package/dist/ui/console.js +9 -2
  50. package/dist/ui/errors.js +7 -5
  51. package/dist/ui/grants.d.ts +3 -0
  52. package/dist/ui/grants.js +13 -0
  53. package/dist/ui/hosting.d.ts +11 -0
  54. package/dist/ui/hosting.js +20 -0
  55. package/dist/ui/primitives/button.d.ts +11 -0
  56. package/dist/ui/primitives/button.js +21 -0
  57. package/dist/ui/primitives/select.d.ts +10 -0
  58. package/dist/ui/primitives/select.js +4 -0
  59. package/dist/ui/primitives/switch.d.ts +9 -0
  60. package/dist/ui/primitives/switch.js +4 -0
  61. package/dist/ui/shape.d.ts +4 -0
  62. package/dist/ui/shape.js +36 -0
  63. package/dist/ui/shell.d.ts +76 -0
  64. package/dist/ui/shell.js +114 -0
  65. package/dist/ui/tokens.d.ts +82 -0
  66. package/dist/ui/tokens.js +172 -0
  67. package/dist/ui/topology/Graph.d.ts +13 -0
  68. package/dist/ui/topology/Graph.js +56 -0
  69. package/dist/ui/topology/Table.d.ts +10 -0
  70. package/dist/ui/topology/Table.js +14 -0
  71. package/dist/ui/topology/layout.d.ts +113 -0
  72. package/dist/ui/topology/layout.js +177 -0
  73. package/dist/ui/use-stream.d.ts +251 -1
  74. package/dist/ui/use-stream.js +11 -1
  75. package/package.json +11 -3
@@ -0,0 +1,210 @@
1
+ import { type LoaderFunctionArgs, type MetaFunction } from "react-router";
2
+ import { ConsoleErrorBoundary } from "../ui/errors.tsx";
3
+ export declare const meta: MetaFunction<typeof loader>;
4
+ export declare function loader({ params, context }: LoaderFunctionArgs): Promise<{
5
+ console: import("../context.ts").ConsolePageData;
6
+ service: {
7
+ name: string;
8
+ projectId: string;
9
+ lifecycle: string;
10
+ generation: number;
11
+ image: string;
12
+ readyInstances: number;
13
+ desiredInstances: number;
14
+ detail: string | undefined;
15
+ confirmed: boolean;
16
+ database: string | undefined;
17
+ hostname: string | undefined;
18
+ exposed: boolean;
19
+ suspended: boolean;
20
+ paused: boolean;
21
+ hosting: string;
22
+ protocol: string | undefined;
23
+ mounts: {
24
+ path: string;
25
+ service: string;
26
+ state: string;
27
+ }[];
28
+ callers: string[];
29
+ processPort: number | undefined;
30
+ broker: string | undefined;
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;
45
+ topicChecks: {
46
+ topic: string | undefined;
47
+ service: string;
48
+ component: string;
49
+ direction: string;
50
+ stated: string | undefined;
51
+ state: string;
52
+ }[] | undefined;
53
+ topicSources: {
54
+ kind: string;
55
+ component: string;
56
+ topic: string;
57
+ group: string;
58
+ start: string;
59
+ version: number;
60
+ recordedVersion: number | undefined;
61
+ behind: boolean;
62
+ broker: string | undefined;
63
+ contract: string | undefined;
64
+ lag: number | undefined;
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;
82
+ }[] | undefined;
83
+ objectStorage: string | undefined;
84
+ bucket: string | undefined;
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;
114
+ };
115
+ project: {
116
+ id: string;
117
+ name: string;
118
+ organizationId: string;
119
+ registry: {
120
+ server: string;
121
+ username: string;
122
+ setAt: string | undefined;
123
+ setBy: string | undefined;
124
+ } | undefined;
125
+ };
126
+ organization: {
127
+ id: string;
128
+ name: string;
129
+ projects: number;
130
+ disabled: boolean;
131
+ role: NonNullable<"member" | "owner"> | undefined;
132
+ quota: {
133
+ projects: number | undefined;
134
+ services: number | undefined;
135
+ instances: number | undefined;
136
+ } | undefined;
137
+ usage: {
138
+ projects: number;
139
+ services: number;
140
+ instances: number;
141
+ };
142
+ };
143
+ topology: {
144
+ service: string;
145
+ running: number;
146
+ contributing: number;
147
+ partial: boolean;
148
+ instances: {
149
+ pod: string;
150
+ status: "failed" | "ok" | "unreachable" | "unsupported";
151
+ problem: string | undefined;
152
+ runtime: string | undefined;
153
+ readAt: string | undefined;
154
+ }[];
155
+ window: {
156
+ seconds: number;
157
+ since: string;
158
+ calls: number;
159
+ unanswered: number;
160
+ };
161
+ nodes: {
162
+ id: string;
163
+ kind: string;
164
+ layer: number;
165
+ platform: boolean;
166
+ handlers: {
167
+ name: string;
168
+ type: string;
169
+ streaming: boolean | undefined;
170
+ }[];
171
+ }[];
172
+ declared: {
173
+ from: string;
174
+ to: string;
175
+ kind: string;
176
+ }[];
177
+ calls: {
178
+ from: string;
179
+ to: string;
180
+ pairs: {
181
+ caller: string;
182
+ callee: string;
183
+ handled: {
184
+ ok: number;
185
+ refused: number;
186
+ failed: number;
187
+ };
188
+ unanswered: {
189
+ timedOut: number;
190
+ undelivered: number;
191
+ };
192
+ durationMillis: {
193
+ p50: number;
194
+ p99: number;
195
+ max: number;
196
+ bucketed: boolean;
197
+ };
198
+ streaming: boolean;
199
+ histogram: number[];
200
+ }[];
201
+ }[];
202
+ differences: {
203
+ node: string;
204
+ presentOn: string[];
205
+ }[];
206
+ } | null;
207
+ none: string | undefined;
208
+ }>;
209
+ export default function ServiceTopologyPage(): import("react").JSX.Element;
210
+ export declare const ErrorBoundary: typeof ConsoleErrorBoundary;
@@ -0,0 +1,54 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A deployed service's topology: what it is made of and what calls what, merged by the control plane
4
+ * from every running instance. The page says how many instances answered, names any that did not, and
5
+ * marks the parts not every instance has. With scripts running it follows new calls as they are counted.
6
+ */
7
+ import { useMemo, useState } from "react";
8
+ import { useLoaderData } from "react-router";
9
+ import { applyPrimary, guard, pageData, projectShell, useConsoleContext } from "../context.js";
10
+ import { ControlPlaneError } from "../client/errors.js";
11
+ import { ConsoleErrorBoundary } from "../ui/errors.js";
12
+ import { Page, ServiceSections } from "../ui/shell.js";
13
+ import { useTopologyStream } from "../ui/use-stream.js";
14
+ import { observedLine, view, edgeMark, totals } from "../ui/topology/layout.js";
15
+ import { Graph } from "../ui/topology/Graph.js";
16
+ import { Table, duration } from "../ui/topology/Table.js";
17
+ export const meta = ({ loaderData }) => [{ title: `Topology of ${loaderData?.service.name ?? "a service"} · ankka` }];
18
+ export async function loader({ params, context }) {
19
+ const ctx = useConsoleContext(context);
20
+ const { projectId, name } = params;
21
+ return guard(ctx, async () => {
22
+ const [service, project, services, page] = await Promise.all([ctx.client.getService(projectId, name), ctx.client.getProject(projectId), ctx.client.listServices(projectId), pageData(ctx)]);
23
+ const organization = await ctx.client.getOrganization(project.organizationId);
24
+ let topology = null;
25
+ let none;
26
+ try {
27
+ topology = await ctx.client.topology(projectId, name);
28
+ }
29
+ catch (e) {
30
+ // No running instance is nothing to show yet, not an error.
31
+ if (e instanceof ControlPlaneError && e.status === 404)
32
+ none = e.reason;
33
+ else
34
+ throw e;
35
+ }
36
+ const shell = projectShell(page, organization, project, services, [{ label: name, to: `projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(name)}` }, { label: "Topology" }], applyPrimary(projectId, name), name);
37
+ return { console: shell, service, project, organization, topology, none };
38
+ });
39
+ }
40
+ export default function ServiceTopologyPage() {
41
+ const { service, project: p, topology: initial, none } = useLoaderData();
42
+ const { topology, state } = useTopologyStream(p.id, service.name, initial);
43
+ const [showPlatform, setShowPlatform] = useState(false);
44
+ const [selected, setSelected] = useState(null);
45
+ const shown = useMemo(() => (topology ? view(topology, { showPlatform }) : null), [topology, showPlatform]);
46
+ const differs = useMemo(() => new Map((topology?.differences ?? []).map((d) => [d.node, d.presentOn])), [topology]);
47
+ const chosen = shown?.nodes.find((n) => n.id === selected) ?? null;
48
+ return (_jsxs(Page, { children: [_jsxs("div", { className: "ac-head", children: [_jsxs("h1", { children: ["Topology of ", service.name] }), _jsx(ServiceSections, { projectId: p.id, name: service.name, current: "topology" })] }), none ? _jsxs("p", { className: "ac-empty", children: [none, ". A topology exists only while an instance runs."] }) : null, topology && shown ? (_jsxs(_Fragment, { children: [_jsxs("p", { className: "ac-actions", children: [_jsxs("span", { "data-instances": true, children: [topology.contributing, " of ", topology.running, " instance", topology.running === 1 ? "" : "s", " answered"] }), topology.partial ? _jsx("span", { className: "ac-status ac-status-trouble ac-topology-partial", children: "Partial" }) : null, _jsx("span", { className: state === "live" ? "ac-live" : undefined, children: state === "live" ? "Following new calls" : state === "connecting" ? "Connecting to follow new calls" : null })] }), topology.partial ? (_jsxs("div", { className: "ac-notice", role: "status", children: [_jsx("p", { children: "These instances did not contribute, so the counts below are of the others only:" }), _jsx("ul", { children: topology.instances
49
+ .filter((i) => i.status !== "ok")
50
+ .map((i) => (_jsxs("li", { "data-missing": i.pod, children: [i.pod, ": ", i.status, i.problem ? ` (${i.problem})` : ""] }, i.pod))) })] })) : null, topology.differences.length > 0 ? (_jsxs("div", { className: "ac-notice", children: [_jsx("p", { children: "Not on every instance:" }), _jsx("ul", { children: topology.differences.map((d) => (_jsxs("li", { "data-difference": d.node, children: [d.node, ", on ", d.presentOn.join(", ")] }, d.node))) })] })) : null, _jsxs("p", { className: "ac-hint", children: [observedLine(topology.window, Date.now()), " Calls are observed, not complete."] }), _jsx("label", { className: "ac-field", children: _jsxs("span", { children: [_jsx("input", { type: "checkbox", checked: showPlatform, onChange: (e) => setShowPlatform(e.target.checked) }), " Show the platform's own components", shown.hiddenPlatform > 0 && !showPlatform ? ` (${shown.hiddenPlatform} left out)` : ""] }) }), shown.nodes.length === 0 ? (_jsx("p", { className: "ac-empty", children: "This service registers no components." })) : (_jsxs("div", { className: "ac-topology", children: [_jsx("div", { className: "ac-topology-picture", children: _jsx(Graph, { shown: shown, differs: new Set(differs.keys()), selected: selected, onSelect: setSelected }) }), chosen ? (_jsxs("aside", { className: "ac-card ac-topology-detail", "aria-label": `About ${chosen.label}`, children: [_jsx("h2", { children: chosen.label }), _jsx("p", { children: chosen.kind }), _jsx("h3", { children: "Calls" }), _jsx("ul", { children: shown.calls
51
+ .filter((c) => c.from === chosen.id || c.to === chosen.id)
52
+ .flatMap((c) => c.pairs.map((pair) => (_jsxs("li", { children: [c.from === chosen.id ? `to ${c.to}` : `from ${c.from}`, ": ", pair.caller, " \u2192 ", pair.callee, pair.streaming ? " (stream)" : "", ", handled ", totals([pair]).handled, ", unanswered ", totals([pair]).unanswered, edgeMark([pair]) ? ", going wrong" : "", ", ", duration(pair), " (approximate)"] }, `${c.from}>${c.to}:${pair.caller}>${pair.callee}`)))) })] })) : null] })), _jsxs("section", { className: "ac-card", "aria-labelledby": "as-text", children: [_jsx("h2", { id: "as-text", children: "As text" }), _jsx(Table, { shown: shown, differs: differs })] })] })) : null] }));
53
+ }
54
+ export const ErrorBoundary = ConsoleErrorBoundary;
@@ -25,17 +25,98 @@ export declare function loader({ params, context }: LoaderFunctionArgs): Promise
25
25
  paused: boolean;
26
26
  hosting: string;
27
27
  protocol: string | undefined;
28
- };
29
- history: {
30
- kind: string;
31
- generation: number;
32
- actor: {
33
- subject: string;
34
- display: string | undefined;
35
- administrative: boolean;
28
+ mounts: {
29
+ path: string;
30
+ service: string;
31
+ state: string;
32
+ }[];
33
+ callers: string[];
34
+ processPort: number | undefined;
35
+ broker: string | undefined;
36
+ undeclaredTopics: string[] | undefined;
37
+ secretStore: {
38
+ backend: string;
39
+ keyRead: boolean;
40
+ move: {
41
+ phase: string;
42
+ outcome: string;
43
+ names: {
44
+ name: string;
45
+ state: string;
46
+ }[];
47
+ detail: string | undefined;
48
+ } | undefined;
36
49
  } | undefined;
37
- at: string | undefined;
38
- }[];
50
+ topicChecks: {
51
+ topic: string | undefined;
52
+ service: string;
53
+ component: string;
54
+ direction: string;
55
+ stated: string | undefined;
56
+ state: string;
57
+ }[] | undefined;
58
+ topicSources: {
59
+ kind: string;
60
+ component: string;
61
+ topic: string;
62
+ group: string;
63
+ start: string;
64
+ version: number;
65
+ recordedVersion: number | undefined;
66
+ behind: boolean;
67
+ broker: string | undefined;
68
+ contract: string | undefined;
69
+ lag: number | undefined;
70
+ failing: string | undefined;
71
+ gap: {
72
+ partitions: {
73
+ partition: number;
74
+ beginning: number;
75
+ earliestRetained: string | undefined;
76
+ }[];
77
+ compacted: boolean;
78
+ gone: boolean;
79
+ readAt: string | undefined;
80
+ } | undefined;
81
+ }[] | undefined;
82
+ warnings: {
83
+ kind: string;
84
+ component: string;
85
+ topic: string;
86
+ message: string;
87
+ }[] | undefined;
88
+ objectStorage: string | undefined;
89
+ bucket: string | undefined;
90
+ bucketAddress: string | undefined;
91
+ objectStore: string | undefined;
92
+ bucketLocation: string | undefined;
93
+ softDeleteDays: number | undefined;
94
+ storageMove: string | undefined;
95
+ databaseCluster: string | undefined;
96
+ grants: string | undefined;
97
+ crossProjectTopics: {
98
+ project: string;
99
+ topic: string;
100
+ right: string;
101
+ status: string;
102
+ }[] | undefined;
103
+ customHostnames: {
104
+ hostname: string;
105
+ state: string;
106
+ reason: string | undefined;
107
+ record: {
108
+ name: string;
109
+ kind: string;
110
+ value: string;
111
+ } | undefined;
112
+ note: string | undefined;
113
+ }[];
114
+ proofRecord: {
115
+ name: string;
116
+ kind: string;
117
+ value: string;
118
+ } | undefined;
119
+ };
39
120
  project: {
40
121
  id: string;
41
122
  name: string;
@@ -5,9 +5,13 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
5
5
  * state the control plane reports, never what the console expected.
6
6
  */
7
7
  import { redirect, useLoaderData } from "react-router";
8
- import { act, guard, pageData, text, useConsoleContext } from "../context.js";
8
+ import { act, applyPrimary, guard, pageData, projectShell, text, useConsoleContext } from "../context.js";
9
9
  import { ConsoleErrorBoundary } from "../ui/errors.js";
10
- import { Breadcrumbs, ConsoleForm, ConsoleLink, Submit, useConsole, when } from "../ui/console.js";
10
+ import { ConsoleForm, Field, Submit, useConsole } from "../ui/console.js";
11
+ import { Select } from "../ui/primitives/select.js";
12
+ import { Page, SectionTitle, ServiceSections } from "../ui/shell.js";
13
+ import { Shape } from "../ui/shape.js";
14
+ import { runsAs } from "../ui/hosting.js";
11
15
  import { Refused, useRefusal } from "../ui/refused.js";
12
16
  import { Lifecycle } from "../ui/status.js";
13
17
  import { useServiceStream } from "../ui/use-stream.js";
@@ -17,17 +21,24 @@ export async function loader({ params, context }) {
17
21
  const ctx = useConsoleContext(context);
18
22
  const { projectId, name } = params;
19
23
  return guard(ctx, async () => {
20
- const [service, history, project, page] = await Promise.all([
24
+ const [service, project, services, page] = await Promise.all([
21
25
  ctx.client.getService(projectId, name),
22
- ctx.client.history(projectId, name),
23
26
  ctx.client.getProject(projectId),
27
+ ctx.client.listServices(projectId),
24
28
  pageData(ctx),
25
29
  ]);
26
30
  const organization = await ctx.client.getOrganization(project.organizationId);
27
- return { console: page, service, history: [...history].reverse(), project, organization, panels: await loadPanels(ctx, "service", service) };
31
+ return {
32
+ console: projectShell(page, organization, project, services, [{ label: name }], applyPrimary(projectId, name), name),
33
+ service,
34
+ project,
35
+ organization,
36
+ panels: await loadPanels(ctx, "service", service),
37
+ };
28
38
  });
29
39
  }
30
40
  const serviceOperations = ["pause", "resume", "restart", "expose", "unexpose"];
41
+ const storageOperations = ["storage-credential", "storage-settings"];
31
42
  export async function action({ request, params, context }) {
32
43
  const ctx = useConsoleContext(context);
33
44
  const { projectId, name } = params;
@@ -38,36 +49,89 @@ export async function action({ request, params, context }) {
38
49
  await ctx.client.deleteService(projectId, name);
39
50
  return redirect(ctx.href(`projects/${encodeURIComponent(projectId)}`));
40
51
  }
52
+ const self = ctx.href(`projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(name)}`);
53
+ if (intent === "storage-credential") {
54
+ await ctx.client.reissueStorageCredential(projectId, name);
55
+ return redirect(self);
56
+ }
57
+ if (intent === "storage-settings") {
58
+ await ctx.client.reapplyStorageSettings(projectId, name);
59
+ return redirect(self);
60
+ }
61
+ if (intent === "storage-move") {
62
+ await ctx.client.moveStorage(projectId, name, text(form, "writePauseBound") || undefined);
63
+ return redirect(self);
64
+ }
65
+ if (intent === "hostname-add") {
66
+ await ctx.client.addHostname(projectId, name, text(form, "hostname").trim());
67
+ return redirect(self);
68
+ }
69
+ if (intent === "hostname-remove") {
70
+ await ctx.client.removeHostname(projectId, name, text(form, "hostname"));
71
+ return redirect(self);
72
+ }
41
73
  if (!serviceOperations.includes(intent))
42
74
  throw new Response(`unknown operation '${intent}'`, { status: 400 });
43
75
  await ctx.client.serviceOperation(projectId, name, intent);
44
- return redirect(ctx.href(`projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(name)}`));
76
+ return redirect(self);
45
77
  });
46
78
  }
47
- const history = {
48
- applied: "Applied",
49
- restarted: "Restarted",
50
- paused: "Paused",
51
- resumed: "Resumed",
52
- exposed: "Exposed",
53
- unexposed: "Unexposed",
54
- deleted: "Deleted",
55
- };
79
+ /** The store a bucket is in, as the operator reported it. */
80
+ const stores = { garage: "Garage", gcs: "Google Cloud Storage" };
81
+ /**
82
+ * What members can do about a bucket the platform made: issue its credential again, reapply the
83
+ * installation's settings to one in Google Cloud Storage, or move one from Garage there. Whether the
84
+ * installation can move at all is the control plane's to say, and its refusal is shown here.
85
+ */
86
+ function Storage({ s }) {
87
+ const { shows } = useConsole();
88
+ const moveRefusal = useRefusal("storage-move");
89
+ if (!s.bucket)
90
+ return null;
91
+ return (_jsxs("section", { className: "ac-ops", "aria-labelledby": "storage-title", children: [_jsx(SectionTitle, { children: _jsx("span", { id: "storage-title", children: "Object storage" }) }), _jsx(Operation, { intent: "storage-credential", label: "Issue credential again", operation: "service.storage-credential", entity: s }), s.objectStore === "gcs" ? (_jsx(Operation, { intent: "storage-settings", label: "Reapply bucket settings", operation: "service.storage-settings", entity: s })) : shows("service.storage-move") ? (_jsxs("details", { className: "ac-more", open: moveRefusal !== undefined || undefined, children: [_jsx("summary", { children: "Move to Google Cloud Storage" }), _jsx("p", { children: "Every object is copied while the service goes on writing; then its writes are paused, what changed is copied, every object is checked on both sides, and the service is replaced onto its new bucket. The bucket in Garage is kept." }), _jsxs(ConsoleForm, { intent: "storage-move", className: "ac-form", children: [_jsx(Field, { label: "Write pause bound", name: "writePauseBound", placeholder: "10m", autoComplete: "off", defaultValue: moveRefusal?.values.writePauseBound, hint: "How long writes may be paused, from 1m to 24h. A pause that reaches it fails the move and gives the writes back." }), _jsx(Refused, { intent: "storage-move" }), _jsx("div", { children: _jsx(Submit, { intent: "storage-move", children: "Move bucket" }) })] })] })) : null, _jsx(HostActions, { operation: "service.storage-move", entity: s }), storageOperations.map((op) => (_jsx(Refused, { intent: op }, op)))] }));
92
+ }
93
+ /** A service's bucket, that it has a store of its own, or none — and a phrase while it waits or failed. */
94
+ function objectStorage(s) {
95
+ if (s.objectStorage && /waiting|failed/.test(s.objectStorage))
96
+ return s.objectStorage;
97
+ if (s.bucket)
98
+ return s.bucket;
99
+ if (s.objectStorage === "supplied")
100
+ return "Its own";
101
+ return "None";
102
+ }
56
103
  function Operation({ intent, label, operation, danger, entity }) {
57
104
  const { shows } = useConsole();
58
105
  return (_jsxs(_Fragment, { children: [shows(operation) ? (_jsx(ConsoleForm, { intent: intent, children: _jsx(Submit, { intent: intent, danger: danger, children: label }) })) : null, _jsx(HostActions, { operation: operation, entity: entity })] }));
59
106
  }
107
+ function record(r) {
108
+ return `${r.kind} ${r.name} → ${r.value}`;
109
+ }
110
+ function hostnameStands(h) {
111
+ return h.reason ? `${h.state}: ${h.reason}` : h.state;
112
+ }
113
+ /**
114
+ * The names a service answers at: the one the platform derives, then each custom hostname with where it
115
+ * stands and the record its owner creates, and the proof record the project's names need. Adding and
116
+ * removing are forms; the control plane's refusal is shown as it says it.
117
+ */
118
+ function Hostnames({ service: s }) {
119
+ return (_jsxs("section", { className: "ac-card", "aria-labelledby": "hostnames", "data-hostnames": true, children: [_jsx("h2", { id: "hostnames", children: "Hostnames" }), _jsxs("dl", { className: "ac-facts", children: [_jsx("dt", { children: "Address" }), _jsx("dd", { children: s.hostname ? _jsx("a", { href: s.hostname, children: s.hostname }) : s.exposed ? "Exposed; the platform has no address for it yet" : "Not exposed" }), s.customHostnames.map((h) => (_jsxs("div", { "data-custom-hostname": h.hostname, "data-state": h.state, children: [_jsx("dt", { children: h.hostname }), _jsxs("dd", { children: [_jsx("span", { children: hostnameStands(h) }), h.record ? (_jsxs("span", { className: "ac-hint", "data-record": true, children: [" ", "Create ", record(h.record)] })) : null, h.note ? _jsxs("span", { className: "ac-hint", children: [" ", h.note] }) : null] })] }, h.hostname))), s.proofRecord ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Proof record" }), _jsx("dd", { children: _jsxs("code", { "data-proof-record": true, children: [s.proofRecord.kind, " ", s.proofRecord.name, " \"", s.proofRecord.value, "\""] }) })] })) : null] })] }));
120
+ }
121
+ /** Adding and removing a custom hostname, in the inspector with the service's other operations. */
122
+ function HostnameOperations({ service: s }) {
123
+ const { shows } = useConsole();
124
+ const addRefusal = useRefusal("hostname-add");
125
+ if (!shows("service-hostname.add") && !shows("service-hostname.remove"))
126
+ return null;
127
+ return (_jsxs("section", { className: "ac-form", "aria-labelledby": "hostname-operations", "data-hostname-operations": true, children: [_jsx(SectionTitle, { children: _jsx("span", { id: "hostname-operations", children: "Custom hostnames" }) }), shows("service-hostname.add") ? (_jsxs(ConsoleForm, { intent: "hostname-add", className: "ac-inline", children: [_jsx(Field, { label: "Add a custom hostname", name: "hostname", required: true, autoComplete: "off", placeholder: "app.example.com", defaultValue: addRefusal?.values.hostname }), _jsx(Submit, { intent: "hostname-add", primary: true, children: "Add" })] })) : null, _jsx(Refused, { intent: "hostname-add" }), shows("service-hostname.remove") && s.customHostnames.length > 0 ? (_jsxs(ConsoleForm, { intent: "hostname-remove", className: "ac-inline", children: [_jsx(Select, { label: "Remove a custom hostname", id: "hostname-remove", name: "hostname", children: s.customHostnames.map((h) => (_jsx("option", { value: h.hostname, children: h.hostname }, h.hostname))) }), _jsx(Submit, { intent: "hostname-remove", danger: true, children: "Remove" })] })) : null, _jsx(Refused, { intent: "hostname-remove" })] }));
128
+ }
60
129
  export default function Service() {
61
130
  const data = useLoaderData();
62
- const { project: p, organization: o, panels } = data;
131
+ const { project: p, panels } = data;
63
132
  const { status: s, state } = useServiceStream(p.id, data.service.name, data.service);
64
133
  const deleteRefused = useRefusal("delete") !== undefined;
65
- const path = `projects/${encodeURIComponent(p.id)}/services/${encodeURIComponent(s.name)}`;
66
- return (_jsxs("section", { className: "ac-page", children: [_jsx(Breadcrumbs, { trail: [
67
- { label: "Organizations", to: "" },
68
- { label: o.name, to: `organizations/${encodeURIComponent(o.id)}` },
69
- { label: p.name, to: `projects/${encodeURIComponent(p.id)}` },
70
- { label: s.name },
71
- ] }), _jsx("h1", { children: s.name }), _jsxs("p", { "aria-live": "polite", className: "ac-state-line", children: [_jsx(Lifecycle, { lifecycle: s.lifecycle, confirmed: s.confirmed }), state === "live" ? _jsx("span", { className: "ac-live", children: " Updating as the platform reports" }) : null] }), s.detail ? _jsx("p", { className: "ac-notice", children: s.detail }) : null, _jsxs("dl", { className: "ac-facts", children: [_jsx("dt", { children: "Instances" }), _jsxs("dd", { children: [s.readyInstances, " ready of ", s.desiredInstances] }), _jsx("dt", { children: "Image" }), _jsx("dd", { children: s.image }), _jsx("dt", { children: "Generation" }), _jsx("dd", { children: s.generation }), _jsx("dt", { children: "Address" }), _jsx("dd", { children: s.hostname ? _jsx("a", { href: s.hostname, children: s.hostname }) : s.exposed ? "Exposed; the platform has no address for it yet" : "Not exposed" }), _jsx("dt", { children: "Database" }), _jsx("dd", { children: s.database ?? "Nothing reported yet" }), _jsx("dt", { children: "Runs as" }), _jsx("dd", { children: s.hosting === "process" ? `A process beside the platform's sidecar${s.protocol ? `, protocol ${s.protocol}` : ""}` : "Embedded in the platform's runtime" }), _jsx("dt", { children: "Stopped by" }), _jsx("dd", { children: s.suspended ? "Its organization is disabled" : s.paused ? "Its members paused it" : "Nobody" }), _jsx("dt", { children: "Report" }), _jsx("dd", { children: s.confirmed ? "Confirmed by the cluster" : "The last known state; the cluster has not confirmed it" })] }), _jsxs("div", { className: "ac-actions", "aria-label": "Operations", children: [s.paused ? _jsx(Operation, { intent: "resume", label: "Resume", operation: "service.resume", entity: s }) : _jsx(Operation, { intent: "pause", label: "Pause", operation: "service.pause", entity: s }), _jsx(Operation, { intent: "restart", label: "Restart", operation: "service.restart", entity: s }), s.exposed ? _jsx(Operation, { intent: "unexpose", label: "Unexpose", operation: "service.unexpose", entity: s }) : _jsx(Operation, { intent: "expose", label: "Expose", operation: "service.expose", entity: s }), _jsx(ConsoleLink, { to: `${path}/logs`, className: "ac-button ac-button-quiet", children: "Logs" }), _jsx(ConsoleLink, { to: `projects/${encodeURIComponent(p.id)}/services/apply?name=${encodeURIComponent(s.name)}`, className: "ac-button ac-button-quiet", children: "Apply a new descriptor" })] }), serviceOperations.map((op) => (_jsx(Refused, { intent: op }, op))), _jsx(Panels, { kind: "service", entity: s, loaded: panels }), _jsx("h2", { children: "History" }), data.history.length === 0 ? (_jsx("p", { className: "ac-empty", children: "Nothing recorded yet." })) : (_jsx("div", { className: "ac-table-wrap", children: _jsxs("table", { className: "ac-table", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "What" }), _jsx("th", { scope: "col", className: "ac-num", children: "Generation" }), _jsx("th", { scope: "col", children: "Who" }), _jsx("th", { scope: "col", children: "When" })] }) }), _jsx("tbody", { children: data.history.map((h, i) => (_jsxs("tr", { children: [_jsx("td", { children: history[h.kind] ?? h.kind }), _jsx("td", { className: "ac-num", children: h.generation }), _jsxs("td", { children: [h.actor ? (h.actor.display ?? h.actor.subject) : "—", h.actor?.administrative ? " (as administrator)" : ""] }), _jsx("td", { children: when(h.at) })] }, i))) })] }) })), _jsxs("details", { className: "ac-more", open: deleteRefused || undefined, children: [_jsx("summary", { children: "Delete" }), _jsxs("div", { className: "ac-danger-zone", children: [_jsxs("p", { children: ["Deleting ", s.name, " stops it and removes it from the project. Its database is kept: applying a descriptor with this name again brings the service back with its data."] }), _jsx(Operation, { intent: "delete", label: "Delete service", operation: "service.delete", danger: true, entity: s }), _jsx(Refused, { intent: "delete" })] })] })] }));
134
+ const inspector = (_jsxs(_Fragment, { children: [_jsxs("section", { className: "ac-ops", "aria-labelledby": "operations-title", children: [_jsx(SectionTitle, { children: _jsx("span", { id: "operations-title", children: "Operations" }) }), s.paused ? _jsx(Operation, { intent: "resume", label: "Resume", operation: "service.resume", entity: s }) : _jsx(Operation, { intent: "pause", label: "Pause", operation: "service.pause", entity: s }), _jsx(Operation, { intent: "restart", label: "Restart", operation: "service.restart", entity: s }), s.exposed ? _jsx(Operation, { intent: "unexpose", label: "Unexpose", operation: "service.unexpose", entity: s }) : _jsx(Operation, { intent: "expose", label: "Expose", operation: "service.expose", entity: s })] }), _jsx(HostnameOperations, { service: s }), serviceOperations.map((op) => (_jsx(Refused, { intent: op }, op))), _jsx(Storage, { s: s }), _jsxs("details", { className: "ac-more", open: deleteRefused || undefined, children: [_jsx("summary", { children: "Delete" }), _jsxs("div", { className: "ac-danger-zone", children: [_jsxs("p", { children: ["Deleting ", s.name, " stops it and removes it from the project. Its database is kept, and so is its bucket if it has one: applying a descriptor with this name again brings the service back with its data."] }), _jsx(Operation, { intent: "delete", label: "Delete service", operation: "service.delete", danger: true, entity: s }), _jsx(Refused, { intent: "delete" })] })] })] }));
135
+ return (_jsxs(Page, { inspector: inspector, children: [_jsxs("div", { className: "ac-head", children: [_jsx("h1", { children: s.name }), _jsxs("p", { "aria-live": "polite", className: "ac-actions ac-state-line", children: [_jsx("span", { className: "ac-pill", children: _jsx(Lifecycle, { lifecycle: s.lifecycle, confirmed: s.confirmed }) }), state === "live" ? _jsx("span", { className: "ac-live", children: " Updating as the platform reports" }) : null] }), _jsx(ServiceSections, { projectId: p.id, name: s.name, current: "overview" })] }), s.detail ? _jsx("p", { className: "ac-notice", children: s.detail }) : null, _jsx(Shape, { service: s }), _jsx(Hostnames, { service: s }), _jsxs("section", { className: "ac-card", "aria-labelledby": "reported", children: [_jsx("h2", { id: "reported", children: "Reported by the cluster" }), _jsxs("dl", { className: "ac-facts", children: [_jsx("dt", { children: "Instances" }), _jsxs("dd", { children: [s.readyInstances, " ready of ", s.desiredInstances] }), _jsx("dt", { children: "Image" }), _jsx("dd", { children: s.image }), _jsx("dt", { children: "Generation" }), _jsx("dd", { children: s.generation }), _jsx("dt", { children: "Database" }), _jsx("dd", { children: s.hosting === "web" ? "None" : (s.database ?? "Nothing reported yet") }), _jsx("dt", { children: "Object storage" }), _jsxs("dd", { children: [objectStorage(s), s.bucketAddress ? _jsxs("span", { className: "ac-hint", "data-bucket-address": true, children: [" ", s.bucketAddress] }) : null] }), s.objectStore ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Object store" }), _jsx("dd", { "data-object-store": s.objectStore, children: stores[s.objectStore] ?? s.objectStore })] })) : null, s.bucketLocation ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Bucket location" }), _jsx("dd", { children: s.bucketLocation })] })) : null, s.softDeleteDays !== undefined && s.softDeleteDays !== null ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Deleted objects" }), _jsxs("dd", { children: ["Recoverable for ", s.softDeleteDays, " days"] })] })) : null, s.storageMove ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Bucket move" }), _jsx("dd", { "data-storage-move": true, children: s.storageMove })] })) : null, s.broker ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Broker" }), _jsx("dd", { children: s.broker })] })) : null, s.grants ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Grants" }), _jsx("dd", { "data-grants": true, children: s.grants })] })) : null, s.crossProjectTopics && s.crossProjectTopics.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Cross-project topics" }), _jsx("dd", { children: _jsx("ul", { className: "ac-topics", children: s.crossProjectTopics.map((t) => (_jsxs("li", { "data-cross-project-topic": `${t.project}/${t.topic}`, "data-granted": t.status === "granted" ? "yes" : "no", children: [_jsx("code", { children: `${t.project}/${t.topic}` }), " ", t.right, " ", t.status === "granted" ? t.status : _jsx("span", { className: "ac-notice", children: t.status })] }, `${t.project}/${t.topic}/${t.right}`))) }) })] })) : null, s.undeclaredTopics && s.undeclaredTopics.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Undeclared topics" }), _jsx("dd", { children: _jsx("ul", { className: "ac-topics", children: s.undeclaredTopics.map((t) => (_jsx("li", { "data-topic": t, children: _jsx("code", { children: t }) }, t))) }) })] })) : null, s.warnings && s.warnings.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Warnings" }), _jsx("dd", { children: _jsx("ul", { className: "ac-topics", children: s.warnings.map((w) => (_jsx("li", { "data-warning": w.kind, children: _jsx("span", { className: "ac-notice", children: w.message }) }, `${w.kind}/${w.component}/${w.topic}`))) }) })] })) : null, s.topicSources && s.topicSources.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Topic sources" }), _jsx("dd", { children: _jsx("div", { className: "ac-table-wrap", children: _jsxs("table", { className: "ac-table", "data-topic-sources": true, children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Component" }), _jsx("th", { scope: "col", children: "Topic" }), _jsx("th", { scope: "col", children: "Group" }), _jsx("th", { scope: "col", className: "ac-num", children: "Version" }), _jsx("th", { scope: "col", className: "ac-num", children: "Lag" }), _jsx("th", { scope: "col", children: "Failing" }), _jsx("th", { scope: "col", children: "Retained" })] }) }), _jsx("tbody", { children: s.topicSources.map((t) => (_jsxs("tr", { "data-topic-source": t.component, "data-failing": t.failing ? "yes" : "no", children: [_jsx("td", { children: t.component }), _jsxs("td", { children: [_jsx("code", { children: t.broker ? `${t.topic}@${t.broker}` : t.topic }), t.contract ? ` as ${t.contract}` : ""] }), _jsx("td", { children: _jsx("code", { children: t.group }) }), _jsx("td", { className: "ac-num", children: t.version }), _jsx("td", { className: "ac-num", children: t.lag ?? "—" }), _jsx("td", { children: t.failing ? _jsx("span", { className: "ac-notice", children: t.failing }) : "—" }), _jsx("td", { "data-gap": t.gap ? (t.gap.compacted ? "compacted" : t.gap.gone ? "gone" : "everything") : "unknown", children: t.gap === undefined || t.gap === null ? ("—") : t.gap.compacted ? ("compacted") : !t.gap.gone ? ("everything") : (_jsxs("details", { children: [_jsx("summary", { children: "earlier messages gone" }), _jsx("ul", { className: "ac-topics", children: t.gap.partitions.map((p) => (_jsxs("li", { "data-partition": p.partition, children: ["p", p.partition, " from ", p.beginning, " (", p.earliestRetained ?? "holds nothing", ")"] }, p.partition))) })] })) })] }, t.component))) })] }) }) })] })) : null, s.topicChecks && s.topicChecks.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Topic checks" }), _jsx("dd", { children: _jsx("ul", { className: "ac-topics", children: s.topicChecks.map((c) => (_jsxs("li", { "data-check": c.state, children: [c.component, " ", c.direction, ": ", c.state, c.state === "mismatch" ? ` (${c.stated ?? "none"})` : ""] }, `${c.component}/${c.direction}`))) }) })] })) : null, _jsx("dt", { children: "Runs as" }), _jsx("dd", { children: runsAs(s) }), s.hosting === "web" ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Admits" }), _jsx("dd", { children: ["The internet", ...s.callers.map((c) => (c === "*" ? `Every service in ${p.name}` : c))].join(", ") }), _jsx("dt", { children: "Mounts" }), _jsx("dd", { children: s.mounts.length === 0 ? ("None") : (_jsx("ul", { className: "ac-mounts", children: s.mounts.map((m) => (_jsxs("li", { "data-mount": m.path, children: [_jsx("code", { children: m.path }), " \u2192 ", m.service, m.state && m.state !== "ok" ? ` (${m.state})` : ""] }, m.path))) })) })] })) : null, _jsx("dt", { children: "Stopped by" }), _jsx("dd", { children: s.suspended ? "Its organization is disabled" : s.paused ? "Its members paused it" : "Nobody" }), _jsx("dt", { children: "Report" }), _jsx("dd", { children: s.confirmed ? "Confirmed by the cluster" : "The last known state; the cluster has not confirmed it" })] })] }), _jsx(Panels, { kind: "service", entity: s, loaded: panels })] }));
72
136
  }
73
137
  export const ErrorBoundary = ConsoleErrorBoundary;
@@ -19,8 +19,10 @@ export async function loader({ request, params, context }) {
19
19
  const name = params.name;
20
20
  const query = new URL(request.url).searchParams;
21
21
  const follow = query.has("logs");
22
+ const topology = query.has("topology");
23
+ let lastTopology = "";
22
24
  const tail = Math.min(Math.max(Number(query.get("tail") ?? 200) || 200, 1), 5_000);
23
- const logQuery = { instance: query.get("instance") || undefined, previous: query.get("previous") === "true", tail };
25
+ const logQuery = { instance: query.get("instance") || undefined, previous: query.get("previous") === "true", platform: query.get("platform") === "true", tail };
24
26
  const follower = new LogFollower(tail);
25
27
  const primed = new Set();
26
28
  let last = "";
@@ -40,6 +42,21 @@ export async function loader({ request, params, context }) {
40
42
  return;
41
43
  throw e;
42
44
  }
45
+ if (topology) {
46
+ try {
47
+ const now = await ctx.client.topology(projectId, name);
48
+ const json = JSON.stringify(now);
49
+ if (json !== lastTopology) {
50
+ lastTopology = json;
51
+ sink.send("topology", now);
52
+ }
53
+ }
54
+ catch (e) {
55
+ if (e instanceof SignInRequired)
56
+ return void ended(sink, e);
57
+ // No running instance has no topology; its status says why.
58
+ }
59
+ }
43
60
  if (!follow)
44
61
  return;
45
62
  try {