ankka-console 0.10.0 → 0.11.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 (67) hide show
  1. package/dist/client/control-plane.d.ts +25 -1
  2. package/dist/client/control-plane.js +60 -1
  3. package/dist/client/schemas.d.ts +525 -0
  4. package/dist/client/schemas.js +174 -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 +7 -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.js +12 -6
  17. package/dist/routes/logs.d.ts +35 -0
  18. package/dist/routes/logs.js +15 -14
  19. package/dist/routes/members.js +11 -8
  20. package/dist/routes/organization-new.js +7 -4
  21. package/dist/routes/organization.js +13 -6
  22. package/dist/routes/project-new.js +8 -4
  23. package/dist/routes/project.d.ts +73 -2
  24. package/dist/routes/project.js +81 -9
  25. package/dist/routes/service-apply.d.ts +5 -2
  26. package/dist/routes/service-apply.js +17 -14
  27. package/dist/routes/service-history.d.ts +30 -0
  28. package/dist/routes/service-history.js +79 -0
  29. package/dist/routes/service-topology.d.ts +153 -0
  30. package/dist/routes/service-topology.js +54 -0
  31. package/dist/routes/service.d.ts +34 -10
  32. package/dist/routes/service.js +27 -22
  33. package/dist/routes/stream.service.js +18 -1
  34. package/dist/routes/tokens.js +8 -6
  35. package/dist/routes.js +2 -0
  36. package/dist/styles.css +2 -481
  37. package/dist/testing/fake-control-plane.d.ts +155 -0
  38. package/dist/testing/fake-control-plane.js +379 -12
  39. package/dist/testing/index.d.ts +1 -1
  40. package/dist/testing/scenarios.js +6 -0
  41. package/dist/theme.css +66 -0
  42. package/dist/ui/console.d.ts +19 -6
  43. package/dist/ui/console.js +9 -2
  44. package/dist/ui/errors.js +7 -5
  45. package/dist/ui/hosting.d.ts +11 -0
  46. package/dist/ui/hosting.js +20 -0
  47. package/dist/ui/primitives/button.d.ts +11 -0
  48. package/dist/ui/primitives/button.js +21 -0
  49. package/dist/ui/primitives/select.d.ts +10 -0
  50. package/dist/ui/primitives/select.js +4 -0
  51. package/dist/ui/primitives/switch.d.ts +9 -0
  52. package/dist/ui/primitives/switch.js +4 -0
  53. package/dist/ui/shape.d.ts +4 -0
  54. package/dist/ui/shape.js +36 -0
  55. package/dist/ui/shell.d.ts +76 -0
  56. package/dist/ui/shell.js +107 -0
  57. package/dist/ui/tokens.d.ts +82 -0
  58. package/dist/ui/tokens.js +172 -0
  59. package/dist/ui/topology/Graph.d.ts +13 -0
  60. package/dist/ui/topology/Graph.js +56 -0
  61. package/dist/ui/topology/Table.d.ts +10 -0
  62. package/dist/ui/topology/Table.js +14 -0
  63. package/dist/ui/topology/layout.d.ts +113 -0
  64. package/dist/ui/topology/layout.js +177 -0
  65. package/dist/ui/use-stream.d.ts +137 -1
  66. package/dist/ui/use-stream.js +11 -1
  67. package/package.json +11 -3
@@ -1,23 +1,31 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * Applies a descriptor: pasted, or read from a file. The console checks only that the text is JSON
4
- * and names a service; every rule about what a descriptor may say is the control plane's, and its
3
+ * Applies a descriptor: pasted, read from a file, or started from the one a service applied at an
4
+ * earlier generation (`?name=cart&generation=1`). The console checks only that the text is JSON and
5
+ * names a service; every rule about what a descriptor may say is the control plane's, and its
5
6
  * refusal is shown problem by problem beside the text.
6
7
  */
7
8
  import { redirect, useLoaderData } from "react-router";
8
- import { act, guard, pageData, useConsoleContext } from "../context.js";
9
+ import { act, guard, pageData, projectShell, useConsoleContext } from "../context.js";
9
10
  import { ConsoleErrorBoundary } from "../ui/errors.js";
