@typed-assistant/builder 0.0.21 → 0.0.23

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/.eslintrc.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /** @type {import("eslint").Linter.Config} */
2
2
  module.exports = {
3
3
  root: true,
4
- extends: ["@typed-assistant/eslint-config/react-internal.js"],
5
- plugins: ['html'],
4
+ extends: ["@typed-assistant/eslint-config"],
5
+ plugins: ["html"],
6
6
  parserOptions: {},
7
7
  }
package/package.json CHANGED
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "name": "@typed-assistant/builder",
3
- "version": "0.0.21",
3
+ "version": "0.0.23",
4
4
  "exports": {
5
5
  "./appProcess": "./src/appProcess.tsx",
6
6
  "./bunInstall": "./src/bunInstall.tsx",
7
- "./getSpawnText": "./src/getSpawnText.tsx",
8
7
  "./pullChanges": "./src/pullChanges.tsx"
9
8
  },
10
9
  "dependencies": {
@@ -14,7 +13,8 @@
14
13
  "@mdi/svg": "^7.3.67",
15
14
  "ignore": "^5.3.0",
16
15
  "react": "^18",
17
- "react-dom": "^18"
16
+ "react-dom": "^18",
17
+ "zod": "^3.22.4"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@types/node": "^20.10.6",
@@ -24,10 +24,10 @@
24
24
  "home-assistant-js-websocket": "^8.2.0",
25
25
  "ts-toolbelt": "^9.6.0",
26
26
  "typescript": "^5.3.3",
27
- "@typed-assistant/eslint-config": "0.0.4",
28
- "@typed-assistant/logger": "0.0.7",
29
- "@typed-assistant/utils": "0.0.7",
30
- "@typed-assistant/typescript-config": "0.0.4"
27
+ "@typed-assistant/eslint-config": "0.0.5",
28
+ "@typed-assistant/typescript-config": "0.0.5",
29
+ "@typed-assistant/utils": "0.0.8",
30
+ "@typed-assistant/logger": "0.0.9"
31
31
  },
32
32
  "peerDependencies": {
33
33
  "home-assistant-js-websocket": "^8.2.0"
@@ -37,6 +37,6 @@
37
37
  "registry": "https://registry.npmjs.org/"
38
38
  },
39
39
  "scripts": {
40
- "lint": "eslint . --max-warnings 0"
40
+ "lint": "tsc --noEmit && eslint ."
41
41
  }
42
42
  }
@@ -1,26 +1,33 @@
1
- import { generateTypes } from "@typed-assistant/types/generateTypes"
2
1
  import { log } from "@typed-assistant/logger"
2
+ import { generateTypes } from "@typed-assistant/types/generateTypes"
3
+ import { getHassAPI, getSupervisorAPI } from "@typed-assistant/utils/getHassAPI"
4
+ import { withErrorHandling } from "@typed-assistant/utils/withErrorHandling"
3
5
  import type { Subprocess } from "bun"
6
+ import { $ } from "bun"
4
7
  import { readFileSync, watch } from "fs"
5
- import { join, relative } from "path"
6
8
  import ignore from "ignore"
7
- import { ONE_SECOND } from "@typed-assistant/utils/durations"
8
- import { getHassAPI, getSupervisorAPI } from "@typed-assistant/utils/getHassAPI"
9
- import { pullChanges } from "./pullChanges"
10
- import { withErrorHandling } from "@typed-assistant/utils/withErrorHandling"
9
+ import { join, relative } from "path"
10
+ import {
11
+ addKillListener,
12
+ callKillListeners,
13
+ callSoftKillListeners,
14
+ killSubprocess,
15
+ } from "./killProcess"
16
+ import { setupGitPoller } from "./setupGitPoller"
17
+ import { setupWebhook } from "./setupWebhook"
11
18
  import { startWebappServer } from "./setupWebserver"
12
19
 
13
20
  type Processes = Awaited<ReturnType<typeof buildAndStartAppProcess>>
14
21
 
15
22
  async function buildAndStartAppProcess(
16
- appSourceFile?: string,
17
- options?: Parameters<typeof generateTypes>[0],
23
+ appSourceFile: string,
24
+ options: Parameters<typeof generateTypes>[0],
18
25
  ) {
19
26
  await generateTypes({ mdiPaths: options?.mdiPaths })
20
27
  return { app: await startApp(appSourceFile) }
21
28
  }
