feat: Add type-check script and enhance UI for better text handling

This commit is contained in:
2026-06-27 12:46:15 -07:00
parent 3997682ab0
commit a3a862b63f
10 changed files with 121 additions and 37 deletions

View File

@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"type-check": "tsc -b --noEmit",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {

2
app/pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react" 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" import { apiClient } from "@/services/api/client"
interface SecretsEditorProps { interface SecretsEditorProps {
@@ -15,6 +15,38 @@ interface SecretState {
saving: boolean saving: boolean
saved: boolean saved: boolean
loaded: 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<boolean> {
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) { export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps) {
@@ -28,7 +60,7 @@ export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps)
...prev, ...prev,
[envKey]: { [envKey]: {
value: "", original: "", visible: false, value: "", original: "", visible: false,
saving: false, saved: false, loaded: false, saving: false, saved: false, loaded: false, copied: false,
}, },
})) }))
apiClient apiClient
@@ -78,11 +110,21 @@ export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps)
...prev, ...prev,
[envKey]: { [envKey]: {
value: "", original: "", visible: true, 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 handleRemove = (envKey: string) => {
const next = { ...secrets } const next = { ...secrets }
delete next[envKey] delete next[envKey]
@@ -102,10 +144,10 @@ export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps)
return ( return (
<div key={envKey} className="flex items-center gap-2"> <div key={envKey} className="flex items-center gap-2">
<span className="w-56 shrink-0 text-xs font-mono text-[var(--muted)] truncate" title={`${envKey}${secretName}`}> <span className="w-24 sm:w-56 shrink-0 text-xs font-mono text-[var(--muted)] truncate" title={`${envKey}${secretName}`}>
{envKey} {envKey}
</span> </span>
<div className="flex-1 flex items-center gap-1.5"> <div className="flex-1 min-w-0 flex items-center gap-1.5">
<input <input
type={s?.visible ? "text" : "password"} type={s?.visible ? "text" : "password"}
value={s?.loaded ? s.value : ""} value={s?.loaded ? s.value : ""}
@@ -116,7 +158,7 @@ export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps)
[envKey]: { ...prev[envKey], value: e.target.value }, [envKey]: { ...prev[envKey], value: e.target.value },
})) }))
} }
className="flex-1 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]" className="flex-1 min-w-0 bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
/> />
<button <button
onClick={() => onClick={() =>
@@ -125,14 +167,22 @@ export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps)
[envKey]: { ...prev[envKey], visible: !prev[envKey]?.visible }, [envKey]: { ...prev[envKey], visible: !prev[envKey]?.visible },
})) }))
} }
className="p-1 text-[var(--muted)] hover:text-[var(--foreground)]" className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)]"
> >
{s?.visible ? <EyeOff size={12} /> : <Eye size={12} />} {s?.visible ? <EyeOff size={12} /> : <Eye size={12} />}
</button> </button>
<button
onClick={() => handleCopy(envKey)}
disabled={!s?.loaded}
title="Copy secret value"
className="p-1 shrink-0 text-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-30"
>
{s?.copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
</button>
<button <button
onClick={() => handleSave(envKey)} onClick={() => handleSave(envKey)}
disabled={!dirty || s?.saving} disabled={!dirty || s?.saving}
className="p-1 text-blue-400 hover:text-blue-300 disabled:opacity-30" className="p-1 shrink-0 text-blue-400 hover:text-blue-300 disabled:opacity-30"
> >
{s?.saving ? ( {s?.saving ? (
<Loader2 size={12} className="animate-spin" /> <Loader2 size={12} className="animate-spin" />
@@ -144,7 +194,7 @@ export function SecretsEditor({ secrets, onSecretsChange }: SecretsEditorProps)
</button> </button>
<button <button
onClick={() => handleRemove(envKey)} onClick={() => handleRemove(envKey)}
className="p-1 text-red-400 hover:text-red-300" className="p-1 shrink-0 text-red-400 hover:text-red-300"
> >
<Trash2 size={12} /> <Trash2 size={12} />
</button> </button>

View File

@@ -20,16 +20,16 @@ export function DetailHeader({ backTo, backLabel, name, behavior, stack, source,
<ArrowLeft size={16} /> {backLabel} <ArrowLeft size={16} /> {backLabel}
</Link> </Link>
<div className="flex items-start justify-between mb-4"> <div className="flex items-start justify-between gap-3 flex-wrap mb-4">
<h1 className="text-2xl font-bold">{name}</h1> <h1 className="text-2xl font-bold break-all min-w-0">{name}</h1>
{children} {children}
</div> </div>
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 flex-wrap mb-6">
<BehaviorBadge behavior={behavior ?? null} /> <BehaviorBadge behavior={behavior ?? null} />
<StackBadge stack={stack ?? null} /> <StackBadge stack={stack ?? null} />
{source && ( {source && (
<span className="text-sm text-[var(--muted)] font-mono">{source}</span> <span className="text-sm text-[var(--muted)] font-mono break-all min-w-0">{source}</span>
)} )}
</div> </div>
</> </>

View File

@@ -96,7 +96,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
<input <input
value={runProgram} value={runProgram}
onChange={(e) => setRunProgram(e.target.value)} onChange={(e) => 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)]"
/> />
</Field> </Field>
<TextField <TextField

View File

@@ -26,9 +26,9 @@ export function SystemdPanel({ name, systemd }: SystemdPanelProps) {
</div> </div>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mt-3"> <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mt-3">
<span className="text-[var(--muted)]">Unit</span> <span className="text-[var(--muted)]">Unit</span>
<span className="font-mono">{systemd.unit_name}</span> <span className="font-mono break-all">{systemd.unit_name}</span>
<span className="text-[var(--muted)]">Path</span> <span className="text-[var(--muted)]">Path</span>
<span className="font-mono">{systemd.unit_path}</span> <span className="font-mono break-all">{systemd.unit_path}</span>
{systemd.timer && ( {systemd.timer && (
<> <>
<span className="text-[var(--muted)]">Timer</span> <span className="text-[var(--muted)]">Timer</span>

View File

@@ -16,8 +16,8 @@ export function Field({
}) { }) {
return ( return (
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<label className="w-32 shrink-0 text-sm font-medium pt-1.5">{label}</label> <label className="w-24 sm:w-32 shrink-0 text-sm font-medium pt-1.5">{label}</label>
<div className="flex-1"> <div className="flex-1 min-w-0">
{children} {children}
{hint && <p className="text-xs text-[var(--muted)] mt-1 leading-snug">{hint}</p>} {hint && <p className="text-xs text-[var(--muted)] mt-1 leading-snug">{hint}</p>}
</div> </div>
@@ -54,20 +54,34 @@ export function TextField({
) )
} }
// A value that is *exactly* a secret ref → fully editable via SecretsEditor.
const SECRET_RE = /^\$\{secret:([^}]+)\}$/ 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. /** 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. */ * Returns the editor element plus a `merged()` that reconstitutes the env. */
export function useEnvSecrets(initial: Record<string, string>) { export function useEnvSecrets(initial: Record<string, string>) {
const { plain, secretRefs } = useMemo(() => { const { plain, secretRefs, embeddedRefs } = useMemo(() => {
const p: Record<string, string> = {} const p: Record<string, string> = {}
const s: Record<string, string> = {} const s: Record<string, string> = {}
const e: { envKey: string; secretName: string }[] = []
for (const [k, v] of Object.entries(initial)) { for (const [k, v] of Object.entries(initial)) {
const m = SECRET_RE.exec(v) const m = SECRET_RE.exec(v)
if (m) s[k] = m[1] if (m) {
else p[k] = v 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
@@ -92,12 +106,16 @@ export function useEnvSecrets(initial: Record<string, string>) {
</p> </p>
{Object.entries(env).map(([key, val]) => ( {Object.entries(env).map(([key, val]) => (
<div key={key} className="flex items-center gap-2"> <div key={key} className="flex items-center gap-2">
<input value={key} readOnly className={`w-56 ${INPUT} text-xs text-[var(--muted)]`} /> <input
<span className="text-[var(--muted)]">=</span> value={key}
readOnly
className={`w-24 sm:w-56 min-w-0 ${INPUT} text-xs text-[var(--muted)]`}
/>
<span className="text-[var(--muted)] shrink-0">=</span>
<input <input
value={val} value={val}
onChange={(e) => setEnv((p) => ({ ...p, [key]: e.target.value }))} onChange={(e) => 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`}
/> />
<button <button
onClick={() => onClick={() =>
@@ -107,7 +125,7 @@ export function useEnvSecrets(initial: Record<string, string>) {
return n return n
}) })
} }
className="text-red-400 hover:text-red-300 p-0.5" className="text-red-400 hover:text-red-300 p-0.5 shrink-0"
title="Remove" title="Remove"
> >
<Trash2 size={12} /> <Trash2 size={12} />
@@ -127,6 +145,19 @@ export function useEnvSecrets(initial: Record<string, string>) {
</Field> </Field>
<Field label="Secrets"> <Field label="Secrets">
<SecretsEditor secrets={secrets} onSecretsChange={setSecrets} /> <SecretsEditor secrets={secrets} onSecretsChange={setSecrets} />
{embeddedRefs.length > 0 && (
<div className="mt-2 space-y-1">
{embeddedRefs.map(({ envKey, secretName }) => (
<p
key={`${envKey}:${secretName}`}
className="text-xs font-mono text-[var(--muted)]"
>
<span className="text-[var(--foreground)]">{secretName}</span> embedded in{" "}
{envKey} (read-only)
</p>
))}
</div>
)}
</Field> </Field>
</div> </div>
) )

View File

@@ -114,7 +114,7 @@ export function ProgramDetailPage() {
{servedAt && ( {servedAt && (
<> <>
<span className="text-[var(--muted)]">Reachable at</span> <span className="text-[var(--muted)]">Reachable at</span>
<a href={servedAt} className="font-mono text-[var(--primary)] hover:underline"> <a href={servedAt} className="font-mono break-all text-[var(--primary)] hover:underline">
{servedAt} <span className="text-[var(--muted)]">· served (static)</span> {servedAt} <span className="text-[var(--muted)]">· served (static)</span>
</a> </a>
</> </>

View File

@@ -48,14 +48,14 @@ export function ScheduledDetailPage() {
</h2> </h2>
<div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm"> <div className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<span className="text-[var(--muted)]">Cron</span> <span className="text-[var(--muted)]">Cron</span>
<span className="flex items-center gap-2 font-mono"> <span className="flex items-center gap-2 min-w-0 break-all font-mono">
<Clock size={14} className="text-[var(--muted)]" /> <Clock size={14} className="shrink-0 text-[var(--muted)]" />
{deployment.schedule} {deployment.schedule}
</span> </span>
{deployment.systemd && ( {deployment.systemd && (
<> <>
<span className="text-[var(--muted)]">Timer unit</span> <span className="text-[var(--muted)]">Timer unit</span>
<span className="font-mono"> <span className="font-mono break-all">
{deployment.systemd.unit_name.replace(".service", ".timer")} {deployment.systemd.unit_name.replace(".service", ".timer")}
</span> </span>
</> </>

View File

@@ -65,7 +65,7 @@ export function ServiceDetailPage() {
{deployment.health_path && ( {deployment.health_path && (
<> <>
<span className="text-[var(--muted)]">Health</span> <span className="text-[var(--muted)]">Health</span>
<span className="font-mono">{deployment.health_path}</span> <span className="font-mono break-all">{deployment.health_path}</span>
</> </>
)} )}
{deployment.proxy_path && ( {deployment.proxy_path && (
@@ -73,25 +73,25 @@ export function ServiceDetailPage() {
<span className="text-[var(--muted)]">Proxy</span> <span className="text-[var(--muted)]">Proxy</span>
<a <a
href={deployment.proxy_path + "/"} href={deployment.proxy_path + "/"}
className="flex items-center gap-1 text-[var(--primary)] hover:underline font-mono" className="flex items-center gap-1 min-w-0 break-all text-[var(--primary)] hover:underline font-mono"
> >
<ExternalLink size={12} />{deployment.proxy_path} <ExternalLink size={12} className="shrink-0" />{deployment.proxy_path}
</a> </a>
</> </>
)} )}
{deployment.proxy_host && ( {deployment.proxy_host && (
<> <>
<span className="text-[var(--muted)]">Host</span> <span className="text-[var(--muted)]">Host</span>
<span className="font-mono">{deployment.proxy_host}</span> <span className="font-mono break-all">{deployment.proxy_host}</span>
</> </>
)} )}
{deployment.runner && ( {deployment.runner && (
<> <>
<span className="text-[var(--muted)]">Runs</span> <span className="text-[var(--muted)]">Runs</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1 min-w-0">
<Terminal size={12} /> <Terminal size={12} className="shrink-0" />
{runnerLabel(deployment.runner)} {runnerLabel(deployment.runner)}
{deployment.run_target && <> &middot; <span className="font-mono">{deployment.run_target}</span></>} {deployment.run_target && <> &middot; <span className="font-mono break-all">{deployment.run_target}</span></>}
</span> </span>
</> </>
)} )}