10
- import { Breadcrumbs, ConsoleForm, Submit } from "../ui/console.js";
11
+ import { ConsoleForm, Submit } from "../ui/console.js";
12
+ import { Page } from "../ui/shell.js";
11
13
  import { Refused, useRefusal } from "../ui/refused.js";
12
14
  import { data } from "react-router";
13
15
  export const meta = () => [{ title: "Apply a descriptor · ankka" }];
14
16
  export async function loader({ request, params, context }) {
15
17
  const ctx = useConsoleContext(context);
16
18
  return guard(ctx, async () => {
17
- const project = await ctx.client.getProject(params.projectId);
19
+ const [project, services, page] = await Promise.all([ctx.client.getProject(params.projectId), ctx.client.listServices(params.projectId), pageData(ctx)]);
18
20
  const organization = await ctx.client.getOrganization(project.organizationId);
19
- const name = new URL(request.url).searchParams.get("name");
20
- return { console: await pageData(ctx), project, organization, name };
21
+ const query = new URL(request.url).searchParams;
22
+ const name = query.get("name");
23
+ const generation = query.get("generation");
24
+ const from = name && generation ? JSON.stringify(await ctx.client.descriptor(project.id, name, Number(generation)), null, 2) : undefined;
25
+ const tail = name
26
+ ? [{ label: name, to: `projects/${encodeURIComponent(project.id)}/services/${encodeURIComponent(name)}` }, { label: "Apply a descriptor" }]
27
+ : [{ label: "Apply a descriptor" }];
28
+ return { console: projectShell(page, organization, project, services, tail, undefined, name ?? undefined), project, organization, name, generation, from };
21
29
  });
22
30
  }
23
31
  const notApplied = (reason, descriptor) => data({ intent: "apply", status: 400, reason, problems: [reason], values: { descriptor } }, { status: 400 });
@@ -53,13 +61,8 @@ const example = `{
53
61
  }
54
62
  }`;
55
63
  export default function ApplyDescriptor() {
56
- const { project: p, organization: o, name } = useLoaderData();
64
+ const { name, generation, from } = useLoaderData();
57
65
  const refusal = useRefusal("apply");
58
- return (_jsxs("section", { className: "ac-page", children: [_jsx(Breadcrumbs, { trail: [
59
- { label: "Organizations", to: "" },
60
- { label: o.name, to: `organizations/${encodeURIComponent(o.id)}` },
61
- { label: p.name, to: `projects/${encodeURIComponent(p.id)}` },
62
- { label: "Apply a descriptor" },
63
- ] }), _jsxs("h1", { children: ["Apply a descriptor", name ? ` to ${name}` : ""] }), _jsxs("p", { className: "ac-lede", children: ["A descriptor is a service's ", _jsx("code", { children: "service.json" }), ". Applying it creates the service, or updates it if one with that name exists."] }), _jsxs(ConsoleForm, { intent: "apply", encType: "multipart/form-data", className: "ac-form", children: [_jsxs("div", { className: "ac-field", children: [_jsx("label", { htmlFor: "descriptor", children: "Descriptor" }), _jsx("textarea", { id: "descriptor", name: "descriptor", spellCheck: false, placeholder: example, defaultValue: refusal?.values.descriptor, "aria-describedby": "descriptor-hint", "aria-invalid": refusal ? true : undefined }), _jsx("p", { className: "ac-hint", id: "descriptor-hint", children: "Paste it here, or choose the file below instead." })] }), _jsxs("div", { className: "ac-field", children: [_jsx("label", { htmlFor: "file", children: "Or a service.json file" }), _jsx("input", { id: "file", name: "file", type: "file", accept: "application/json,.json" })] }), _jsx(Refused, { intent: "apply" }), _jsx("div", { children: _jsx(Submit, { intent: "apply", children: "Apply" }) })] })] }));
66
+ return (_jsxs(Page, { children: [_jsxs("h1", { children: ["Apply a descriptor", name ? ` to ${name}` : ""] }), _jsxs("p", { className: "ac-lede", children: ["A descriptor is a service's ", _jsx("code", { children: "service.json" }), ". Applying it creates the service, or updates it if one with that name exists."] }), from ? _jsxs("p", { className: "ac-notice", children: ["This is the descriptor ", name, " applied at generation ", generation, ". Applying it, changed or not, is a new generation."] }) : null, _jsxs(ConsoleForm, { intent: "apply", encType: "multipart/form-data", className: "ac-card ac-form", children: [_jsxs("div", { className: "ac-field", children: [_jsx("label", { htmlFor: "descriptor", children: "Descriptor" }), _jsx("textarea", { id: "descriptor", name: "descriptor", spellCheck: false, placeholder: example, defaultValue: refusal?.values.descriptor ?? from, "aria-describedby": "descriptor-hint", "aria-invalid": refusal ? true : undefined }), _jsx("p", { className: "ac-hint", id: "descriptor-hint", children: "Paste it here, or choose the file below instead." })] }), _jsxs("div", { className: "ac-field", children: [_jsx("label", { htmlFor: "file", children: "Or a service.json file" }), _jsx("input", { id: "file", name: "file", type: "file", accept: "application/json,.json" })] }), _jsx(Refused, { intent: "apply" }), _jsx("div", { children: _jsx(Submit, { intent: "apply", primary: true, children: "Apply" }) })] })] }));
64
67
  }
