querysub 0.195.0 → 0.196.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.
package/.cursorrules ADDED
@@ -0,0 +1,96 @@
1
+ The code automatically updates on save, so you do not need to ever run commands to run the site.
2
+
3
+ Always directly set the state (this.state.x = 1), and never use setState.
4
+
5
+ Unless schema values use atomic or type they will always be returned as a value (as a proxy). Use "in" to check if a value is in a t.lookup.
6
+
7
+ NEVER EVER pass state to qreact.Component as a template parameter. It should ALSO be declared like so (inside the class):
8
+ state = t.state({
9
+ num: t.number,
10
+ nested: {
11
+ atomicObj: t.atomic<{ nestedAtomicField: number }>;
12
+ k: t.string
13
+ },
14
+ // All lookups are keyed by string
15
+ // (There is no t.array, arrays are not allowed, unless they are inside t.atomic)
16
+ lookup: t.lookup({
17
+ userName: t.string
18
+ })
19
+ });
20
+
21
+ t.lookup objects cannot be set with `this.state.someLookup = {}`. You can add keys or remove keys, and keys are added implicitly (`this.state.someLookup["someKey"] = { x: 1, y: 1 }`, always works, the key is automatically added if it doesn't exist). Removal uses delete `delete this.state.someLookup["someKey"]`.
22
+
23
+
24
+ Try not to use "null", and instead always use "undefined".
25
+
26
+ Never use the ternary operator. Instead, do this: "x ? y : z" => "x && y || z".
27
+
28
+ If are inside an async Event Handlers, you need to use... Querysub.onCommitFinished(() => ...). and put the async code inside the callback. Then when you set state, you need to put the state setting code inside of Querysub.commit(() => ...).
29
+
30
+ The site automatically builds and hotreloads. Just save the files. DO NOT try to run any build scripts, because it automatically builds on save. DO NOT RUN "npm run build". DO NOT TRY TO RUN UNIT TESTS. DO NOT TRY TO RUN TESTS.
31
+
32
+ If you want to make a NON-Button feel like a button, you can use "css.button", which makes the background color change on hover, and make the cursor a pointer. Only use this if the background color is set, otherwise you need to message it's a button in another way. And never use it for <Button> which already does this.
33
+
34
+ General use hbox/vbox to set the spacing between elements, instead of using margins.
35
+
36
+ When checking for if a value lies within a range, compare like so: `start <= pos && pos < end`. Specifically, making the pos in the middle.
37
+
38
+ Never use non-null assertion operator, instead check the value, and if necessary (because it is accessed in nested functions), use a const variable to preserve the type.
39
+
40
+ Use <InputLabel /> (from "querysub/src/library-components/InputLabel"), instead of <input />. Use <InputLabel textarea /> instead of <textarea />. The input props of this allow you to set a label with "label", to act as a number with "number", as an integer with "integer", as a checkbox with "checkbox", and allow clicking to edit with "edit". There is a callback called "onChangeValue" which returns new values (as opposed to the entire event), which is useful.
41
+
42
+ Never use em or rem. Only use px or vw/vh/%.
43
+
44
+ Errors should almost always use a template string to include the expected value, and the actual value, as in: "throw new Error(`Expected X, was ${Y}`);"
45
+
46
+ We use a dark theme.
47
+
48
+ Don't use switch statements. Use if statements instead.
49
+
50
+ Don't use ! when accessing a value from a map. Use the get / if undefined initialize and set, and then use style. It's faster, and more type safe.
51
+
52
+ Sort with this function, which takes a single function to map each object to a sortable value
53
+ import { sort } from "socket-function/src/misc";
54
+ export function sort<T>(arr: T[], sortKey: (obj: T) => unknown);
55
+
56
+ css should be set using className={css.cssPropertyName(cssPropertyValue).anotherPropertyName...}
57
+ There are also some special aliases, some of which take parameters, some of which don't (which allows them to be chained like so: css.vbox0.wrap):
58
+ let nonCallAliases = {
59
+ relative: (c: CSSHelperTypeBase) => c.position("relative"),
60
+ absolute: (c: CSSHelperTypeBase) => c.position("absolute"),
61
+ fixed: (c: CSSHelperTypeBase) => c.position("fixed"),
62
+ wrap: (c: CSSHelperTypeBase) => c.flexWrap("wrap").display("flex", "soft").alignItems("center", "soft"),
63
+ marginAuto: (c: CSSHelperTypeBase) => c.margin("auto"),
64
+ fillBoth: (c: CSSHelperTypeBase) => c.width("100%").height("100%"),
65
+ fillWidth: (c: CSSHelperTypeBase) => c.width("100%"),
66
+ fillHeight: (c: CSSHelperTypeBase) => c.height("100%"),
67
+ flexShrink0: (c: CSSHelperTypeBase) => c.flexShrink(0),
68
+ ellipsis: (c: CSSHelperTypeBase) => c.overflow("hidden").textOverflow("ellipsis").whiteSpace("nowrap").display("inline-block"),
69
+ overflowAuto: (c: CSSHelperTypeBase) => c.overflow("auto"),
70
+ overflowHidden: (c: CSSHelperTypeBase) => c.overflow("hidden"),
71
+ };
72
+ let callAliases = {
73
+ hbox: (c: CSSHelperTypeBase, gap: number, rowGap?: number) => c.display("flex").flexDirection("row").rowGap(rowGap ?? gap).columnGap(gap).alignItems("center", "soft"),
74
+ vbox: (c: CSSHelperTypeBase, gap: number, columnGap?: number) => c.display("flex").flexDirection("column").rowGap(gap).columnGap(columnGap ?? gap).alignItems("start", "soft"),
75
+ pad2: (c: CSSHelperTypeBase, value: number, verticalValue?: number): CSSHelperTypeBase => {
76
+ if (verticalValue !== undefined) return c.padding(`${verticalValue}px ${value}px` as any);
77
+ return c.padding(value);
78
+ },
79
+ hsl: (c: CSSHelperTypeBase, h: number, s: number, l: number): CSSHelperTypeBase => c.background(`hsl(${h}, ${s}%, ${l}%)`),
80
+ hslhover: (c: CSSHelperTypeBase, h: number, s: number, l: number): CSSHelperTypeBase => c.background(`hsl(${h}, ${s}%, ${l}%)`, "hover"),
81
+ hsla: (c: CSSHelperTypeBase, h: number, s: number, l: number, a: number): CSSHelperTypeBase => c.background(`hsla(${h}, ${s}%, ${l}%, ${a})`),
82
+ hslahover: (c: CSSHelperTypeBase, h: number, s: number, l: number, a: number): CSSHelperTypeBase => c.background(`hsla(${h}, ${s}%, ${l}%, ${a})`, "hover"),
83
+ bord: (c: CSSHelperTypeBase, width: number, color: string | { h: number; s: number; l: number; a?: number; }, style = "solid"): CSSHelperTypeBase => {
84
+ let colorStr = typeof color === "string" ? color : `hsla(${color.h}, ${color.s}%, ${color.l}%, ${color.a ?? 1})`;
85
+ return c.border(`${width}px ${style} ${colorStr}`);
86
+ },
87
+ bord2: (c: CSSHelperTypeBase, h: number, s: number, l: number, width = 1, style = "solid"): CSSHelperTypeBase => {
88
+ return c.border(`${width}px ${style} hsla(${h}, ${s}%, ${l}%, 1)`);
89
+ },
90
+ hslcolor: (c: CSSHelperTypeBase, h: number, s: number, l: number): CSSHelperTypeBase => c.color(`hsl(${h}, ${s}%, ${l}%)`),
91
+ colorhsl: (c: CSSHelperTypeBase, h: number, s: number, l: number): CSSHelperTypeBase => c.color(`hsl(${h}, ${s}%, ${l}%)`),
92
+ hslacolor: (c: CSSHelperTypeBase, h: number, s: number, l: number, a: number): CSSHelperTypeBase => c.color(`hsla(${h}, ${s}%, ${l}%, ${a})`),
93
+ colorhsla: (c: CSSHelperTypeBase, h: number, s: number, l: number, a: number): CSSHelperTypeBase => c.color(`hsla(${h}, ${s}%, ${l}%, ${a})`),
94
+ size: (c: CSSHelperTypeBase, width: LengthOrPercentage, height: LengthOrPercentage) => c.width(width).height(height),
95
+ pos: (c: CSSHelperTypeBase, x: LengthOrPercentage, y: LengthOrPercentage) => c.left(x).top(y),
96
+ };
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+
3
+ require("typenode");
4
+ require("../src/deployManager/applyAlwaysUpMain");
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+
3
+ require("typenode");
4
+ require("../src/deployManager/setupMachineMain");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "querysub",
3
- "version": "0.195.0",
3
+ "version": "0.196.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -48,7 +48,9 @@
48
48
  "server-public": "./bin/server-public.js",
49
49
  "function": "./bin/function.js",
50
50
  "function-public": "./bin/function-public.js",
51
- "machine": "./bin/machine.js",
51
+ "machine": "./bin/machine-once.js",
52
+ "machine-alwaysup": "./bin/machine-alwaysup.js",
53
+ "setup-machine": "./bin/setup-machine.js",
52
54
  "gc": "./bin/gc.js",
53
55
  "gc-watch": "./bin/gc-watch.js",
54
56
  "gc-watch-public": "./bin/gc-watch-public.js",
@@ -361,8 +361,8 @@ async function runMemoryAuditLoop() {
361
361
  async function writeHeartbeat() {
362
362
  if (shutdown) return;
363
363
  let now = Date.now();
364
- console.log(green(`Writing heartbeat ${formatDateTime(now)}`));
365
364
  let nodeId = getMountNodeId();
365
+ console.log(green(`Writing heartbeat ${formatDateTime(now)} for self (${nodeId})`));
366
366
  if (!nodeId) return;
367
367
  await archives().set(nodeId, Buffer.from(now + ""));
368
368
  }
@@ -1,26 +1,14 @@
1
1
  import * as child_process from "child_process";
2
2
  import { cache } from "socket-function/src/caching";
3
+ import { runPromise } from "../functional/runCommand";
4
+ import { measureWrap } from "socket-function/src/profiling/measure";
5
+ import fs from "fs";
6
+ import os from "os";
3
7
  export async function getGitURLLive(gitDir = ".") {
4
- return new Promise<string>((resolve, reject) => {
5
- child_process.exec(`git remote get-url origin`, { cwd: gitDir }, (error, stdout, stderr) => {
6
- if (error) {
7
- reject(error);
8
- } else {
9
- resolve(stdout.toString().trim());
10
- }
11
- });
12
- });
8
+ return (await runPromise(`git remote get-url origin`, { cwd: gitDir })).trim();
13
9
  }
14
10
  export async function getGitRefLive(gitDir = ".") {
15
- return new Promise<string>((resolve, reject) => {
16
- child_process.exec(`git rev-parse HEAD`, { cwd: gitDir }, (error, stdout, stderr) => {
17
- if (error) {
18
- reject(error);
19
- } else {
20
- resolve(stdout.toString().trim());
21
- }
22
- });
23
- });
11
+ return (await runPromise(`git rev-parse HEAD`, { cwd: gitDir })).trim();
24
12
  }
25
13
 
26
14
  export const getGitURLSync = cache(function getGitURLSync(gitDir?: string) {
@@ -28,4 +16,25 @@ export const getGitURLSync = cache(function getGitURLSync(gitDir?: string) {
28
16
  });
29
17
  export const getGitRefSync = cache(function getGitRefSync(gitDir?: string) {
30
18
  return child_process.execSync(`git rev-parse HEAD`, { cwd: gitDir || "." }).toString().trim();
19
+ });
20
+
21
+ export const setGitRef = measureWrap(async function setGitRef(config: {
22
+ gitFolder: string;
23
+ repoUrl: string;
24
+ gitRef: string;
25
+ }) {
26
+ let hostKey = await runPromise(`ssh-keyscan -t rsa bitbucket.org`);
27
+ hostKey = hostKey.split("\n").filter(x => !x.startsWith("#")).join("\n");
28
+ let knownHostsPath = os.homedir() + "/.ssh/known_hosts";
29
+ if (!fs.existsSync(knownHostsPath) || !fs.readFileSync(knownHostsPath).toString().includes(hostKey)) {
30
+ fs.appendFileSync(knownHostsPath, "\n" + hostKey + "\n");
31
+ }
32
+
33
+ await runPromise(`git remote update`, { cwd: config.gitFolder });
34
+ await runPromise(`git add --all`, { cwd: config.gitFolder });
35
+ await runPromise(`git stash`, { cwd: config.gitFolder });
36
+ await runPromise(`git fetch --all`, { cwd: config.gitFolder });
37
+ await runPromise(`git reset --hard ${config.gitRef}`, { cwd: config.gitFolder });
38
+ // Allows us to remove deleted objects from storage, ex, if we accidentally commit a 1GB, this deletes it, so we don't have to fix each server individually. Also I think the repo breaks if we go too long without pruning it?
39
+ await runPromise(`git prune`, { cwd: config.gitFolder });
31
40
  });
@@ -1249,13 +1249,6 @@ setImmediate(() => {
1249
1249
  });
1250
1250
  });
1251
1251
 
1252
- if (isNode()) {
1253
- setImmediate(async () => {
1254
- const { hookErrors } = await import("../diagnostics/errorLogs/hookErrors");
1255
- hookErrors();
1256
- });
1257
- }
1258
-
1259
1252
  setImmediate(async () => {
1260
1253
  // Import, so it registers addStatPeriodic
1261
1254
  await import("../5-diagnostics/nodeMetadata");
@@ -0,0 +1,53 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { qreact } from "../4-dom/qreact";
3
+ import { DeployController } from "./machineSchema";
4
+ import { css } from "typesafecss";
5
+ import { currentViewParam, selectedServiceIdParam, selectedMachineIdParam } from "./urlParams";
6
+ import { ServicesListPage } from "./components/ServicesListPage";
7
+ import { MachinesListPage } from "./components/MachinesListPage";
8
+ import { ServiceDetailPage } from "./components/ServiceDetailPage";
9
+ import { MachineDetailPage } from "./components/MachineDetailPage";
10
+
11
+ export class MachinesPage extends qreact.Component {
12
+ private renderTabs() {
13
+ return <div className={
14
+ css.hbox(4)
15
+ //.bord2(0, 0, 20)
16
+ }>
17
+ {[
18
+ { key: "services", label: "Services", otherKeys: ["service-detail"] },
19
+ { key: "machines", label: "Machines", otherKeys: ["machine-detail"] }
20
+ ].map(tab => {
21
+ let isActive = currentViewParam.value === tab.key || tab.otherKeys.includes(currentViewParam.value);
22
+ return <div key={tab.key}
23
+ className={css.pad2(12, 8).button
24
+ + (isActive && css.hsl(0, 0, 100).bord2(210, 50, 50))
25
+ + (!isActive && css.hsl(0, 0, 70).colorhsl(0, 0, 50).bord2(0, 0, 50))
26
+ }
27
+ onClick={() => {
28
+ currentViewParam.value = tab.key as any;
29
+ selectedServiceIdParam.value = "";
30
+ selectedMachineIdParam.value = "";
31
+ }}>
32
+ {tab.label}
33
+ </div>;
34
+ })}
35
+ </div>;
36
+ }
37
+
38
+ render() {
39
+ let controller = DeployController(SocketFunction.browserNodeId());
40
+ let serviceConfigType = controller.getServiceConfigType();
41
+ if (!serviceConfigType) return <div>Loading...</div>;
42
+
43
+ return <div className={css.vbox(0).fillBoth.pad2(20)}>
44
+ {this.renderTabs()}
45
+ <div className={css.flexGrow(1).overflowAuto.pad2(20, 10)}>
46
+ {currentViewParam.value === "services" && <ServicesListPage />}
47
+ {currentViewParam.value === "machines" && <MachinesListPage />}
48
+ {currentViewParam.value === "service-detail" && <ServiceDetailPage />}
49
+ {currentViewParam.value === "machine-detail" && <MachineDetailPage />}
50
+ </div>
51
+ </div>;
52
+ }
53
+ }
@@ -0,0 +1,75 @@
1
+ const child_process = require("child_process");
2
+ const fs = require("fs");
3
+
4
+ export function shellEscape(array) {
5
+ return array
6
+ .map((value) => {
7
+ if (/[^A-Za-z0-9_\/:=-]/.test(value)) {
8
+ value = "'" + value.replace(/'/g, "'\\''") + "'";
9
+ value = value
10
+ .replace(/^(?:'')+/g, "") // unduplicate single-quote at the beginning
11
+ .replace(/\\'''/g, "\\'"); // remove non-escaped single-quote if there are enclosed between 2 escaped
12
+ }
13
+ return value;
14
+ })
15
+ .join(" ");
16
+ }
17
+
18
+ function delay(ms) {
19
+ return new Promise((resolve) => setTimeout(resolve, ms));
20
+ }
21
+
22
+ async function runCommandWithLogging(command) {
23
+ return new Promise((resolve, reject) => {
24
+ const childProc = child_process.spawn(command, {
25
+ shell: true,
26
+ stdio: ["inherit", "pipe", "pipe"], // stdin: inherit, stdout: pipe, stderr: pipe
27
+ });
28
+
29
+ let combinedOutput = "";
30
+ function limitOutputSize(output) {
31
+ if (output.length > 100 * 1000) {
32
+ output = output.slice(-50 * 1000);
33
+ }
34
+ }
35
+
36
+ // Capture and log stdout
37
+ childProc.stdout?.on("data", (data) => {
38
+ const chunk = data.toString();
39
+ combinedOutput += chunk;
40
+ limitOutputSize(chunk);
41
+ process.stdout.write(chunk);
42
+ });
43
+
44
+ // Capture and log stderr
45
+ childProc.stderr?.on("data", (data) => {
46
+ const chunk = data.toString();
47
+ combinedOutput += chunk;
48
+ limitOutputSize(chunk);
49
+ process.stderr.write(chunk);
50
+ });
51
+
52
+ childProc.on("error", (err) => {
53
+ reject(combinedOutput.slice(-1000));
54
+ });
55
+
56
+ childProc.on("close", (code) => {
57
+ reject(`${combinedOutput.slice(-1000)}\nProcess exited with code ${code}, recent output:`);
58
+ });
59
+ });
60
+ }
61
+
62
+ async function main() {
63
+ let args = process.argv.slice(2);
64
+ while (true) {
65
+ try {
66
+ await runCommandWithLogging(shellEscape(args));
67
+ } catch (e) {
68
+ console.error(`Process finished, restarting in 5 seconds`);
69
+ fs.writeFileSync("lastAlwaysUpError.txt", e.stack);
70
+ }
71
+ await delay(5000);
72
+ }
73
+ }
74
+
75
+ main().catch(console.error);
@@ -0,0 +1,159 @@
1
+ import { SocketFunction } from "socket-function/SocketFunction";
2
+ import { qreact } from "../../4-dom/qreact";
3
+ import { MACHINE_RESYNC_INTERVAL, DeployController, ServiceConfig } from "../machineSchema";
4
+ import { css } from "typesafecss";
5
+ import { currentViewParam, selectedMachineIdParam, selectedServiceIdParam } from "../urlParams";
6
+ import { formatVeryNiceDateTime } from "socket-function/src/formatting/format";
7
+ import { sort } from "socket-function/src/misc";
8
+
9
+ export class MachineDetailPage extends qreact.Component {
10
+ render() {
11
+ const selectedMachineId = selectedMachineIdParam.value;
12
+ if (!selectedMachineId) return <div>No machine selected</div>;
13
+
14
+ let controller = DeployController(SocketFunction.browserNodeId());
15
+ let machineInfo = controller.getMachineInfo(selectedMachineId);
16
+ let serviceList = controller.getServiceList();
17
+
18
+ if (controller.isAnyLoading()) return <div>Loading machine info...</div>;
19
+ if (!machineInfo) return <div>Machine not found</div>;
20
+ if (!serviceList) return <div>Service list not found</div>;
21
+
22
+ const machine = machineInfo; // Create const reference for type safety
23
+
24
+ // Get all service configs that target this machine
25
+ let relevantServiceConfigs = new Map<string, ServiceConfig>();
26
+ for (let serviceId of serviceList) {
27
+ let serviceConfig = controller.getServiceConfig(serviceId);
28
+ if (serviceConfig && serviceConfig.machineIds.includes(selectedMachineId)) {
29
+ relevantServiceConfigs.set(serviceId, serviceConfig);
30
+ }
31
+ }
32
+
33
+ // Find configured services that aren't running
34
+ let configuredButNotRunning = Array.from(relevantServiceConfigs.entries()).filter(([serviceId]) =>
35
+ !(serviceId in machine.services)
36
+ );
37
+ sort(configuredButNotRunning, x => x[0]); // Sort by service ID
38
+
39
+ // Sort running services
40
+ let runningServices = Object.entries(machine.services);
41
+ sort(runningServices, x => -x[1].lastLaunchedTime); // Sort by last launched time, most recent first
42
+
43
+ const isMachineDead = Date.now() - machine.heartbeat > (MACHINE_RESYNC_INTERVAL * 4);
44
+
45
+ return <div className={css.vbox(16)}>
46
+ <div className={css.hbox(12)}>
47
+ <h2 className={css.flexGrow(1)}>{selectedMachineId}</h2>
48
+ {isMachineDead && <div className={css.colorhsl(0, 80, 60)}>
49
+ ⚠️ Machine is likely dead
50
+ </div>}
51
+ </div>
52
+
53
+ <div className={css.vbox(12)}>
54
+ <div>
55
+ <h3>Machine Info</h3>
56
+ <div>
57
+ Last heartbeat: {formatVeryNiceDateTime(machine.heartbeat)}
58
+ </div>
59
+ <div>
60
+ Apply Node ID: {machine.applyNodeId}
61
+ </div>
62
+ </div>
63
+
64
+ <div>
65
+ <h3>System Information</h3>
66
+ <div className={css.vbox(4)}>
67
+ {Object.entries(machine.info).map(([key, value]) => (
68
+ <div key={key}>
69
+ <span>{key}:</span> {value}
70
+ </div>
71
+ ))}
72
+ </div>
73
+ </div>
74
+
75
+ {configuredButNotRunning.length > 0 && (
76
+ <div>
77
+ <h3>⚠️ Configured Services Not Running ({configuredButNotRunning.length})</h3>
78
+ <div className={css.vbox(8)}>
79
+ {configuredButNotRunning.map(([serviceId, serviceConfig]) => (
80
+ <div key={serviceId}
81
+ className={
82
+ css.pad2(12).button.bord2(0, 0, 20).hsl(0, 70, 90)
83
+ }
84
+ onClick={() => {
85
+ currentViewParam.value = "service-detail";
86
+ selectedServiceIdParam.value = serviceId;
87
+ }}>
88
+ <div className={css.hbox(12)}>
89
+ <div className={css.flexGrow(1)}>
90
+ {serviceConfig.info.title} ({serviceConfig.parameters.key} / {serviceId})
91
+ </div>
92
+ <div className={css.colorhsl(0, 80, 60)}>
93
+ ⚠️ Not Running
94
+ </div>
95
+ <div>
96
+ Deploy: {serviceConfig.parameters.deploy ? "Yes" : "No"}
97
+ </div>
98
+ </div>
99
+ </div>
100
+ ))}
101
+ </div>
102
+ </div>
103
+ )}
104
+
105
+ <div>
106
+ <h3>Services ({Object.keys(machine.services).length} running)</h3>
107
+ <div className={css.vbox(8)}>
108
+ {runningServices.map(([serviceId, serviceInfo]) => {
109
+ let serviceConfig = relevantServiceConfigs.get(serviceId);
110
+ let isConfigured = relevantServiceConfigs.has(serviceId);
111
+ let hasError = serviceInfo.errorFromLastRun;
112
+
113
+ let backgroundColor = css.hsl(0, 0, 100); // Default: white
114
+ if (!isConfigured) {
115
+ backgroundColor = css.hsl(30, 70, 90); // Orange-ish for unconfigured
116
+ } else if (hasError) {
117
+ backgroundColor = css.hsl(0, 70, 90); // Red-ish for errors
118
+ }
119
+
120
+ return (
121
+ <div key={serviceId}
122
+ className={
123
+ css.pad2(12).button.bord2(0, 0, 20)
124
+ + backgroundColor
125
+ }
126
+ onClick={() => {
127
+ currentViewParam.value = "service-detail";
128
+ selectedServiceIdParam.value = serviceId;
129
+ }}>
130
+ <div className={css.hbox(12)}>
131
+ <div className={css.flexGrow(1)}>
132
+ {serviceConfig && <>{serviceConfig.info.title} ({serviceConfig.parameters.key})</> || <>serviceId = {serviceId}</>}
133
+ </div>
134
+ {!isConfigured && (
135
+ <div className={css.colorhsl(30, 80, 50)}>
136
+ ⚠️ Orphaned screen with no service config
137
+ </div>
138
+ )}
139
+ <div>
140
+ Last deployed: {formatVeryNiceDateTime(serviceInfo.lastLaunchedTime)}
141
+ </div>
142
+ <div>
143
+ Launches: {serviceInfo.totalTimesLaunched}
144
+ </div>
145
+ {hasError && (
146
+ <div className={css.colorhsl(0, 80, 50)}>
147
+ ⚠️ Has Error
148
+ </div>
149
+ )}
150
+ </div>
151
+ </div>
152
+ );
153
+ })}
154
+ </div>
155
+ </div>
156
+ </div>
157
+ </div>;
158
+ }
159
+ }