22
29
 
23
- async function startApp(appSourceFile: string = "src/entry.tsx") {
30
+ async function startApp(appSourceFile: string) {
24
31
  log("🚀 Starting app...")
25
32
  const path = join(process.cwd(), appSourceFile)
26
33
  return Bun.spawn(["bun", path], {
@@ -29,79 +36,109 @@ async function startApp(appSourceFile: string = "src/entry.tsx") {
29
36
  })
30
37
  }
31
38
 
32
- async function kill(process: Subprocess) {
33
- log(`💀 Killing process: ${process.pid}`)
34
- process.kill()
35
- await process.exited
36
- }
37
-
38
39
  let settingUp = { current: false }
39
- async function killAndRestartApp(subprocesses: Processes) {
40
+ async function killAndRestartApp(
41
+ entryFile: string,
42
+ options: Parameters<typeof buildAndStartAppProcess>[1],
43
+ subprocesses: Processes,
44
+ ) {
40
45
  if (settingUp.current) return subprocesses
41
46
  log("♻️ Restarting app...")
42
47
  settingUp.current = true
43
- if (subprocesses.app) await kill(subprocesses.app)
44
- const newSubprocesses = await buildAndStartAppProcess()
48
+ if (subprocesses.app) await killSubprocess(subprocesses.app)
49
+ const newSubprocesses = await buildAndStartAppProcess(entryFile, options)
45
50
  settingUp.current = false
46
51
  return newSubprocesses
47
52
  }
48
53
 
49
- export async function setupWatcher(
50
- ...args: Parameters<typeof buildAndStartAppProcess>
51
- ) {
52
- const { data: addonInfo, error: addonInfoError } = await getAddonInfo()
53
- if (addonInfoError) {
54
- log(`🚨 Failed to get addon info: ${addonInfoError}`)
54
+ let multipleProcessesErrorCount = 0
55
+ let noProcessesErrorCount = 0
56
+ const checkProcesses = async (
57
+ entryFile: string,
58
+ { onProcessError }: { onProcessError?: (message: string) => void },
59
+ ) => {
60
+ const ps = await $`ps -f`.text()
61
+ const matches = ps.match(new RegExp(`bun .+${entryFile}`, "gmi")) ?? []
62
+
63
+ if (matches.length > 1) {
64
+ multipleProcessesErrorCount++
65
+ if (multipleProcessesErrorCount > 5) {
66
+ const message = `🚨 Multiple processes detected. Restarting TypedAssistant addon...`
67
+ log(message)
68
+ onProcessError?.(message)
69
+ restartAddon()
70
+ }
71
+ } else {
72
+ multipleProcessesErrorCount = 0
55
73
  }
56
- await setupGitSync()
57
74
 
58
- let subprocesses = await buildAndStartAppProcess(...args)
75
+ if (matches.length === 0) {
76
+ noProcessesErrorCount++
77
+ if (noProcessesErrorCount > 5) {
78
+ const message = `🚨 No processes detected. Restarting TypedAssistant addon...`
79
+ log(message)
80
+ onProcessError?.(message)
81
+ restartAddon()
82
+ }
83
+ } else {
84
+ noProcessesErrorCount = 0
85
+ }
59
86
 
60
- const directory = join(process.cwd(), "./src")
61
- log("👀 Watching directory:", directory)
62
- const watcher = watch(
63
- directory,
64
- { recursive: true },
65
- async function onFileChange(event, filename) {
66
- console.log("😅😅😅 ~ filename:", filename)
67
- if (!filename) return
68
- if (shouldIgnoreFileOrFolder(filename)) return
69
- log(`⚠️ Change to ${filename} detected.`)
70
- if (filename.endsWith("process.tsx")) {
71
- await restartAddon()
72
- } else {
73
- subprocesses = await killAndRestartApp(subprocesses)
74
- }
75
- },
76
- )
87
+ setTimeout(() => checkProcesses(entryFile, { onProcessError }), 5000)
88
+ }
89
+
90
+ export async function setup({
91
+ entryFile,
92
+ mdiPaths,
93
+ onProcessError,
94
+ }: {
95
+ entryFile: string
96
+ } & Parameters<typeof generateTypes>[0] &
97
+ Parameters<typeof checkProcesses>[1]) {
98
+ const addonInfo = await getAddonInfo()
99
+ const basePath = addonInfo?.data.ingress_entry ?? ""
100
+ const directoryToWatch = join(process.cwd(), "./src")
101
+
102
+ checkProcesses(entryFile, { onProcessError })
103
+ await setupGitSync()
77
104
 
105
+ let subprocesses = await buildAndStartAppProcess(entryFile, {
106
+ mdiPaths: mdiPaths,
107
+ })
78
108
  startWebappServer({
79
- basePath: addonInfo?.data.ingress_entry ?? "",
109
+ basePath,
80
110
  getSubprocesses: () => subprocesses,
81
111
  })
82
-
83
- process.on("SIGINT", () => {
84
- console.log("👋 Closing watcher...")
85
- watcher.close()
112
+ setupWatcher({
113
+ directoryToWatch,
114
+ entryFile,
115
+ mdiPaths,
116
+ onSubprocessChange: (newSubprocesses) => {
117
+ subprocesses = newSubprocesses
118
+ },
119
+ getSubprocesses: () => subprocesses,
86
120
  })
87
121
 
88
122
  return subprocesses
89
123
  }
90
124
 
91
- const setupGitSync = async ({
92
- gitPullPollDuration,
93
- }: {
94
- /** Duration in seconds */
95
- gitPullPollDuration?: number
96
- } = {}) => {
97
- const duration = gitPullPollDuration ?? 30
98
- const { error } = await pullChanges()
99
- if (error) return
100
- log(` ⏳ Pulling changes again in ${duration} seconds...`)
101
-
102
- setTimeout(() => {
103
- setupGitSync({ gitPullPollDuration })
104
- }, duration * ONE_SECOND)
125
+ const setupGitSync = async () => {
126
+ if (
127
+ !process.env.GITHUB_TOKEN ||
128
+ !process.env.GITHUB_USERNAME ||
129
+ !process.env.GITHUB_REPO
130
+ ) {
131
+ log(
132
+ "⚠️ Cannot sync with Github without Github token, username, and repo details. Add these in the add-on configuration.",
133
+ )
134
+ return { error: {} }
135
+ }
136
+ if (process.env.HASS_EXTERNAL_URL) {
137
+ await setupWebhook()
138
+ return
139
+ }
140
+ log("⚠️ No HASS_EXTERNAL_URL found. Setting up git poller...")
141
+ await setupGitPoller()
105
142
  }
106
143
 
107
144
  const ig = ignore().add(
@@ -123,14 +160,60 @@ const restartAddon = async () => {
123
160
  const getAddonInfo = async () => {
124
161
  log("🔍 Getting addon info...")
125
162
 
126
- return withErrorHandling(getSupervisorAPI)<{
163
+ const { data, error } = await withErrorHandling(getSupervisorAPI)<{
127
164
  data: { ingress_entry: string }
128
165
  }>("/addons/self/info")
166
+
167
+ if (error) log(`🚨 Failed to get addon info: ${error}`)
168
+
169
+ return data
129
170
  }
130
171
 
131
- process.on("SIGINT", () => {
132
- setTimeout(() => {
133
- console.log("👋 ...")
134
- process.exit(0)
135
- }, 0)
172
+ function setupWatcher({
173
+ directoryToWatch,
174
+ entryFile,
175
+ mdiPaths,
176
+ onSubprocessChange,
177
+ getSubprocesses,
178
+ }: {
179
+ directoryToWatch: string
180
+ onSubprocessChange: (newSubprosses: {
181
+ app: Subprocess<"ignore", "pipe", "pipe">
182
+ }) => void
183
+ entryFile: string
184
+ mdiPaths: string[] | undefined
185
+ getSubprocesses: () => {
186
+ app: Subprocess<"ignore", "pipe", "pipe">
187
+ }
188
+ }) {
189
+ log("👀 Watching directory:", directoryToWatch)
190
+ const watcher = watch(
191
+ directoryToWatch,
192
+ { recursive: true },
193
+ async function onFileChange(event, filename) {
194
+ if (!filename) return
195
+ if (shouldIgnoreFileOrFolder(filename)) return
196
+ log(`⚠️ Change to ${filename} detected.`)
197
+ if (filename.endsWith("process.tsx")) {
198
+ await restartAddon()
199
+ } else {
200
+ onSubprocessChange(
201
+ await killAndRestartApp(entryFile, { mdiPaths }, getSubprocesses()),
202
+ )
203
+ }
204
+ },
205
+ )
206
+
207
+ addKillListener(() => {
208
+ if (watcher) watcher.close()
209
+ })
210
+
211
+ return watcher
212
+ }
213
+
214
+ process.on("SIGINT", async () => {
215
+ log("👋 Exiting...")
216
+ await callSoftKillListeners()
217
+ await callKillListeners()
218
+ process.exit(0)
136
219
  })
@@ -1,21 +1,11 @@
1
1
  import { log } from "@typed-assistant/logger"
2
+ import { $ } from "bun"
2
3
 
3
4
  export async function bunInstall() {
4
5
  log("🏗️ Running bun install...")
5
- const proc = Bun.spawn([
6
- "bun",
7
- "install",
8
- "--frozen-lockfile",
9
- "--cache-dir=.bun-cache",
10
- ])
11
- await proc.exited
12
- const bunInstallText = await Bun.readableStreamToText(proc.stdout)
13
- if (proc.exitCode === 0) return { error: null }
14
- return {
15
- error: {
16
- signalCode: proc.signalCode,
17
- exitCode: proc.exitCode,
18
- text: bunInstallText,
19
- },
20
- }
6
+ return $`bun install --frozen-lockfile --cache-dir=.bun-cache`
7
+ .text()
8
+ .catch((error) => {
9
+ log(`🚨 Failed to run bun install: ${error}`)
10
+ })
21
11
  }
@@ -0,0 +1,32 @@
1
+ import { log } from "@typed-assistant/logger"
2
+ import type { Subprocess } from "bun"
3
+ import { $ } from "bun"
4
+
5
+ const killListeners: (() => void | Promise<void>)[] = []
6
+ const softKillListeners: (() => void | Promise<void>)[] = []
7
+
8
+ export const addKillListener = (listener: () => void | Promise<void>) => {
9
+ killListeners.push(listener)
10
+ }
11
+ export const addSoftKillListener = (listener: () => void | Promise<void>) => {
12
+ softKillListeners.push(listener)
13
+ }
14
+
15
+ export async function callKillListeners() {
16
+ log("👋 Calling kill listeners!")
17
+ await Promise.all(killListeners.map((listener) => listener()))
18
+ log("👋 Called all kill listeners!")
19
+ }
20
+
21
+ export async function callSoftKillListeners() {
22
+ log("👋 Calling soft kill listeners!")
23
+ await Promise.all(softKillListeners.map((listener) => listener()))
24
+ log("👋 Called all soft kill listeners!")
25
+ }
26
+
27
+ export async function killSubprocess(subprocess: Subprocess) {
28
+ log(`💀 Killing process: ${subprocess.pid}`)
29
+ await callSoftKillListeners()
30
+ subprocess.kill()
31
+ await subprocess.exited
32
+ }
@@ -1,20 +1,10 @@
1
1
  import { log } from "@typed-assistant/logger"
2
- import { getSpawnText } from "./getSpawnText"
2
+ import { $ } from "bun"
3
3
  import { bunInstall } from "./bunInstall"
4
4
 
5
5
  export const pullChanges = async () => {
6
- if (
7
- !process.env.GITHUB_TOKEN ||
8
- !process.env.GITHUB_USERNAME ||
9
- !process.env.GITHUB_REPO
10
- ) {
11
- log(
12
- "⚠️ Cannot pull changes without GITHUB_TOKEN, GITHUB_USERNAME, and GITHUB_REPO environment variables.",
13
- )
14
- return { error: {} }
15
- }
16
- log("⬇️ Pulling changes...")
17
- const gitPullText = await getSpawnText(["git", "pull"])
6
+ log("⬇️ Pulling changes...")
7
+ const gitPullText = await $`git pull`.text()
18
8
  const packageJSONUpdated = /package.json/.test(gitPullText)
19
9
  const nothingNew = /Already up to date./.test(gitPullText)
20
10
  if (nothingNew) {
@@ -25,8 +15,7 @@ export const pullChanges = async () => {
25
15
  }
26
16
  if (packageJSONUpdated) {
27
17
  log(" 📦 package.json updated.")
28
- const { error } = await bunInstall()
29
- if (error) throw new Error(error.text)
18
+ await bunInstall()
30
19
  }
31
20
  return {}
32
21
  }
@@ -0,0 +1,19 @@
1
+ import { log } from "@typed-assistant/logger"
2
+ import { ONE_SECOND } from "@typed-assistant/utils/durations"
3
+ import { pullChanges } from "./pullChanges"
4
+
5
+ export const setupGitPoller = async ({
6
+ gitPullPollDuration,
7
+ }: {
8
+ /** Duration in seconds */
9
+ gitPullPollDuration?: number
10
+ } = {}) => {
11
+ const duration = gitPullPollDuration ?? 30
12
+ const { error } = await pullChanges()
13
+ if (error) return
14
+ log(` ⏳ Pulling changes again in ${duration} seconds...`)
15
+
16
+ setTimeout(() => {
17
+ setupGitPoller({ gitPullPollDuration })
18
+ }, duration * ONE_SECOND)
19
+ }
@@ -0,0 +1,159 @@
1
+ import { log } from "@typed-assistant/logger"
2
+ import { withErrorHandling } from "@typed-assistant/utils/withErrorHandling"
3
+ import { z } from "zod"
4
+
5
+ const webhookUrl = `${process.env.HASS_EXTERNAL_URL}/webhook`
6
+
7
+ const commonOptions = {
8
+ headers: {
9
+ Accept: "application/vnd.github+json",
10
+ Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
11
+ "X-GitHub-Api-Version": "2022-11-28",
12
+ },
13
+ }
14
+
15
+ const listRepoWebhooks = async () =>
16
+ withErrorHandling(() =>
17
+ fetch(
18
+ `https://api.github.com/repos/${process.env.GITHUB_USERNAME}/${process.env.GITHUB_REPO}/hooks`,
19
+ { ...commonOptions },
20
+ )
21
+ .then(handleFetchError)
22
+ .then((d) => d?.json())
23
+ .then(z.array(Webhook).parse),
24
+ )()
25
+
26
+ const deleteRepoWebhook = async (id: number) =>
27
+ withErrorHandling(() =>
28
+ fetch(
29
+ `https://api.github.com/repos/${process.env.GITHUB_USERNAME}/${process.env.GITHUB_REPO}/hooks/${id}`,
30
+ { ...commonOptions, method: "DELETE" },
31
+ ),
32
+ )()
33
+
34
+ const deleteAllRepoWebhooks = async () => {
35
+ const { data: webhooks, error } = await listRepoWebhooks()
36
+
37
+ if (error) {
38
+ log("🚨 Failed fetching webhooks", error.message)
39
+ return
40
+ }
41
+
42
+ await Promise.all(
43
+ webhooks.map(async (webhook) => {
44
+ await deleteRepoWebhook(webhook.id)
45
+ log("🚮 Webhook deleted: ", webhook.config.url)
46
+ }),
47
+ )
48
+ }
49
+
50
+ const createRepoWebhook = async () =>
51
+ withErrorHandling(() =>
52
+ fetch(
53
+ `https://api.github.com/repos/${process.env.GITHUB_USERNAME}/${process.env.GITHUB_REPO}/hooks`,
54
+ {
55
+ ...commonOptions,
56
+ method: "POST",
57
+ body: JSON.stringify({
58
+ name: "web",
59
+ active: true,
60
+ config: {
61
+ url: webhookUrl,
62
+ content_type: "json",
63
+ insecure_ssl: "0",
64
+ },
65
+ events: ["push"],
66
+ }),
67
+ },
68
+ )
69
+ .then(handleFetchError)
70
+ .then((d) => d.json())
71
+ .then(Webhook.parse),
72
+ )()
73
+
74
+ const Webhook = z.object({
75
+ type: z.literal("Repository"),
76
+ id: z.number(),
77
+ name: z.literal("web"),
78
+ active: z.boolean(),
79
+ events: z.array(z.string()),
80
+ config: z.object({
81
+ content_type: z.string(),
82
+ insecure_ssl: z.enum(["0", "1"]),
83
+ url: z.string(),
84
+ }),
85
+ updated_at: z.string(),
86
+ created_at: z.string(),
87
+ url: z.string(),
88
+ test_url: z.string(),
89
+ ping_url: z.string(),
90
+ deliveries_url: z.string(),
91
+ last_response: z.object({
92
+ code: z.number().nullable(),
93
+ status: z.string().nullable(),
94
+ message: z.string().nullable(),
95
+ }),
96
+ })
97
+
98
+ type Webhook = z.infer<typeof Webhook>
99
+
100
+ const handleFetchError = async (d: Response): Promise<Response> => {
101
+ if (!d.ok)
102
+ throw new Error(
103
+ d.status +
104
+ " " +
105
+ d.statusText +
106
+ (d.headers.get("Content-Type")?.includes("application/json")
107
+ ? `:\n${JSON.stringify(await d.json(), null, 2)}`
108
+ : ""),
109
+ )
110
+ return d
111
+ }
112
+
113
+ const retryTimeout = 2000
114
+ let retries = 0
115
+ export const setupWebhook = async (): Promise<void> => {
116
+ const { data: webhooks, error } = await listRepoWebhooks()
117
+
118
+ if (error) {
119
+ if (retries < 5) {
120
+ retries++
121
+ log(
122
+ `🔁 Failed fetching webhooks. Retrying setup in ${retryTimeout / 1000}s...`,
123
+ )
124
+ setTimeout(setupWebhook, retryTimeout)
125
+ return
126
+ }
127
+ log("🚨 Failed fetching webhooks. Giving up.", error.message)
128
+ return
129
+ }
130
+
131
+ const webhookAlreadyExists = webhooks.some(
132
+ async (webhook) => webhook.config.url === webhookUrl,
133
+ )
134
+
135
+ if (webhookAlreadyExists) {
136
+ log("🪝 Webhook already set up")
137
+ return
138
+ }
139
+
140
+ const { data: webhook, error: createError } = await createRepoWebhook()
141
+
142
+ if (createError) {
143
+ if (retries < 5) {
144
+ retries++
145
+ log(
146
+ `🔁 Failed creating webhook. Retrying setup in ${retryTimeout / 1000}s...`,
147
+ )
148
+ setTimeout(setupWebhook, retryTimeout)
149
+ return
150
+ }
151
+ log("🚨 Failed creating webhook. Giving up.", createError.message)
152
+ return
153
+ }
154
+
155
+ log("🪝 Webhook created: ", webhook.config.url)
156
+ }
157
+
158
+ // await setupWebhook()
159
+ // await deleteAllRepoWebhooks()
@@ -6,6 +6,7 @@ import { Elysia, t } from "elysia"
6
6
  import { watch } from "fs"
7
7
  import { basename, join } from "path"
8
8
  import type { List, String } from "ts-toolbelt"
9
+ import { addKillListener, addSoftKillListener } from "./killProcess"
9
10
 
10
11
  const indexHtmlFilePath = `${import.meta.dir}/webserver/index.html` as const
11
12
  const cssFile = `${import.meta.dir}/webserver/input.css` as const
@@ -159,10 +160,9 @@ export const startWebappServer = async ({
159
160
  }
160
161
  })
161
162
 
162
- process.on("SIGINT", () => {
163
- console.log("👋 Closing log watcher...")
163
+ addKillListener(async () => {
164
164
  watcher.close()
165
- server.stop()
165
+ await server.stop()
166
166
  })
167
167
 
168
168
  // eslint-disable-next-line no-constant-condition
@@ -170,7 +170,8 @@ export const startWebappServer = async ({
170
170
  const stdoutReader = getReader(getSubprocesses().app.stdout)
171
171
  const { value } = await stdoutReader.read()
172
172
 
173
- const convertedMessage = convert.toHtml(decoder.decode(value))
173
+ const newLocal = decoder.decode(value)
174
+ const convertedMessage = convert.toHtml(newLocal)
174
175
  if (convertedMessage !== "") {
175
176
  lastMessage = convertedMessage
176
177
  }
@@ -20,7 +20,12 @@ const App = () => {
20
20
 
21
21
  const Logs = () => {
22
22
  const [limit, setLimit] = useState(50)
23
- const [logs, setLogs] = useState<string[]>([])
23
+ const [dateTimeVisibility, setDateTimeVisibility] = useState<
24
+ "hidden" | "timeOnly" | "visible"
25
+ >("timeOnly")
26
+ const [logs, setLogs] = useState<
27
+ { date: string; time: string; message: string }[]
28
+ >([])
24
29
 
25
30
  const ws = useWS({
26
31
  subscribe: useCallback(
@@ -28,7 +33,13 @@ const Logs = () => {
28
33
  [limit],
29
34
  ),
30
35
  onMessage: useCallback((event) => {
31
- setLogs(JSON.parse(event.data).logs)
36
+ setLogs(
37
+ (JSON.parse(event.data).logs as string[]).map((log: string) => {
38
+ const [date, time, message] =
39
+ log.match(/\[(.+), (.+)\] +(.+)/)?.slice(1) ?? []
40
+ return { date: date ?? "", time: time ?? "", message: message ?? log }
41
+ }),
42
+ )
32
43
  }, []),
33
44
  })
34
45
 
@@ -39,15 +50,34 @@ const Logs = () => {
39
50
  <h2 className="mb-2 text-2xl flex items-baseline gap-3">
40
51
  Logs <WSIndicator ws={ws.ws} />
41
52
  </h2>
42
- <div className="flex gap-2">
43
- <label htmlFor="limit">Limit</label>
44
- <input
45
- className="border border-gray-300 rounded-md text-slate-800 px-2"
46
- id="limit"
47
- onChange={(e) => setLimit(Number(e.target.value))}
48
- size={8}
49
- value={limit}
50
- />
53
+ <div className="flex flex-wrap gap-2">
54
+ <div className="flex gap-2">
55
+ <label htmlFor="dateTimeVisibility">Date/Time</label>
56
+ <select
57
+ className="border border-gray-300 rounded-md text-slate-800 px-2"
58
+ id="dateTimeVisibility"
59
+ onChange={(e) =>
60
+ setDateTimeVisibility(
61
+ e.target.value as typeof dateTimeVisibility,
62
+ )
63
+ }
64
+ value={dateTimeVisibility}
65
+ >
66
+ <option value="hidden">Hidden</option>
67
+ <option value="timeOnly">Time only</option>
68
+ <option value="visible">Visible</option>
69
+ </select>
70
+ </div>
71
+ <div className="flex gap-2">
72
+ <label htmlFor="limit">Limit</label>
73
+ <input
74
+ className="border border-gray-300 rounded-md text-slate-800 px-2"
75
+ id="limit"
76
+ onChange={(e) => setLimit(Number(e.target.value))}
77
+ size={8}
78
+ value={limit}
79
+ />
80
+ </div>
51
81
  </div>
52
82
  </>
53
83
  )}
@@ -55,7 +85,16 @@ const Logs = () => {
55
85
  <pre>
56
86
  <ul>
57
87
  {logs.map((log) => (
58
- <li key={log}>{log}</li>
88
+ <li key={log.date + log.time + log.message} className="">
89
+ <span className="text-slate-400 mr-2">
90
+ {dateTimeVisibility === "hidden"
91
+ ? null
92
+ : dateTimeVisibility === "timeOnly"
93
+ ? log.time
94
+ : `${log.date} ${log.time}`}
95
+ </span>
96
+ {log.message}
97
+ </li>
59
98
  ))}
60
99
  </ul>
61
100
  </pre>
@@ -7,7 +7,7 @@ export const AppSection = ({
7
7
  }) => {
8
8
  return (
9
9
  <div className="p-4 text-xs h-full max-h-dvh w-dvw md:w-auto overflow-x-auto flex flex-col">
10
- <div className="flex gap-4 mb-4 items-center justify-between">
10
+ <div className="flex flex-wrap gap-4 mb-4 items-center justify-between">
11
11
  {renderHeader()}
12
12
  </div>
13
13
  <div className="overflow-x-auto">{children}</div>
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useState } from "react"
2
- import { app } from "./api"
2
+ import type { app } from "./api"
3
3
 
4
4
  export function useWS({
5
5
  onMessage,
package/tsconfig.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "extends": "@typed-assistant/typescript-config/react-app.json",
2
+ "extends": "@typed-assistant/typescript-config/react.json",
3
3
  "compilerOptions": {
4
4
  "paths": {
5
5
  "@elysiajs/eden": ["./node_modules/@elysiajs/eden/"],
@@ -1,14 +0,0 @@
1
- export async function getSpawnText(...args: Parameters<typeof Bun.spawn>) {
2
- const { exitCode, exited, stderr, stdout } = Bun.spawn(...args)
3
- await exited
4
- if (exitCode === 0 || exitCode === null)
5
- return await Bun.readableStreamToText(stdout)
6
- if (!exitCode) return ""
7
- throw new Error(
8
- `Failed to run command: "${args.join(
9
- " ",
10
- )}". Exit code: ${exitCode}. Stderr: ${
11
- stderr ? await Bun.readableStreamToText(stderr) : "None"
12
- }`,
13
- )
14
- }