65
68
  export const ErrorBoundary = ConsoleErrorBoundary;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * A service's history: every change its members made, newest first, each with the generation it
3
+ * produced and who made it, and for each that recorded a descriptor the image it ran and a digest
4
+ * of it. A row whose descriptor differs from the service's own offers a rollback to it. A section
5
+ * of the service, beside its overview, topology and logs.
6
+ */
7
+ import { type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "react-router";
8
+ import { ConsoleErrorBoundary } from "../ui/errors.tsx";
9
+ export declare const meta: MetaFunction<typeof loader>;
10
+ export declare function loader({ params, context }: LoaderFunctionArgs): Promise<{
11
+ console: import("../context.ts").ConsolePageData;
12
+ name: string;
13
+ projectId: string;
14
+ history: {
15
+ kind: string;
16
+ generation: number;
17
+ actor: {
18
+ subject: string;
19
+ display: string | undefined;
20
+ administrative: boolean;
21
+ } | undefined;
22
+ at: string | undefined;
23
+ image: string | undefined;
24
+ digest: string | undefined;
25
+ rolledBackTo: number | undefined;
26
+ }[];
27
+ }>;
28
+ export declare function action({ request, params, context }: ActionFunctionArgs): Promise<import("react-router").UNSAFE_DataWithResponseInit<import("../context.ts").ActionRefusal> | Response>;
29
+ export default function ServiceHistory(): import("react").JSX.Element;
30
+ export declare const ErrorBoundary: typeof ConsoleErrorBoundary;
@@ -0,0 +1,79 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A service's history: every change its members made, newest first, each with the generation it
4
+ * produced and who made it, and for each that recorded a descriptor the image it ran and a digest
5
+ * of it. A row whose descriptor differs from the service's own offers a rollback to it. A section
6
+ * of the service, beside its overview, topology and logs.
7
+ */
8
+ import { redirect, useLoaderData } from "react-router";
9
+ import { act, applyPrimary, guard, pageData, projectShell, text, useConsoleContext } from "../context.js";
10
+ import { ConsoleErrorBoundary } from "../ui/errors.js";
11
+ import { ConsoleForm, ConsoleLink, Submit, useConsole, when } from "../ui/console.js";
12
+ import { Refused } from "../ui/refused.js";
13
+ import { HostActions } from "../extensions/render.js";
14
+ import { Page, ServiceSections } from "../ui/shell.js";
15
+ export const meta = ({ loaderData }) => [{ title: `History of ${loaderData?.name ?? "a service"} · ankka` }];
16
+ export async function loader({ params, context }) {
17
+ const ctx = useConsoleContext(context);
18
+ const { projectId, name } = params;
19
+ return guard(ctx, async () => {
20
+ const [history, project, services, page] = await Promise.all([
21
+ ctx.client.history(projectId, name),
22
+ ctx.client.getProject(projectId),
23
+ ctx.client.listServices(projectId),
24
+ pageData(ctx),
25
+ ]);
26
+ const organization = await ctx.client.getOrganization(project.organizationId);
27
+ return {
28
+ console: projectShell(page, organization, project, services, [{ label: name, to: `projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(name)}` }, { label: "History" }], applyPrimary(projectId, name), name),
29
+ name,
30
+ projectId,
31
+ history: [...history].reverse(),
32
+ };
33
+ });
34
+ }
35
+ export async function action({ request, params, context }) {
36
+ const ctx = useConsoleContext(context);
37
+ const { projectId, name } = params;
38
+ const form = await request.formData();
39
+ const intent = text(form, "intent");
40
+ return act(ctx, intent, form, async () => {
41
+ if (intent !== "rollback")
42
+ throw new Response(`unknown operation '${intent}'`, { status: 400 });
43
+ await ctx.client.rollback(projectId, name, Number(text(form, "generation")));
44
+ return redirect(ctx.href(`projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(name)}/history`));
45
+ });
46
+ }
47
+ /**
48
+ * The generations a rollback can be offered at: each whose entry recorded a descriptor different
49
+ * from the one the service has now. The newest entry that recorded one is the service's own, since
50
+ * only an apply or a rollback changes it. An entry recorded before digests were kept offers
51
+ * nothing, though the CLI can still roll back to it; the control plane decides either way.
52
+ */
53
+ function rollbackTargets(history) {
54
+ const recorded = history.filter((h) => h.digest !== undefined);
55
+ if (recorded.length === 0)
56
+ return new Set();
57
+ const current = recorded.reduce((a, b) => (b.generation > a.generation ? b : a)).digest;
58
+ return new Set(recorded.filter((h) => h.digest !== current).map((h) => h.generation));
59
+ }
60
+ function RollBack({ generation, image, entity }) {
61
+ const { shows } = useConsole();
62
+ return (_jsxs(_Fragment, { children: [shows("service.rollback") ? (_jsxs("details", { className: "ac-more", children: [_jsx("summary", { children: "Roll back" }), _jsxs("p", { children: ["Apply generation ", generation, "'s descriptor again", image ? ` (image ${image})` : "", " as a new generation."] }), _jsxs(ConsoleForm, { intent: "rollback", children: [_jsx("input", { type: "hidden", name: "generation", value: generation }), _jsxs(Submit, { intent: "rollback", children: ["Roll back to generation ", generation] })] })] })) : null, _jsx(HostActions, { operation: "service.rollback", entity: entity })] }));
63
+ }
64
+ const words = {
65
+ applied: "Applied",
66
+ "rolled-back": "Rolled back",
67
+ restarted: "Restarted",
68
+ paused: "Paused",
69
+ resumed: "Resumed",
70
+ exposed: "Exposed",
71
+ unexposed: "Unexposed",
72
+ deleted: "Deleted",
73
+ };
74
+ export default function ServiceHistory() {
75
+ const { name, projectId, history } = useLoaderData();
76
+ const offered = rollbackTargets(history);
77
+ return (_jsxs(Page, { children: [_jsxs("div", { className: "ac-head", children: [_jsxs("h1", { children: ["History of ", name] }), _jsx(ServiceSections, { projectId: projectId, name: name, current: "history" })] }), _jsxs("section", { className: "ac-card", "aria-labelledby": "history", children: [_jsx("h2", { id: "history", children: "History" }), 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: "Image" }), _jsx("th", { scope: "col", children: "Digest" }), _jsx("th", { scope: "col", children: "Who" }), _jsx("th", { scope: "col", children: "When" }), _jsx("th", { scope: "col", "aria-label": "Roll back" })] }) }), _jsx("tbody", { children: history.map((h, i) => (_jsxs("tr", { children: [_jsx("td", { children: h.rolledBackTo !== undefined ? `Rolled back to generation ${h.rolledBackTo}` : (words[h.kind] ?? h.kind) }), _jsx("td", { className: "ac-num", children: h.generation }), _jsx("td", { children: h.image ?? "—" }), _jsx("td", { children: h.digest ? (_jsx(ConsoleLink, { to: `projects/${encodeURIComponent(projectId)}/services/apply?name=${encodeURIComponent(name)}&generation=${h.generation}`, "aria-label": `Generation ${h.generation}'s descriptor`, children: _jsx("code", { title: h.digest, children: h.digest.slice(0, 12) }) })) : ("—") }), _jsxs("td", { children: [h.actor ? (h.actor.display ?? h.actor.subject) : "—", h.actor?.administrative ? " (as administrator)" : ""] }), _jsx("td", { children: when(h.at) }), _jsx("td", { children: offered.has(h.generation) ? _jsx(RollBack, { generation: h.generation, image: h.image, entity: { name, projectId, entry: h } }) : null })] }, i))) })] }) })), _jsx(Refused, { intent: "rollback" })] })] }));
78
+ }
79
+ export const ErrorBoundary = ConsoleErrorBoundary;
@@ -0,0 +1,153 @@
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
+ topicChecks: {
33
+ topic: string | undefined;
34
+ service: string;
35
+ component: string;
36
+ direction: string;
37
+ stated: string | undefined;
38
+ state: string;
39
+ }[] | undefined;
40
+ topicSources: {
41
+ kind: string;
42
+ component: string;
43
+ topic: string;
44
+ group: string;
45
+ start: string;
46
+ version: number;
47
+ recordedVersion: number | undefined;
48
+ behind: boolean;
49
+ broker: string | undefined;
50
+ contract: string | undefined;
51
+ lag: number | undefined;
52
+ failing: string | undefined;
53
+ }[] | undefined;
54
+ objectStorage: string | undefined;
55
+ bucket: string | undefined;
56
+ bucketAddress: string | undefined;
57
+ };
58
+ project: {
59
+ id: string;
60
+ name: string;
61
+ organizationId: string;
62
+ registry: {
63
+ server: string;
64
+ username: string;
65
+ setAt: string | undefined;
66
+ setBy: string | undefined;
67
+ } | undefined;
68
+ };
69
+ organization: {
70
+ id: string;
71
+ name: string;
72
+ projects: number;
73
+ disabled: boolean;
74
+ role: NonNullable<"member" | "owner"> | undefined;
75
+ quota: {
76
+ projects: number | undefined;
77
+ services: number | undefined;
78
+ instances: number | undefined;
79
+ } | undefined;
80
+ usage: {
81
+ projects: number;
82
+ services: number;
83
+ instances: number;
84
+ };
85
+ };
86
+ topology: {
87
+ service: string;
88
+ running: number;
89
+ contributing: number;
90
+ partial: boolean;
91
+ instances: {
92
+ pod: string;
93
+ status: "failed" | "ok" | "unreachable" | "unsupported";
94
+ problem: string | undefined;
95
+ runtime: string | undefined;
96
+ readAt: string | undefined;
97
+ }[];
98
+ window: {
99
+ seconds: number;
100
+ since: string;
101
+ calls: number;
102
+ unanswered: number;
103
+ };
104
+ nodes: {
105
+ id: string;
106
+ kind: string;
107
+ layer: number;
108
+ platform: boolean;
109
+ handlers: {
110
+ name: string;
111
+ type: string;
112
+ streaming: boolean | undefined;
113
+ }[];
114
+ }[];
115
+ declared: {
116
+ from: string;
117
+ to: string;
118
+ kind: string;
119
+ }[];
120
+ calls: {
121
+ from: string;
122
+ to: string;
123
+ pairs: {
124
+ caller: string;
125
+ callee: string;
126
+ handled: {
127
+ ok: number;
128
+ refused: number;
129
+ failed: number;
130
+ };
131
+ unanswered: {
132
+ timedOut: number;
133
+ undelivered: number;
134
+ };
135
+ durationMillis: {
136
+ p50: number;
137
+ p99: number;
138
+ max: number;
139
+ bucketed: boolean;
140
+ };
141
+ streaming: boolean;
142
+ histogram: number[];
143
+ }[];
144
+ }[];
145
+ differences: {
146
+ node: string;
147
+ presentOn: string[];
148
+ }[];
149
+ } | null;
150
+ none: string | undefined;
151
+ }>;
152
+ export default function ServiceTopologyPage(): import("react").JSX.Element;
153
+ 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,41 @@ export declare function loader({ params, context }: LoaderFunctionArgs): Promise
25
25
  paused: boolean;
