@norskvideo/ctl-sdk 0.1.27 → 0.1.29
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.
- package/index.d.ts +1 -0
- package/index.js +1 -0
- package/package.json +1 -1
- package/product-hub.d.ts +22 -0
- package/product-hub.js +114 -0
package/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from "./manifest-fetch.js";
|
|
|
13
13
|
export * from "./manifest-router.js";
|
|
14
14
|
export * from "./openapi-router.js";
|
|
15
15
|
export * from "./product-health-monitor.js";
|
|
16
|
+
export * from "./product-hub.js";
|
|
16
17
|
export * from "./product-reach.js";
|
|
17
18
|
export * from "./product-service.js";
|
|
18
19
|
export * from "./product-template-materials.js";
|
package/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export * from "./manifest-fetch.js";
|
|
|
15
15
|
export * from "./manifest-router.js";
|
|
16
16
|
export * from "./openapi-router.js";
|
|
17
17
|
export * from "./product-health-monitor.js";
|
|
18
|
+
export * from "./product-hub.js";
|
|
18
19
|
export * from "./product-reach.js";
|
|
19
20
|
export * from "./product-service.js";
|
|
20
21
|
export * from "./product-template-materials.js";
|
package/package.json
CHANGED
package/product-hub.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Express } from "express";
|
|
2
|
+
import type { Manifest } from "./manifest-schema.js";
|
|
3
|
+
/** What a product wants said about itself above the links. One or two
|
|
4
|
+
* sentences: the hub is an orientation page, not a marketing page. */
|
|
5
|
+
export interface ProductHubOptions {
|
|
6
|
+
manifest: Manifest;
|
|
7
|
+
/** The manifest sidebar route this hub answers, e.g. "/playout". */
|
|
8
|
+
route: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function renderProductHub(opts: ProductHubOptions): string;
|
|
12
|
+
/**
|
|
13
|
+
* Mount the product's hub at the manifest sidebar route it declares.
|
|
14
|
+
*
|
|
15
|
+
* ctl renders every `ui.sidebarEntries` entry as a link under
|
|
16
|
+
* `/products/<name>/<route>`; before this, four products declared a branded
|
|
17
|
+
* entry whose backend served nothing, so the button 404'd (fleet review 04c).
|
|
18
|
+
* The page is built from the manifest the product already publishes, so it
|
|
19
|
+
* cannot drift from what the product actually offers. The trailing-slash form
|
|
20
|
+
* redirects to the bare route: relative links then resolve one way only.
|
|
21
|
+
*/
|
|
22
|
+
export declare function serveProductHub(app: Express, opts: ProductHubOptions): void;
|
package/product-hub.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
function esc(s) {
|
|
2
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
3
|
+
}
|
|
4
|
+
/** The hub is reached through the daemon's product proxy under
|
|
5
|
+
* /products/<name>/, and the product cannot see that prefix — the proxy
|
|
6
|
+
* forwards req.url unchanged. A root-absolute href would therefore escape the
|
|
7
|
+
* prefix and land on the runner's own SPA, so every link is relative to the
|
|
8
|
+
* product root, which is the hub's parent directory either way. */
|
|
9
|
+
function rel(path) {
|
|
10
|
+
return path.replace(/^\/+/, "");
|
|
11
|
+
}
|
|
12
|
+
function linkCard(label, href, note) {
|
|
13
|
+
const sub = note ? `<span class="note">${esc(note)}</span>` : "";
|
|
14
|
+
return `<a class="card" href="${esc(rel(href))}"><span class="label">${esc(label)}</span>${sub}</a>`;
|
|
15
|
+
}
|
|
16
|
+
export function renderProductHub(opts) {
|
|
17
|
+
const { manifest: m, route, description } = opts;
|
|
18
|
+
const ui = m.ui ?? { sidebarEntries: [], dashboardWidgets: [], productTemplateActions: [] };
|
|
19
|
+
const cards = [];
|
|
20
|
+
if (ui.configScreenUrl) {
|
|
21
|
+
cards.push(linkCard("Configure", ui.configScreenUrl, "Build a product template"));
|
|
22
|
+
}
|
|
23
|
+
for (const entry of ui.sidebarEntries ?? []) {
|
|
24
|
+
if (entry.route === route)
|
|
25
|
+
continue;
|
|
26
|
+
cards.push(linkCard(entry.label, entry.route));
|
|
27
|
+
}
|
|
28
|
+
const templates = m.defaultProductTemplates ?? [];
|
|
29
|
+
const templateList = templates.length > 0
|
|
30
|
+
? `<h2>Ready to launch</h2>
|
|
31
|
+
<p class="lead">Product templates this product registers with norsk-ctl. Launch one from the runner; there is nothing to configure first.</p>
|
|
32
|
+
<ul class="templates">${templates.map((t) => `<li>${esc(t.name)}</li>`).join("")}</ul>`
|
|
33
|
+
: "";
|
|
34
|
+
const api = [linkCard("OpenAPI", m.api.openapiFragmentPath, "This product's API fragment")];
|
|
35
|
+
if (m.api.productMcpPath)
|
|
36
|
+
api.push(linkCard("MCP", m.api.productMcpPath, "Always-on control-plane endpoint"));
|
|
37
|
+
api.push(linkCard("Health", m.api.healthCheckPath ?? "/healthz", "What the runner polls"));
|
|
38
|
+
return `<!doctype html>
|
|
39
|
+
<html lang="en">
|
|
40
|
+
<head>
|
|
41
|
+
<meta charset="utf-8" />
|
|
42
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
43
|
+
<title>${esc(m.name)}</title>
|
|
44
|
+
<style>
|
|
45
|
+
:root { color-scheme: light dark; --bg: #fbfbfa; --fg: #18181b; --muted: #71717a; --line: #e4e4e7; --card: #ffffff; }
|
|
46
|
+
@media (prefers-color-scheme: dark) {
|
|
47
|
+
:root { --bg: #18181b; --fg: #fafafa; --muted: #a1a1aa; --line: #3f3f46; --card: #27272a; }
|
|
48
|
+
}
|
|
49
|
+
* { box-sizing: border-box; }
|
|
50
|
+
body { margin: 0; padding: 2.5rem 1.5rem; background: var(--bg); color: var(--fg);
|
|
51
|
+
font: 14px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
52
|
+
main { max-width: 46rem; margin: 0 auto; }
|
|
53
|
+
h1 { font-size: 1.5rem; margin: 0 0 .25rem; letter-spacing: -0.01em; }
|
|
54
|
+
h2 { font-size: .8rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted);
|
|
55
|
+
margin: 2.25rem 0 .75rem; font-weight: 600; }
|
|
56
|
+
.version { font-size: .75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
57
|
+
.lead { color: var(--muted); margin: .5rem 0 0; max-width: 34rem; }
|
|
58
|
+
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); gap: .75rem; }
|
|
59
|
+
.card { display: flex; flex-direction: column; gap: .15rem; padding: .8rem .9rem; text-decoration: none;
|
|
60
|
+
background: var(--card); border: 1px solid var(--line); border-radius: 8px; color: inherit; }
|
|
61
|
+
.card:hover { border-color: var(--muted); }
|
|
62
|
+
.label { font-weight: 600; }
|
|
63
|
+
.note { font-size: .75rem; color: var(--muted); }
|
|
64
|
+
ul.templates { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: .4rem; }
|
|
65
|
+
ul.templates li { border: 1px solid var(--line); border-radius: 999px; padding: .2rem .7rem;
|
|
66
|
+
font-size: .8rem; background: var(--card); font-variant-numeric: tabular-nums; }
|
|
67
|
+
footer { margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--line);
|
|
68
|
+
font-size: .75rem; color: var(--muted); }
|
|
69
|
+
</style>
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<main>
|
|
73
|
+
<h1>${esc(m.name)}</h1>
|
|
74
|
+
<div class="version">version ${esc(m.version)}</div>
|
|
75
|
+
${description ? `<p class="lead">${esc(description)}</p>` : ""}
|
|
76
|
+
|
|
77
|
+
<h2>Start here</h2>
|
|
78
|
+
<div class="cards">${cards.join("")}</div>
|
|
79
|
+
|
|
80
|
+
${templateList}
|
|
81
|
+
|
|
82
|
+
<h2>For integrators</h2>
|
|
83
|
+
<div class="cards">${api.join("")}</div>
|
|
84
|
+
|
|
85
|
+
<footer>Served by this product's own control plane. norsk-ctl links here from the product page.</footer>
|
|
86
|
+
</main>
|
|
87
|
+
</body>
|
|
88
|
+
</html>
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Mount the product's hub at the manifest sidebar route it declares.
|
|
93
|
+
*
|
|
94
|
+
* ctl renders every `ui.sidebarEntries` entry as a link under
|
|
95
|
+
* `/products/<name>/<route>`; before this, four products declared a branded
|
|
96
|
+
* entry whose backend served nothing, so the button 404'd (fleet review 04c).
|
|
97
|
+
* The page is built from the manifest the product already publishes, so it
|
|
98
|
+
* cannot drift from what the product actually offers. The trailing-slash form
|
|
99
|
+
* redirects to the bare route: relative links then resolve one way only.
|
|
100
|
+
*/
|
|
101
|
+
export function serveProductHub(app, opts) {
|
|
102
|
+
const route = `/${rel(opts.route)}`;
|
|
103
|
+
const html = renderProductHub(opts);
|
|
104
|
+
// Express's non-strict routing makes `/route` and `/route/` the same match,
|
|
105
|
+
// so the two cannot be separate handlers (the bare form would hit the
|
|
106
|
+
// redirect and loop). One handler, told apart by what the client asked for.
|
|
107
|
+
app.get(route, (req, res) => {
|
|
108
|
+
if (req.originalUrl.split("?")[0]?.endsWith("/")) {
|
|
109
|
+
res.redirect(301, route);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
res.type("html").send(html);
|
|
113
|
+
});
|
|
114
|
+
}
|