ankka-console 0.10.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 (99) hide show
  1. package/README.md +32 -0
  2. package/dist/auth/oidc.d.ts +64 -0
  3. package/dist/auth/oidc.js +199 -0
  4. package/dist/client/control-plane.d.ts +67 -0
  5. package/dist/client/control-plane.js +208 -0
  6. package/dist/client/errors.d.ts +26 -0
  7. package/dist/client/errors.js +59 -0
  8. package/dist/client/index.d.ts +4 -0
  9. package/dist/client/index.js +3 -0
  10. package/dist/client/schemas.d.ts +365 -0
  11. package/dist/client/schemas.js +221 -0
  12. package/dist/context.d.ts +83 -0
  13. package/dist/context.js +156 -0
  14. package/dist/extensions/render.d.ts +20 -0
  15. package/dist/extensions/render.js +49 -0
  16. package/dist/extensions/types.d.ts +44 -0
  17. package/dist/extensions/types.js +30 -0
  18. package/dist/index.d.ts +5 -0
  19. package/dist/index.js +5 -0
  20. package/dist/middleware.d.ts +14 -0
  21. package/dist/middleware.js +48 -0
  22. package/dist/options.d.ts +45 -0
  23. package/dist/options.js +39 -0
  24. package/dist/routes/auth.callback.d.ts +11 -0
  25. package/dist/routes/auth.callback.js +43 -0
  26. package/dist/routes/auth.sign-in.d.ts +11 -0
  27. package/dist/routes/auth.sign-in.js +29 -0
  28. package/dist/routes/auth.sign-out.d.ts +19 -0
  29. package/dist/routes/auth.sign-out.js +37 -0
  30. package/dist/routes/front.d.ts +41 -0
  31. package/dist/routes/front.js +25 -0
  32. package/dist/routes/logs.d.ts +67 -0
  33. package/dist/routes/logs.js +72 -0
  34. package/dist/routes/members.d.ts +45 -0
  35. package/dist/routes/members.js +59 -0
  36. package/dist/routes/organization-new.d.ts +14 -0
  37. package/dist/routes/organization-new.js +30 -0
  38. package/dist/routes/organization.d.ts +43 -0
  39. package/dist/routes/organization.js +82 -0
  40. package/dist/routes/project-new.d.ts +27 -0
  41. package/dist/routes/project-new.js +27 -0
  42. package/dist/routes/project.d.ts +59 -0
  43. package/dist/routes/project.js +61 -0
  44. package/dist/routes/service-apply.d.ts +44 -0
  45. package/dist/routes/service-apply.js +65 -0
  46. package/dist/routes/service.d.ts +71 -0
  47. package/dist/routes/service.js +73 -0
  48. package/dist/routes/stream.project.d.ts +3 -0
  49. package/dist/routes/stream.project.js +25 -0
  50. package/dist/routes/stream.service.d.ts +9 -0
  51. package/dist/routes/stream.service.js +68 -0
  52. package/dist/routes/tokens.d.ts +47 -0
  53. package/dist/routes/tokens.js +50 -0
  54. package/dist/routes.d.ts +28 -0
  55. package/dist/routes.js +48 -0
  56. package/dist/runtime.d.ts +28 -0
  57. package/dist/runtime.js +77 -0
  58. package/dist/server/create-console-server.d.ts +33 -0
  59. package/dist/server/create-console-server.js +203 -0
  60. package/dist/server/index.d.ts +15 -0
  61. package/dist/server/index.js +13 -0
  62. package/dist/server/transport.d.ts +25 -0
  63. package/dist/server/transport.js +97 -0
  64. package/dist/session/cookie-store.d.ts +51 -0
  65. package/dist/session/cookie-store.js +66 -0
  66. package/dist/session/cookies.d.ts +15 -0
  67. package/dist/session/cookies.js +33 -0
  68. package/dist/session/seal.d.ts +12 -0
  69. package/dist/session/seal.js +38 -0
  70. package/dist/session/token-cache.d.ts +21 -0
  71. package/dist/session/token-cache.js +47 -0
  72. package/dist/session/token-source.d.ts +41 -0
  73. package/dist/session/token-source.js +63 -0
  74. package/dist/stream/log-follow.d.ts +18 -0
  75. package/dist/stream/log-follow.js +40 -0
  76. package/dist/stream/registry.d.ts +6 -0
  77. package/dist/stream/registry.js +34 -0
  78. package/dist/stream/sse.d.ts +14 -0
  79. package/dist/stream/sse.js +87 -0
  80. package/dist/styles.css +481 -0
  81. package/dist/testing/fake-control-plane.d.ts +161 -0
  82. package/dist/testing/fake-control-plane.js +715 -0
  83. package/dist/testing/fake-issuer.d.ts +66 -0
  84. package/dist/testing/fake-issuer.js +303 -0
  85. package/dist/testing/index.d.ts +5 -0
  86. package/dist/testing/index.js +3 -0
  87. package/dist/testing/scenarios.d.ts +6 -0
  88. package/dist/testing/scenarios.js +44 -0
  89. package/dist/ui/console.d.ts +60 -0
  90. package/dist/ui/console.js +73 -0
  91. package/dist/ui/errors.d.ts +6 -0
  92. package/dist/ui/errors.js +30 -0
  93. package/dist/ui/refused.d.ts +6 -0
  94. package/dist/ui/refused.js +12 -0
  95. package/dist/ui/status.d.ts +5 -0
  96. package/dist/ui/status.js +23 -0
  97. package/dist/ui/use-stream.d.ts +59 -0
  98. package/dist/ui/use-stream.js +67 -0
  99. package/package.json +79 -0
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A project: its services, kept current while the page is open, and its registry credential.
4
+ */
5
+ import { redirect, useLoaderData } from "react-router";
6
+ import { act, guard, pageData, text, useConsoleContext } from "../context.js";
7
+ import { ConsoleErrorBoundary } from "../ui/errors.js";
8
+ import { Breadcrumbs, ConsoleForm, ConsoleLink, Field, Submit, useConsole, when } from "../ui/console.js";
9
+ import { Refused, useRefusal } from "../ui/refused.js";
10
+ import { Lifecycle } from "../ui/status.js";
11
+ import { useProjectStream } from "../ui/use-stream.js";
12
+ import { HostActions, loadPanels, Panels } from "../extensions/render.js";
13
+ export const meta = ({ loaderData }) => [{ title: `${loaderData?.project.name ?? "Project"} · ankka` }];
14
+ export async function loader({ params, context }) {
15
+ const ctx = useConsoleContext(context);
16
+ const id = params.projectId;
17
+ return guard(ctx, async () => {
18
+ const [project, services, page] = await Promise.all([ctx.client.getProject(id), ctx.client.listServices(id), pageData(ctx)]);
19
+ const organization = await ctx.client.getOrganization(project.organizationId);
20
+ return { console: page, project, organization, services, panels: await loadPanels(ctx, "project", project) };
21
+ });
22
+ }
23
+ export async function action({ request, params, context }) {
24
+ const ctx = useConsoleContext(context);
25
+ const id = params.projectId;
26
+ const form = await request.formData();
27
+ const intent = text(form, "intent");
28
+ const self = ctx.href(`projects/${encodeURIComponent(id)}`);
29
+ return act(ctx, intent, form, async () => {
30
+ switch (intent) {
31
+ case "rename":
32
+ await ctx.client.renameProject(id, text(form, "name"));
33
+ return redirect(self);
34
+ case "delete": {
35
+ const project = await ctx.client.getProject(id);
36
+ await ctx.client.deleteProject(id);
37
+ return redirect(ctx.href(`organizations/${encodeURIComponent(project.organizationId)}`));
38
+ }
39
+ case "registry-set":
40
+ // The password crosses once, to the control plane, and is never shown again by anything.
41
+ await ctx.client.setRegistry(id, { server: text(form, "server"), username: text(form, "username"), password: String(form.get("password") ?? "") });
42
+ return redirect(self);
43
+ case "registry-clear":
44
+ await ctx.client.clearRegistry(id);
45
+ return redirect(self);
46
+ default:
47
+ throw new Response(`unknown operation '${intent}'`, { status: 400 });
48
+ }
49
+ });
50
+ }
51
+ export default function Project() {
52
+ const { project: p, organization: o, services: initial, panels } = useLoaderData();
53
+ const { services, state } = useProjectStream(p.id, initial);
54
+ const { shows } = useConsole();
55
+ const renameRefusal = useRefusal("rename");
56
+ const deleteRefusal = useRefusal("delete");
57
+ const registryRefusal = useRefusal("registry-set");
58
+ const path = `projects/${encodeURIComponent(p.id)}`;
59
+ return (_jsxs("section", { className: "ac-page", children: [_jsx(Breadcrumbs, { trail: [{ label: "Organizations", to: "" }, { label: o.name, to: `organizations/${encodeURIComponent(o.id)}` }, { label: p.name }] }), _jsx("h1", { children: p.name }), _jsxs("dl", { className: "ac-facts", children: [_jsx("dt", { children: "Id" }), _jsx("dd", { children: p.id }), _jsx("dt", { children: "Organization" }), _jsx("dd", { children: o.name })] }), _jsx("h2", { id: "services", children: "Services" }), state === "live" ? _jsx("p", { className: "ac-live", children: "Updating as the platform reports" }) : null, services.length === 0 ? (_jsx("p", { className: "ac-empty", children: "No services yet. Apply a descriptor to deploy one." })) : (_jsx("div", { className: "ac-table-wrap", children: _jsxs("table", { className: "ac-table", "aria-describedby": "services", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Service" }), _jsx("th", { scope: "col", children: "State" }), _jsx("th", { scope: "col", className: "ac-num", children: "Instances" }), _jsx("th", { scope: "col", children: "Image" }), _jsx("th", { scope: "col", className: "ac-num", children: "Generation" }), _jsx("th", { scope: "col", children: "Address" })] }) }), _jsx("tbody", { children: services.map((s) => (_jsxs("tr", { "data-service": s.name, children: [_jsx("td", { children: _jsx(ConsoleLink, { to: `${path}/services/${encodeURIComponent(s.name)}`, children: s.name }) }), _jsx("td", { children: _jsx(Lifecycle, { lifecycle: s.lifecycle, confirmed: s.confirmed }) }), _jsxs("td", { className: "ac-num", children: [s.readyInstances, " of ", s.desiredInstances] }), _jsx("td", { children: s.image }), _jsx("td", { className: "ac-num", children: s.generation }), _jsx("td", { children: s.hostname ? _jsx("a", { href: s.hostname, children: s.hostname.replace(/^https:\/\//, "") }) : s.exposed ? "Exposed, no address yet" : "Not exposed" })] }, s.name))) })] }) })), _jsxs("div", { className: "ac-actions", children: [shows("service.apply") ? (_jsx(ConsoleLink, { to: `${path}/services/apply`, className: "ac-button", children: "Apply a descriptor" })) : null, _jsx(HostActions, { operation: "service.apply", entity: p })] }), _jsx("h2", { children: "Registry" }), p.registry ? (_jsxs("p", { children: ["Private images are pulled from ", _jsx("strong", { children: p.registry.server }), " as ", _jsx("strong", { children: p.registry.username }), ", set ", when(p.registry.setAt), p.registry.setBy ? ` by ${p.registry.setBy}` : "", "."] })) : (_jsx("p", { children: "Services in this project pull only public images. Set a credential to pull private ones." })), shows("registry.set") ? (_jsxs("details", { className: "ac-more", open: registryRefusal !== undefined || undefined, children: [_jsx("summary", { children: p.registry ? "Replace the registry credential" : "Set a registry credential" }), _jsxs(ConsoleForm, { intent: "registry-set", className: "ac-form", children: [_jsx(Field, { label: "Registry server", name: "server", required: true, placeholder: "ghcr.io", defaultValue: registryRefusal?.values.server ?? p.registry?.server }), _jsx(Field, { label: "Username", name: "username", required: true, autoComplete: "off", defaultValue: registryRefusal?.values.username ?? p.registry?.username }), _jsx(Field, { label: "Password or token", name: "password", type: "password", required: true, autoComplete: "new-password", hint: "Sent once to the platform, which keeps it only in the cluster. It is never shown again." }), _jsx(Refused, { intent: "registry-set" }), _jsx("div", { children: _jsx(Submit, { intent: "registry-set", children: "Save credential" }) })] })] })) : null, p.registry && shows("registry.clear") ? (_jsxs(ConsoleForm, { intent: "registry-clear", children: [_jsx(Submit, { intent: "registry-clear", children: "Clear credential" }), _jsx(Refused, { intent: "registry-clear" })] })) : null, _jsx(Panels, { kind: "project", entity: p, loaded: panels }), shows("project.rename") || shows("project.delete") ? (_jsxs("details", { className: "ac-more", open: renameRefusal !== undefined || deleteRefusal !== undefined || undefined, children: [_jsx("summary", { children: "Rename or delete" }), shows("project.rename") ? (_jsxs(ConsoleForm, { intent: "rename", className: "ac-inline", children: [_jsx(Field, { label: "New name", name: "name", required: true, defaultValue: renameRefusal?.values.name ?? p.name }), _jsx(Submit, { intent: "rename", children: "Rename" })] })) : null, _jsx(Refused, { intent: "rename" }), shows("project.delete") ? (_jsxs("div", { className: "ac-danger-zone", children: [_jsxs("p", { children: ["Deleting ", p.name, " is permanent, and its id can never be used again. It must have no services."] }), _jsx(ConsoleForm, { intent: "delete", children: _jsx(Submit, { intent: "delete", danger: true, children: "Delete project" }) }), _jsx(Refused, { intent: "delete" })] })) : null] })) : null] }));
60
+ }
61
+ export const ErrorBoundary = ConsoleErrorBoundary;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Applies a descriptor: pasted, or read from a file. The console checks only that the text is JSON
3
+ * and names a service; every rule about what a descriptor may say is the control plane's, and its
4
+ * refusal is shown problem by problem beside the text.
5
+ */
6
+ import { type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "react-router";
7
+ import { type ActionRefusal } from "../context.ts";
8
+ import { ConsoleErrorBoundary } from "../ui/errors.tsx";
9
+ export declare const meta: MetaFunction;
10
+ export declare function loader({ request, params, context }: LoaderFunctionArgs): Promise<{
11
+ console: import("../context.ts").ConsolePageData;
12
+ project: {
13
+ id: string;
14
+ name: string;
15
+ organizationId: string;
16
+ registry: {
17
+ server: string;
18
+ username: string;
19
+ setAt: string | undefined;
20
+ setBy: string | undefined;
21
+ } | undefined;
22
+ };
23
+ organization: {
24
+ id: string;
25
+ name: string;
26
+ projects: number;
27
+ disabled: boolean;
28
+ role: NonNullable<"member" | "owner"> | undefined;
29
+ quota: {
30
+ projects: number | undefined;
31
+ services: number | undefined;
32
+ instances: number | undefined;
33
+ } | undefined;
34
+ usage: {
35
+ projects: number;
36
+ services: number;
37
+ instances: number;
38
+ };
39
+ };
40
+ name: string | null;
41
+ }>;
42
+ export declare function action({ request, params, context }: ActionFunctionArgs): Promise<import("react-router").UNSAFE_DataWithResponseInit<ActionRefusal> | Response>;
43
+ export default function ApplyDescriptor(): import("react").JSX.Element;
44
+ export declare const ErrorBoundary: typeof ConsoleErrorBoundary;
@@ -0,0 +1,65 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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
5
+ * refusal is shown problem by problem beside the text.
6
+ */
7
+ import { redirect, useLoaderData } from "react-router";
8
+ import { act, guard, pageData, useConsoleContext } from "../context.js";
9
+ import { ConsoleErrorBoundary } from "../ui/errors.js";
10
+ import { Breadcrumbs, ConsoleForm, Submit } from "../ui/console.js";
11
+ import { Refused, useRefusal } from "../ui/refused.js";
12
+ import { data } from "react-router";
13
+ export const meta = () => [{ title: "Apply a descriptor · ankka" }];
14
+ export async function loader({ request, params, context }) {
15
+ const ctx = useConsoleContext(context);
16
+ return guard(ctx, async () => {
17
+ const project = await ctx.client.getProject(params.projectId);
18
+ 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
+ });
22
+ }
23
+ const notApplied = (reason, descriptor) => data({ intent: "apply", status: 400, reason, problems: [reason], values: { descriptor } }, { status: 400 });
24
+ export async function action({ request, params, context }) {
25
+ const ctx = useConsoleContext(context);
26
+ const projectId = params.projectId;
27
+ const form = await request.formData();
28
+ const file = form.get("file");
29
+ const pasted = String(form.get("descriptor") ?? "");
30
+ const descriptor = pasted.trim() !== "" ? pasted : file instanceof File && file.size > 0 ? await file.text() : "";
31
+ if (descriptor.trim() === "")
32
+ return notApplied("Paste a descriptor or choose a service.json file.", descriptor);
33
+ let name;
34
+ try {
35
+ name = JSON.parse(descriptor).name;
36
+ }
37
+ catch (e) {
38
+ return notApplied(`The descriptor is not valid JSON: ${e instanceof Error ? e.message : String(e)}`, descriptor);
39
+ }
40
+ if (typeof name !== "string" || name === "")
41
+ return notApplied('The descriptor has no "name": it names the service it deploys.', descriptor);
42
+ const values = new FormData();
43
+ values.set("descriptor", descriptor);
44
+ return act(ctx, "apply", values, async () => {
45
+ await ctx.client.applyService(projectId, name, descriptor);
46
+ return redirect(ctx.href(`projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(name)}`));
47
+ });
48
+ }
49
+ const example = `{
50
+ "name": "cart",
51
+ "service": {
52
+ "image": "ghcr.io/example/cart:1.0.0"
53
+ }
54
+ }`;
55
+ export default function ApplyDescriptor() {
56
+ const { project: p, organization: o, name } = useLoaderData();
57
+ 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" }) })] })] }));
64
+ }
65
+ export const ErrorBoundary = ConsoleErrorBoundary;
@@ -0,0 +1,71 @@
1
+ /**
2
+ * A service: everything the platform reports about it, kept current while the page is open, its
3
+ * attributed history, and the operations the CLI offers. After an operation the page shows the
4
+ * state the control plane reports, never what the console expected.
5
+ */
6
+ import { type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "react-router";
7
+ import { ConsoleErrorBoundary } from "../ui/errors.tsx";
8
+ export declare const meta: MetaFunction<typeof loader>;
9
+ export declare function loader({ params, context }: LoaderFunctionArgs): Promise<{
10
+ console: import("../context.ts").ConsolePageData;
11
+ service: {
12
+ name: string;
13
+ projectId: string;
14
+ lifecycle: string;
15
+ generation: number;
16
+ image: string;
17
+ readyInstances: number;
18
+ desiredInstances: number;
19
+ detail: string | undefined;
20
+ confirmed: boolean;
21
+ database: string | undefined;
22
+ hostname: string | undefined;
23
+ exposed: boolean;
24
+ suspended: boolean;
25
+ paused: boolean;
26
+ hosting: string;
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;
36
+ } | undefined;
37
+ at: string | undefined;
38
+ }[];
39
+ project: {
40
+ id: string;
41
+ name: string;
42
+ organizationId: string;
43
+ registry: {
44
+ server: string;
45
+ username: string;
46
+ setAt: string | undefined;
47
+ setBy: string | undefined;
48
+ } | undefined;
49
+ };
50
+ organization: {
51
+ id: string;
52
+ name: string;
53
+ projects: number;
54
+ disabled: boolean;
55
+ role: NonNullable<"member" | "owner"> | undefined;
56
+ quota: {
57
+ projects: number | undefined;
58
+ services: number | undefined;
59
+ instances: number | undefined;
60
+ } | undefined;
61
+ usage: {
62
+ projects: number;
63
+ services: number;
64
+ instances: number;
65
+ };
66
+ };
67
+ panels: import("../extensions/render.tsx").LoadedPanels;
68
+ }>;
69
+ export declare function action({ request, params, context }: ActionFunctionArgs): Promise<import("react-router").UNSAFE_DataWithResponseInit<import("../context.ts").ActionRefusal> | Response>;
70
+ export default function Service(): import("react").JSX.Element;
71
+ export declare const ErrorBoundary: typeof ConsoleErrorBoundary;
@@ -0,0 +1,73 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * A service: everything the platform reports about it, kept current while the page is open, its
4
+ * attributed history, and the operations the CLI offers. After an operation the page shows the
5
+ * state the control plane reports, never what the console expected.
6
+ */
7
+ import { redirect, useLoaderData } from "react-router";
8
+ import { act, guard, pageData, text, useConsoleContext } from "../context.js";
9
+ import { ConsoleErrorBoundary } from "../ui/errors.js";
10
+ import { Breadcrumbs, ConsoleForm, ConsoleLink, Submit, useConsole, when } from "../ui/console.js";
11
+ import { Refused, useRefusal } from "../ui/refused.js";
12
+ import { Lifecycle } from "../ui/status.js";
13
+ import { useServiceStream } from "../ui/use-stream.js";
14
+ import { HostActions, loadPanels, Panels } from "../extensions/render.js";
15
+ export const meta = ({ loaderData }) => [{ title: `${loaderData?.service.name ?? "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 [service, history, project, page] = await Promise.all([
21
+ ctx.client.getService(projectId, name),
22
+ ctx.client.history(projectId, name),
23
+ ctx.client.getProject(projectId),
24
+ pageData(ctx),
25
+ ]);
26
+ const organization = await ctx.client.getOrganization(project.organizationId);
27
+ return { console: page, service, history: [...history].reverse(), project, organization, panels: await loadPanels(ctx, "service", service) };
28
+ });
29
+ }
30
+ const serviceOperations = ["pause", "resume", "restart", "expose", "unexpose"];
31
+ export async function action({ request, params, context }) {
32
+ const ctx = useConsoleContext(context);
33
+ const { projectId, name } = params;
34
+ const form = await request.formData();
35
+ const intent = text(form, "intent");
36
+ return act(ctx, intent, form, async () => {
37
+ if (intent === "delete") {
38
+ await ctx.client.deleteService(projectId, name);
39
+ return redirect(ctx.href(`projects/${encodeURIComponent(projectId)}`));
40
+ }
41
+ if (!serviceOperations.includes(intent))
42
+ throw new Response(`unknown operation '${intent}'`, { status: 400 });
43
+ await ctx.client.serviceOperation(projectId, name, intent);
44
+ return redirect(ctx.href(`projects/${encodeURIComponent(projectId)}/services/${encodeURIComponent(name)}`));
45
+ });
46
+ }
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
+ function Operation({ intent, label, operation, danger, entity }) {
57
+ const { shows } = useConsole();
58
+ 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
+ }
60
+ export default function Service() {
61
+ const data = useLoaderData();
62
+ const { project: p, organization: o, panels } = data;
63
+ const { status: s, state } = useServiceStream(p.id, data.service.name, data.service);
64
+ 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" })] })] })] }));
72
+ }
73
+ export const ErrorBoundary = ConsoleErrorBoundary;
@@ -0,0 +1,3 @@
1
+ /** `GET <mount>/stream/projects/:projectId`: a project's service listing, sent whenever it changes. */
2
+ import type { LoaderFunctionArgs } from "react-router";
3
+ export declare function loader({ request, params, context }: LoaderFunctionArgs): Promise<Response>;
@@ -0,0 +1,25 @@
1
+ import { useConsoleContext } from "../context.js";
2
+ import { pollingStream } from "../stream/sse.js";
3
+ import { ended } from "./stream.service.js";
4
+ export async function loader({ request, params, context }) {
5
+ const ctx = useConsoleContext(context);
6
+ const projectId = params.projectId;
7
+ let last = "";
8
+ return pollingStream({
9
+ signal: request.signal,
10
+ async tick(sink) {
11
+ try {
12
+ const services = await ctx.client.listServices(projectId);
13
+ const now = JSON.stringify(services);
14
+ if (now !== last) {
15
+ last = now;
16
+ sink.send("services", services);
17
+ }
18
+ }
19
+ catch (e) {
20
+ if (!ended(sink, e))
21
+ throw e;
22
+ }
23
+ },
24
+ });
25
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `GET <mount>/stream/services/:projectId/:name`: a service page's live updates. Every two seconds
3
+ * the service is read as the signed-in person and `status` is sent when it changed; with `logs` in
4
+ * the query, the chosen instances' recent output is read too and only new lines are sent.
5
+ */
6
+ import type { LoaderFunctionArgs } from "react-router";
7
+ import { type EventSink } from "../stream/sse.ts";
8
+ export declare function ended(sink: EventSink, e: unknown): boolean;
9
+ export declare function loader({ request, params, context }: LoaderFunctionArgs): Promise<Response>;
@@ -0,0 +1,68 @@
1
+ import { useConsoleContext } from "../context.js";
2
+ import { ControlPlaneError, SignInRequired } from "../client/errors.js";
3
+ import { pollingStream } from "../stream/sse.js";
4
+ import { LogFollower, lines } from "../stream/log-follow.js";
5
+ export function ended(sink, e) {
6
+ if (e instanceof SignInRequired) {
7
+ sink.end("session-ended", { reason: "sign-in-required" });
8
+ return true;
9
+ }
10
+ if (e instanceof ControlPlaneError && e.status === 404) {
11
+ sink.end("gone", { reason: e.reason });
12
+ return true;
13
+ }
14
+ return false;
15
+ }
16
+ export async function loader({ request, params, context }) {
17
+ const ctx = useConsoleContext(context);
18
+ const projectId = params.projectId;
19
+ const name = params.name;
20
+ const query = new URL(request.url).searchParams;
21
+ const follow = query.has("logs");
22
+ 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 };
24
+ const follower = new LogFollower(tail);
25
+ const primed = new Set();
26
+ let last = "";
27
+ return pollingStream({
28
+ signal: request.signal,
29
+ async tick(sink) {
30
+ try {
31
+ const status = await ctx.client.getService(projectId, name);
32
+ const now = JSON.stringify(status);
33
+ if (now !== last) {
34
+ last = now;
35
+ sink.send("status", status);
36
+ }
37
+ }
38
+ catch (e) {
39
+ if (ended(sink, e))
40
+ return;
41
+ throw e;
42
+ }
43
+ if (!follow)
44
+ return;
45
+ try {
46
+ const logs = await ctx.client.logs(projectId, name, logQuery);
47
+ for (const instance of logs.instances) {
48
+ if (instance.error) {
49
+ sink.send("logs", { instance: instance.instance, error: instance.error });
50
+ continue;
51
+ }
52
+ const fresh = follower.next(instance.instance, lines(instance.output));
53
+ // The first window replaces what the page rendered, so nothing written between the page's
54
+ // own read and this one is lost.
55
+ const reset = !primed.has(instance.instance);
56
+ primed.add(instance.instance);
57
+ if (fresh.length > 0 || reset)
58
+ sink.send("logs", { instance: instance.instance, lines: fresh, reset });
59
+ }
60
+ }
61
+ catch (e) {
62
+ if (e instanceof SignInRequired)
63
+ return void ended(sink, e);
64
+ // A service with no running instance has no logs; its status says why.
65
+ }
66
+ },
67
+ });
68
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * An organization's deploy tokens. A new token's secret is shown exactly once: the creation
3
+ * redirects (so a reload can never create a second token), carrying the secret across in a sealed
4
+ * cookie that this page reads and clears on its next render.
5
+ */
6
+ import { type ActionFunctionArgs, type LoaderFunctionArgs, type MetaFunction } from "react-router";
7
+ import { ConsoleErrorBoundary } from "../ui/errors.tsx";
8
+ export declare const meta: MetaFunction;
9
+ export declare function loader({ request, params, context }: LoaderFunctionArgs): Promise<{
10
+ console: import("../context.ts").ConsolePageData;
11
+ organization: {
12
+ id: string;
13
+ name: string;
14
+ projects: number;
15
+ disabled: boolean;
16
+ role: NonNullable<"member" | "owner"> | undefined;
17
+ quota: {
18
+ projects: number | undefined;
19
+ services: number | undefined;
20
+ instances: number | undefined;
21
+ } | undefined;
22
+ usage: {
23
+ projects: number;
24
+ services: number;
25
+ instances: number;
26
+ };
27
+ };
28
+ tokens: {
29
+ id: string;
30
+ label: string;
31
+ subject: string;
32
+ createdBy: string | undefined;
33
+ createdAt: string | undefined;
34
+ expiresAt: string | undefined;
35
+ lastUsed: string | undefined;
36
+ }[];
37
+ created: {
38
+ id: string;
39
+ label: string;
40
+ secret: string;
41
+ subject: string;
42
+ expiresAt: string | undefined;
43
+ } | null;
44
+ }>;
45
+ export declare function action({ request, params, context }: ActionFunctionArgs): Promise<import("react-router").UNSAFE_DataWithResponseInit<import("../context.ts").ActionRefusal> | Response>;
46
+ export default function Tokens(): import("react").JSX.Element;
47
+ export declare const ErrorBoundary: typeof ConsoleErrorBoundary;
@@ -0,0 +1,50 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * An organization's deploy tokens. A new token's secret is shown exactly once: the creation
4
+ * redirects (so a reload can never create a second token), carrying the secret across in a sealed
5
+ * cookie that this page reads and clears on its next render.
6
+ */
7
+ import { redirect, useLoaderData } from "react-router";
8
+ import { act, guard, pageData, text, useConsoleContext } from "../context.js";
9
+ import { ConsoleErrorBoundary } from "../ui/errors.js";
10
+ import { Breadcrumbs, ConsoleForm, Field, Submit, useConsole, when } from "../ui/console.js";
11
+ import { Refused, useRefusal } from "../ui/refused.js";
12
+ import { HostActions } from "../extensions/render.js";
13
+ export const meta = () => [{ title: "Deploy tokens · ankka" }];
14
+ export async function loader({ request, params, context }) {
15
+ const ctx = useConsoleContext(context);
16
+ const id = params.organizationId;
17
+ return guard(ctx, async () => {
18
+ const [organization, tokens, page] = await Promise.all([ctx.client.getOrganization(id), ctx.client.tokens(id), pageData(ctx)]);
19
+ const created = await ctx.runtime.tokenFlash.take(request, ctx.headers);
20
+ // Shown from the one-time cookie alone: the listing is a projection and may not show the new token yet.
21
+ return { console: page, organization, tokens, created };
22
+ });
23
+ }
24
+ export async function action({ request, params, context }) {
25
+ const ctx = useConsoleContext(context);
26
+ const id = params.organizationId;
27
+ const form = await request.formData();
28
+ const intent = text(form, "intent");
29
+ const self = ctx.href(`organizations/${encodeURIComponent(id)}/tokens`);
30
+ return act(ctx, intent, form, async () => {
31
+ if (intent === "create") {
32
+ const days = text(form, "expiresInDays");
33
+ const created = await ctx.client.createToken(id, { label: text(form, "label"), expiresIn: days ? Number(days) * 86_400 : undefined });
34
+ await ctx.runtime.tokenFlash.write(created, ctx.headers);
35
+ return redirect(self);
36
+ }
37
+ if (intent === "revoke") {
38
+ await ctx.client.revokeToken(id, text(form, "tokenId"));
39
+ return redirect(self);
40
+ }
41
+ throw new Response(`unknown operation '${intent}'`, { status: 400 });
42
+ });
43
+ }
44
+ export default function Tokens() {
45
+ const { organization: o, tokens, created } = useLoaderData();
46
+ const { shows } = useConsole();
47
+ const refusal = useRefusal("create");
48
+ return (_jsxs("section", { className: "ac-page", children: [_jsx(Breadcrumbs, { trail: [{ label: "Organizations", to: "" }, { label: o.name, to: `organizations/${encodeURIComponent(o.id)}` }, { label: "Deploy tokens" }] }), _jsxs("h1", { children: ["Deploy tokens for ", o.name] }), _jsx("p", { className: "ac-lede", children: "A deploy token lets a machine, such as a CI job, deploy as a member of this organization. It cannot manage members or tokens." }), created ? (_jsxs("div", { className: "ac-notice", role: "status", children: [_jsxs("p", { children: [_jsxs("strong", { children: ["Copy the token for ", created.label, " now."] }), " It is shown this once and cannot be retrieved later."] }), _jsx("code", { className: "ac-secret", "data-secret": true, children: created.secret })] })) : null, tokens.length === 0 ? (_jsx("p", { className: "ac-empty", children: "No deploy tokens." })) : (_jsx("div", { className: "ac-table-wrap", children: _jsxs("table", { className: "ac-table", children: [_jsx("caption", { className: "ac-visually-hidden", children: "Deploy tokens" }), _jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { scope: "col", children: "Label" }), _jsx("th", { scope: "col", children: "Id" }), _jsx("th", { scope: "col", children: "Created" }), _jsx("th", { scope: "col", children: "Expires" }), _jsx("th", { scope: "col", children: "Last used" }), _jsx("th", { scope: "col", children: "Revoke" })] }) }), _jsx("tbody", { children: tokens.map((t) => (_jsxs("tr", { "data-token": t.id, children: [_jsx("td", { children: t.label }), _jsx("td", { children: t.id }), _jsxs("td", { children: [when(t.createdAt), t.createdBy ? ` by ${t.createdBy}` : ""] }), _jsx("td", { children: t.expiresAt ? when(t.expiresAt) : "Never" }), _jsx("td", { children: t.lastUsed ?? "Never" }), _jsx("td", { children: shows("token.revoke") ? (_jsxs(ConsoleForm, { intent: "revoke", children: [_jsx("input", { type: "hidden", name: "tokenId", value: t.id }), _jsx(Submit, { intent: "revoke", danger: true, children: "Revoke" })] })) : null })] }, t.id))) })] }) })), _jsx(Refused, { intent: "revoke" }), shows("token.create") ? (_jsxs(_Fragment, { children: [_jsx("h2", { children: "Create a deploy token" }), _jsxs(ConsoleForm, { intent: "create", className: "ac-inline", children: [_jsx(Field, { label: "Label", name: "label", required: true, hint: "What uses it, such as a repository's CI.", defaultValue: refusal?.values.label, autoComplete: "off" }), _jsx(Field, { label: "Expires after days", name: "expiresInDays", type: "number", min: 0, max: 365, hint: "Empty for the platform's default of 90 days; 0 for never; at most 365.", defaultValue: refusal?.values.expiresInDays }), _jsx(Submit, { intent: "create", children: "Create token" })] }), _jsx(Refused, { intent: "create" }), _jsx("div", { className: "ac-actions", children: _jsx(HostActions, { operation: "token.create", entity: o }) })] })) : null] }));
49
+ }
50
+ export const ErrorBoundary = ConsoleErrorBoundary;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A route config entry, the shape `@react-router/dev/routes` builds. Declared here rather than
3
+ * imported so that the package's runtime entry point never loads the framework's build tooling.
4
+ */
5
+ export interface RouteConfigEntry {
6
+ id?: string;
7
+ path?: string;
8
+ index?: boolean;
9
+ caseSensitive?: boolean;
10
+ file: string;
11
+ children?: RouteConfigEntry[];
12
+ }
13
+ export interface ConsoleRoutesOptions {
14
+ /**
15
+ * Whether the package's own sign-in routes are included. A host that signs people in itself and
16
+ * supplies a `TokenSource` passes `false`.
17
+ */
18
+ auth?: boolean;
19
+ }
20
+ /**
21
+ * The console's pages and resource routes, to be spread inside a host's layout and under any
22
+ * prefix it chooses:
23
+ *
24
+ * ```ts
25
+ * layout("./layout.tsx", [...prefix("console", consoleRoutes())])
26
+ * ```
27
+ */
28
+ export declare function consoleRoutes(options?: ConsoleRoutesOptions): RouteConfigEntry[];
package/dist/routes.js ADDED
@@ -0,0 +1,48 @@
1
+ import { fileURLToPath } from "node:url";
2
+ // The route modules sit beside this file: `src/routes/*.tsx` when the package is used from source
3
+ // (the repository's own host and tests, under the `ankka-source` condition), `dist/routes/*.js` when
4
+ // it is installed from npm. Route config entries are file paths, not module specifiers, so each is
5
+ // an absolute path into wherever the package is installed.
6
+ const here = import.meta.url;
7
+ const fromSource = here.endsWith(".ts");
8
+ const routesDir = fileURLToPath(new URL("./routes/", here));
9
+ function file(name, kind) {
10
+ const ext = fromSource ? (kind === "page" ? ".tsx" : ".ts") : ".js";
11
+ return routesDir + name + ext;
12
+ }
13
+ function page(path, name) {
14
+ return path === undefined
15
+ ? { id: `ankka-console/${name}`, index: true, file: file(name, "page") }
16
+ : { id: `ankka-console/${name}`, path, file: file(name, "page") };
17
+ }
18
+ function resource(path, name) {
19
+ return { id: `ankka-console/${name}`, path, file: file(name, "resource") };
20
+ }
21
+ /**
22
+ * The console's pages and resource routes, to be spread inside a host's layout and under any
23
+ * prefix it chooses:
24
+ *
25
+ * ```ts
26
+ * layout("./layout.tsx", [...prefix("console", consoleRoutes())])
27
+ * ```
28
+ */
29
+ export function consoleRoutes(options = {}) {
30
+ const auth = options.auth ?? true;
31
+ return [
32
+ page(undefined, "front"),
33
+ page("organizations/new", "organization-new"),
34
+ page("organizations/:organizationId", "organization"),
35
+ page("organizations/:organizationId/members", "members"),
36
+ page("organizations/:organizationId/tokens", "tokens"),
37
+ page("organizations/:organizationId/projects/new", "project-new"),
38
+ page("projects/:projectId", "project"),
39
+ page("projects/:projectId/services/apply", "service-apply"),
40
+ page("projects/:projectId/services/:name", "service"),
41
+ page("projects/:projectId/services/:name/logs", "logs"),
42
+ resource("stream/projects/:projectId", "stream.project"),
43
+ resource("stream/services/:projectId/:name", "stream.service"),
44
+ ...(auth
45
+ ? [page("auth/sign-in", "auth.sign-in"), page("auth/callback", "auth.callback"), page("auth/sign-out", "auth.sign-out")]
46
+ : []),
47
+ ];
48
+ }