26
26
  hosting: string;
27
27
  protocol: string | undefined;
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
+ topicChecks: {
38
+ topic: string | undefined;
39
+ service: string;
40
+ component: string;
41
+ direction: string;
42
+ stated: string | undefined;
43
+ state: string;
44
+ }[] | undefined;
45
+ topicSources: {
46
+ kind: string;
47
+ component: string;
48
+ topic: string;
49
+ group: string;
50
+ start: string;
51
+ version: number;
52
+ recordedVersion: number | undefined;
53
+ behind: boolean;
54
+ broker: string | undefined;
55
+ contract: string | undefined;
56
+ lag: number | undefined;
57
+ failing: string | undefined;
58
+ }[] | undefined;
59
+ objectStorage: string | undefined;
60
+ bucket: string | undefined;
61
+ bucketAddress: string | undefined;
28
62
  };
29
- history: {
30
- kind: string;
31
- generation: number;
32
- actor: {
33
- subject: string;
34
- display: string | undefined;
35
- administrative: boolean;
36
- } | undefined;
37
- at: string | undefined;
38
- }[];
39
63
  project: {
40
64
  id: string;
41
65
  name: string;
@@ -5,9 +5,12 @@ 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, Submit, useConsole } from "../ui/console.js";
11
+ import { Page, SectionTitle, ServiceSections } from "../ui/shell.js";
12
+ import { Shape } from "../ui/shape.js";
13
+ import { runsAs } from "../ui/hosting.js";
11
14
  import { Refused, useRefusal } from "../ui/refused.js";
