From a3a862b63fcf9515b8ffa0312704f04944b49f20 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sat, 27 Jun 2026 12:46:15 -0700 Subject: [PATCH] feat: Add type-check script and enhance UI for better text handling --- app/package.json | 1 + app/pnpm-workspace.yaml | 2 + app/src/components/SecretsEditor.tsx | 68 ++++++++++++++++++--- app/src/components/detail/DetailHeader.tsx | 8 +-- app/src/components/detail/ServiceFields.tsx | 2 +- app/src/components/detail/SystemdPanel.tsx | 4 +- app/src/components/detail/fields.tsx | 51 +++++++++++++--- app/src/pages/ProgramDetail.tsx | 2 +- app/src/pages/ScheduledDetail.tsx | 6 +- app/src/pages/ServiceDetail.tsx | 14 ++--- 10 files changed, 121 insertions(+), 37 deletions(-) create mode 100644 app/pnpm-workspace.yaml diff --git a/app/package.json b/app/package.json index 368c33b..2e99647 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", + "type-check": "tsc -b --noEmit", "preview": "vite preview" }, "dependencies": { diff --git a/app/pnpm-workspace.yaml b/app/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/app/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/app/src/components/SecretsEditor.tsx b/app/src/components/SecretsEditor.tsx index 14a4031..945bfcf 100644 --- a/app/src/components/SecretsEditor.tsx +++ b/app/src/components/SecretsEditor.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react" -import { Check, Eye, EyeOff, Loader2, Plus, Save, Trash2 } from "lucide-react" +import { Check, Copy, Eye, EyeOff, Loader2, Plus, Save, Trash2 } from "lucide-react" import { apiClient } from "@/services/api/client" interface SecretsEditorProps { @@ -15,6 +15,38 @@ interface SecretState { saving: boolean saved: boolean loaded: boolean + copied: boolean +} + +/** Copy text to the clipboard, returning whether it succeeded. + * + * `navigator.clipboard` only exists in a secure context (HTTPS or + * localhost). The dashboard is reached over plain HTTP across the LAN + * (e.g. from a phone), where it's undefined — so fall back to the legacy + * execCommand path, which works in insecure contexts. */ +async function writeClipboard(text: string): Promise { + if (navigator.clipboard?.writeText) { + try { + await navigator.clipboard.writeText(text) + return true + } catch { + // Fall through to the legacy path below. + } + } + try { + const ta = document.createElement("textarea") + ta.value = text + // Keep it out of view and unfocusable to the page layout. + ta.style.position = "fixed" + ta.style.opacity = "0" + document.body.appendChild(ta) + ta.select() + const ok = document.execCommand("copy") + document.body.removeChild(ta) + return ok + } catch { + return false + } } export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps) { @@ -28,7 +60,7 @@ export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps) ...prev, [envKey]: { value: "", original: "", visible: false, - saving: false, saved: false, loaded: false, + saving: false, saved: false, loaded: false, copied: false, }, })) apiClient @@ -78,11 +110,21 @@ export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps) ...prev, [envKey]: { value: "", original: "", visible: true, - saving: false, saved: false, loaded: true, + saving: false, saved: false, loaded: true, copied: false, }, })) } + const handleCopy = async (envKey: string) => { + const s = states[envKey] + if (!s?.loaded) return + if (!(await writeClipboard(s.value))) return + setStates((prev) => ({ ...prev, [envKey]: { ...prev[envKey], copied: true } })) + setTimeout(() => { + setStates((prev) => ({ ...prev, [envKey]: { ...prev[envKey], copied: false } })) + }, 2000) + } + const handleRemove = (envKey: string) => { const next = { ...secrets } delete next[envKey] @@ -102,10 +144,10 @@ export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps) return (
- + {envKey} -
+
+ diff --git a/app/src/components/detail/DetailHeader.tsx b/app/src/components/detail/DetailHeader.tsx index ca2f3cb..c6eda84 100644 --- a/app/src/components/detail/DetailHeader.tsx +++ b/app/src/components/detail/DetailHeader.tsx @@ -20,16 +20,16 @@ export function DetailHeader({ backTo, backLabel, name, behavior, stack, source, {backLabel} -
-

{name}

+
+

{name}

{children}
-
+
{source && ( - {source} + {source} )}
diff --git a/app/src/components/detail/ServiceFields.tsx b/app/src/components/detail/ServiceFields.tsx index 5946789..a67de74 100644 --- a/app/src/components/detail/ServiceFields.tsx +++ b/app/src/components/detail/ServiceFields.tsx @@ -96,7 +96,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) { setRunProgram(e.target.value)} - className="w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]" + className="w-full sm:w-56 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]" />
Unit - {systemd.unit_name} + {systemd.unit_name} Path - {systemd.unit_path} + {systemd.unit_path} {systemd.timer && ( <> Timer diff --git a/app/src/components/detail/fields.tsx b/app/src/components/detail/fields.tsx index 8e68192..13ab54d 100644 --- a/app/src/components/detail/fields.tsx +++ b/app/src/components/detail/fields.tsx @@ -16,8 +16,8 @@ export function Field({ }) { return (
- -
+ +
{children} {hint &&

{hint}

}
@@ -54,20 +54,34 @@ export function TextField({ ) } +// A value that is *exactly* a secret ref → fully editable via SecretsEditor. const SECRET_RE = /^\$\{secret:([^}]+)\}$/ +// A secret ref embedded anywhere in a value (e.g. `neo4j/${secret:PW}`). These +// are composite literals, so we surface them read-only rather than letting +// merged() rewrite the value and clobber the surrounding text. +const EMBEDDED_SECRET_RE = /\$\{secret:([^}]+)\}/g /** Hook for editing a run env that mixes plain vars and `${secret:NAME}` refs. * Returns the editor element plus a `merged()` that reconstitutes the env. */ export function useEnvSecrets(initial: Record) { - const { plain, secretRefs } = useMemo(() => { + const { plain, secretRefs, embeddedRefs } = useMemo(() => { const p: Record = {} const s: Record = {} + const e: { envKey: string; secretName: string }[] = [] for (const [k, v] of Object.entries(initial)) { const m = SECRET_RE.exec(v) - if (m) s[k] = m[1] - else p[k] = v + if (m) { + s[k] = m[1] + } else { + // Composite values stay in `plain` so their literal text round-trips + // untouched; any embedded secret names are surfaced read-only below. + p[k] = v + for (const em of v.matchAll(EMBEDDED_SECRET_RE)) { + e.push({ envKey: k, secretName: em[1] }) + } + } } - return { plain: p, secretRefs: s } + return { plain: p, secretRefs: s, embeddedRefs: e } // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -92,12 +106,16 @@ export function useEnvSecrets(initial: Record) {

{Object.entries(env).map(([key, val]) => (
- - = + + = setEnv((p) => ({ ...p, [key]: e.target.value }))} - className={`flex-1 ${INPUT} text-xs font-mono`} + className={`flex-1 min-w-0 ${INPUT} text-xs font-mono`} />
) diff --git a/app/src/pages/ProgramDetail.tsx b/app/src/pages/ProgramDetail.tsx index 169835c..c8d5842 100644 --- a/app/src/pages/ProgramDetail.tsx +++ b/app/src/pages/ProgramDetail.tsx @@ -114,7 +114,7 @@ export function ProgramDetailPage() { {servedAt && ( <> Reachable at - + {servedAt} · served (static) diff --git a/app/src/pages/ScheduledDetail.tsx b/app/src/pages/ScheduledDetail.tsx index c9e3368..b4d137a 100644 --- a/app/src/pages/ScheduledDetail.tsx +++ b/app/src/pages/ScheduledDetail.tsx @@ -48,14 +48,14 @@ export function ScheduledDetailPage() {
Cron - - + + {deployment.schedule} {deployment.systemd && ( <> Timer unit - + {deployment.systemd.unit_name.replace(".service", ".timer")} diff --git a/app/src/pages/ServiceDetail.tsx b/app/src/pages/ServiceDetail.tsx index 9d93e51..e4bb1c1 100644 --- a/app/src/pages/ServiceDetail.tsx +++ b/app/src/pages/ServiceDetail.tsx @@ -65,7 +65,7 @@ export function ServiceDetailPage() { {deployment.health_path && ( <> Health - {deployment.health_path} + {deployment.health_path} )} {deployment.proxy_path && ( @@ -73,25 +73,25 @@ export function ServiceDetailPage() { Proxy - {deployment.proxy_path} + {deployment.proxy_path} )} {deployment.proxy_host && ( <> Host - {deployment.proxy_host} + {deployment.proxy_host} )} {deployment.runner && ( <> Runs - - + + {runnerLabel(deployment.runner)} - {deployment.run_target && <> · {deployment.run_target}} + {deployment.run_target && <> · {deployment.run_target}} )}