12
15
  import { Lifecycle } from "../ui/status.js";
13
16
  import { useServiceStream } from "../ui/use-stream.js";
@@ -17,14 +20,20 @@ export async function loader({ params, context }) {
17
20
  const ctx = useConsoleContext(context);
18
21
  const { projectId, name } = params;
19
22
  return guard(ctx, async () => {
20
- const [service, history, project, page] = await Promise.all([
23
+ const [service, project, services, page] = await Promise.all([
21
24
  ctx.client.getService(projectId, name),
22
- ctx.client.history(projectId, name),
23
25
  ctx.client.getProject(projectId),
26
+ ctx.client.listServices(projectId),
24
27
  pageData(ctx),
25
28
  ]);
26
29
  const organization = await ctx.client.getOrganization(project.organizationId);
27
- return { console: page, service, history: [...history].reverse(), project, organization, panels: await loadPanels(ctx, "service", service) };
30
+ return {
31
+ console: projectShell(page, organization, project, services, [{ label: name }], applyPrimary(projectId, name), name),
32
+ service,
33
+ project,
34
+ organization,
35
+ panels: await loadPanels(ctx, "service", service),
36
+ };
28
37
  });
29
38
  }
30
39
  const serviceOperations = ["pause", "resume", "restart", "expose", "unexpose"];
@@ -44,30 +53,26 @@ export async function action({ request, params, context }) {
44
53
  return redirect(ctx.href(`projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(name)}`));
45
54
  });
46
55
  }
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
- };
56
+ /** A service's bucket, that it has a store of its own, or none — and a phrase while it waits or failed. */
57
+ function objectStorage(s) {
58
+ if (s.objectStorage && /waiting|failed/.test(s.objectStorage))
59
+ return s.objectStorage;
60
+ if (s.bucket)
61
+ return s.bucket;
62
+ if (s.objectStorage === "supplied")
63
+ return "Its own";
64
+ return "None";
65
+ }
56
66
  function Operation({ intent, label, operation, danger, entity }) {
57
67
  const { shows } = useConsole();
58
68
  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
69
  }
60
70
  export default function Service() {
61
71
  const data = useLoaderData();
62
- const { project: p, organization: o, panels } = data;
72
+ const { project: p, panels } = data;
63
73
  const { status: s, state } = useServiceStream(p.id, data.service.name, data.service);
64
74
  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" })] })] })] }));
75
+ 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 })] }), serviceOperations.map((op) => (_jsx(Refused, { intent: op }, op))), _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" })] })] })] }));
76
+ 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 }), _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: "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.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.broker ? (_jsxs(_Fragment, { children: [_jsx("dt", { children: "Broker" }), _jsx("dd", { children: s.broker })] })) : 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.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("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 }) : "—" })] }, 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
77
  }
73
78
  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 {