From 815990a51feca9528e24601f2f9ee20ebe657e97 Mon Sep 17 00:00:00 2001 From: sawka Date: Mon, 17 Nov 2025 17:55:55 -0800 Subject: [PATCH 01/12] working on secrets tab --- frontend/builder/builder-apppanel.tsx | 24 +--- frontend/builder/tabs/builder-envtab.tsx | 103 -------------- frontend/builder/tabs/builder-secrettab.tsx | 150 ++++++++++++++++++++ package-lock.json | 4 +- 4 files changed, 154 insertions(+), 127 deletions(-) delete mode 100644 frontend/builder/tabs/builder-envtab.tsx create mode 100644 frontend/builder/tabs/builder-secrettab.tsx diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index a0f0523a2a..fbfa1c485a 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -8,7 +8,7 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { BuilderAppPanelModel, type TabType } from "@/builder/store/builder-apppanel-model"; import { BuilderFocusManager } from "@/builder/store/builder-focusmanager"; import { BuilderCodeTab } from "@/builder/tabs/builder-codetab"; -import { BuilderEnvTab } from "@/builder/tabs/builder-envtab"; +import { BuilderEnvTab } from "@/builder/tabs/builder-secrettab"; import { BuilderFilesTab } from "@/builder/tabs/builder-filestab"; import { BuilderPreviewTab } from "@/builder/tabs/builder-previewtab"; import { builderAppHasSelection } from "@/builder/utils/builder-focus-utils"; @@ -194,7 +194,6 @@ const BuilderAppPanel = memo(() => { const activeTab = useAtomValue(model.activeTab); const focusType = useAtomValue(BuilderFocusManager.getInstance().focusType); const isAppFocused = focusType === "app"; - const envSaveNeeded = useAtomValue(model.envVarsDirtyAtom); const builderAppId = useAtomValue(atoms.builderAppId); const builderId = useAtomValue(atoms.builderId); @@ -241,12 +240,6 @@ const BuilderAppPanel = memo(() => { [model] ); - const handleEnvSave = useCallback(() => { - if (builderId) { - model.saveEnvVars(builderId); - } - }, [builderId, model]); - const handleRestart = useCallback(() => { model.restartBuilder(); }, [model]); @@ -301,7 +294,7 @@ const BuilderAppPanel = memo(() => { /> )} { Publish App - {activeTab === "env" && ( - - )} diff --git a/frontend/builder/tabs/builder-envtab.tsx b/frontend/builder/tabs/builder-envtab.tsx deleted file mode 100644 index e269c4fcde..0000000000 --- a/frontend/builder/tabs/builder-envtab.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2025, Command Line Inc. -// SPDX-License-Identifier: Apache-2.0 - -import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; -import { useAtomValue } from "jotai"; -import { memo } from "react"; - -type EnvVarRowProps = { - model: BuilderAppPanelModel; - index: number; -}; - -const EnvVarRow = memo(({ model, index }: EnvVarRowProps) => { - const envVar = useAtomValue(model.getEnvVarIndexAtom(index)); - - if (!envVar) { - return null; - } - - const isValueVisible = envVar.visible ?? false; - - return ( -
- model.setEnvVarAtIndex(index, { ...envVar, name: e.target.value }, true)} - placeholder="Variable Name" - className="flex-1 px-3 py-2 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" - /> -
- model.setEnvVarAtIndex(index, { ...envVar, value: e.target.value }, true)} - placeholder="Value" - className="w-full px-3 py-2 pr-10 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" - /> - -
- -
- ); -}); - -EnvVarRow.displayName = "EnvVarRow"; - -const BuilderEnvTab = memo(() => { - const model = BuilderAppPanelModel.getInstance(); - const envVars = useAtomValue(model.envVarsArrayAtom); - const error = useAtomValue(model.errorAtom); - - return ( -
-
-

Environment Variables

- -
- -
- These environment variables are transient and only used during builder testing. They are not bundled - with the app. -
- - {error &&
{error}
} - -
-
- {envVars.length === 0 ? ( -
- No environment variables defined. Click "Add Variable" to create one. -
- ) : ( - envVars.map((_, index) => ) - )} -
-
-
- ); -}); - -BuilderEnvTab.displayName = "BuilderEnvTab"; - -export { BuilderEnvTab }; diff --git a/frontend/builder/tabs/builder-secrettab.tsx b/frontend/builder/tabs/builder-secrettab.tsx new file mode 100644 index 0000000000..72a4ab66c3 --- /dev/null +++ b/frontend/builder/tabs/builder-secrettab.tsx @@ -0,0 +1,150 @@ +// Copyright 2025, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { BuilderAppPanelModel } from "@/builder/store/builder-apppanel-model"; +import { RpcApi } from "@/app/store/wshclientapi"; +import { TabRpcClient } from "@/app/store/wshrpcutil"; +import { atoms } from "@/store/global"; +import { globalStore } from "@/app/store/jotaiStore"; +import { useAtomValue } from "jotai"; +import { memo, useState } from "react"; + +type SecretRowProps = { + secretName: string; + secretMeta: SecretMeta; + currentBinding: string; + onBindingChange: (secretName: string, binding: string) => void; +}; + +const SecretRow = memo(({ secretName, secretMeta, currentBinding, onBindingChange }: SecretRowProps) => { + return ( +
+
+ {secretName} + {!secretMeta.optional && ( + Required + )} + {secretMeta.optional && ( + Optional + )} + {secretMeta.desc && — {secretMeta.desc}} +
+
+ onBindingChange(secretName, e.target.value)} + placeholder="Wave secret store name" + className="w-full px-3 py-2 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" + /> +
+
+ ); +}); + +SecretRow.displayName = "SecretRow"; + +const BuilderEnvTab = memo(() => { + const model = BuilderAppPanelModel.getInstance(); + const builderStatus = useAtomValue(model.builderStatusAtom); + const error = useAtomValue(model.errorAtom); + + const [localBindings, setLocalBindings] = useState<{ [key: string]: string }>({}); + const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const manifest = builderStatus?.manifest; + const secrets = manifest?.secrets || {}; + const secretBindings = builderStatus?.secretbindings || {}; + + if (!localBindings || Object.keys(localBindings).length === 0) { + if (Object.keys(secretBindings).length > 0) { + setLocalBindings({ ...secretBindings }); + } + } + + const sortedSecretEntries = Object.entries(secrets).sort(([nameA, metaA], [nameB, metaB]) => { + if (!metaA.optional && metaB.optional) return -1; + if (metaA.optional && !metaB.optional) return 1; + return nameA.localeCompare(nameB); + }); + + const handleBindingChange = (secretName: string, binding: string) => { + setLocalBindings((prev) => ({ ...prev, [secretName]: binding })); + setIsDirty(true); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + const appId = globalStore.get(atoms.builderAppId); + await RpcApi.WriteAppSecretBindingsCommand(TabRpcClient, { + appid: appId, + bindings: localBindings, + }); + setIsDirty(false); + globalStore.set(model.errorAtom, ""); + } catch (err) { + console.error("Failed to save secret bindings:", err); + globalStore.set(model.errorAtom, `Failed to save secret bindings: ${err.message || "Unknown error"}`); + } finally { + setIsSaving(false); + } + }; + + const allRequiredBound = + sortedSecretEntries.filter(([_, meta]) => !meta.optional).every(([name]) => localBindings[name]?.trim()) || + false; + + return ( +
+
+

Secret Bindings

+ +
+ +
+ Map app secrets to Wave secret store names. Required secrets must be bound before the app can run + successfully. +
+ + {!allRequiredBound && ( +
+ Some required secrets are not bound yet. +
+ )} + + {error &&
{error}
} + +
+ {sortedSecretEntries.length === 0 ? ( +
+ No secrets defined in this app manifest. +
+ ) : ( +
+ {sortedSecretEntries.map(([secretName, secretMeta]) => ( + + ))} +
+ )} +
+
+ ); +}); + +BuilderEnvTab.displayName = "BuilderEnvTab"; + +export { BuilderEnvTab }; diff --git a/package-lock.json b/package-lock.json index dfc132373a..b626130608 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveterm", - "version": "0.12.3-beta.2", + "version": "0.12.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveterm", - "version": "0.12.3-beta.2", + "version": "0.12.3", "hasInstallScript": true, "license": "Apache-2.0", "workspaces": [ From d3ce0b8568c4bb7976ef4fd427564c3e8ba85a76 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 18 Nov 2025 10:54:50 -0800 Subject: [PATCH 02/12] only show secrets tab if there are secrets, remove "env" internal name --- frontend/builder/builder-apppanel.tsx | 19 +++++++++++-------- .../builder/store/builder-apppanel-model.ts | 13 ++++++++++++- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/frontend/builder/builder-apppanel.tsx b/frontend/builder/builder-apppanel.tsx index 8507a4ba5c..3f36cbdb1e 100644 --- a/frontend/builder/builder-apppanel.tsx +++ b/frontend/builder/builder-apppanel.tsx @@ -196,6 +196,7 @@ const BuilderAppPanel = memo(() => { const isAppFocused = focusType === "app"; const builderAppId = useAtomValue(atoms.builderAppId); const builderId = useAtomValue(atoms.builderId); + const hasSecrets = useAtomValue(model.hasSecretsAtom); useEffect(() => { model.initialize(); @@ -291,13 +292,15 @@ const BuilderAppPanel = memo(() => { isAppFocused={isAppFocused} onClick={() => handleTabClick("files")} /> - handleTabClick("env")} - /> + {hasSecrets && ( + handleTabClick("secrets")} + /> + )}
-
+
diff --git a/frontend/builder/store/builder-apppanel-model.ts b/frontend/builder/store/builder-apppanel-model.ts index 09aec03dbf..5ee3d0e174 100644 --- a/frontend/builder/store/builder-apppanel-model.ts +++ b/frontend/builder/store/builder-apppanel-model.ts @@ -10,7 +10,7 @@ import { base64ToString, stringToBase64 } from "@/util/util"; import { atom, type Atom, type PrimitiveAtom } from "jotai"; import { debounce } from "throttle-debounce"; -export type TabType = "preview" | "files" | "code" | "env"; +export type TabType = "preview" | "files" | "code" | "secrets"; export type EnvVar = { name: string; @@ -30,6 +30,7 @@ export class BuilderAppPanelModel { isLoadingAtom: PrimitiveAtom = atom(false); errorAtom: PrimitiveAtom = atom(""); builderStatusAtom = atom(null) as PrimitiveAtom; + hasSecretsAtom: PrimitiveAtom = atom(false); saveNeededAtom!: Atom; focusElemRef: { current: HTMLInputElement | null } = { current: null }; monacoEditorRef: { current: any | null } = { current: null }; @@ -85,6 +86,7 @@ export class BuilderAppPanelModel { const currentStatus = globalStore.get(this.builderStatusAtom); if (!currentStatus || !currentStatus.version || status.version > currentStatus.version) { globalStore.set(this.builderStatusAtom, status); + this.updateSecretsLatch(status); } }, }); @@ -92,6 +94,7 @@ export class BuilderAppPanelModel { try { const status = await RpcApi.GetBuilderStatusCommand(TabRpcClient, builderId); globalStore.set(this.builderStatusAtom, status); + this.updateSecretsLatch(status); } catch (err) { console.error("Failed to load builder status:", err); } @@ -110,6 +113,14 @@ export class BuilderAppPanelModel { }); } + updateSecretsLatch(status: BuilderStatusData) { + if (!status?.manifest?.secrets) return; + const secrets = status.manifest.secrets; + if (Object.keys(secrets).length > 0) { + globalStore.set(this.hasSecretsAtom, true); + } + } + async loadEnvVars(builderId: string) { try { const rtInfo = await RpcApi.GetRTInfoCommand(TabRpcClient, { From 7fecaa1b8352c592351bdd9bd2b616c33a786e6f Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 18 Nov 2025 11:16:48 -0800 Subject: [PATCH 03/12] different error message UI when secrets are not mapped (secret error sentinel) --- frontend/builder/tabs/builder-previewtab.tsx | 33 ++++++++++++++++++++ pkg/buildercontroller/buildercontroller.go | 5 ++- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/frontend/builder/tabs/builder-previewtab.tsx b/frontend/builder/tabs/builder-previewtab.tsx index 61f8157a07..2258e31441 100644 --- a/frontend/builder/tabs/builder-previewtab.tsx +++ b/frontend/builder/tabs/builder-previewtab.tsx @@ -34,9 +34,12 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => { const displayMsg = errorMsg && errorMsg.trim() ? errorMsg : "Unknown Error"; const waveAIModel = WaveAIModel.getInstance(); const buildPanelModel = BuilderBuildPanelModel.getInstance(); + const appPanelModel = BuilderAppPanelModel.getInstance(); const outputLines = useAtomValue(buildPanelModel.outputLines); const isStreaming = useAtomValue(waveAIModel.isAIStreaming); + const isSecretError = displayMsg.includes("ERR-SECRET"); + const getBuildContext = () => { const filteredLines = outputLines.filter((line) => !line.startsWith("[debug]")); const buildOutput = filteredLines.join("\n").trim(); @@ -55,6 +58,36 @@ const ErrorStateView = memo(({ errorMsg }: { errorMsg: string }) => { await waveAIModel.handleSubmit(); }; + const handleGoToSecrets = () => { + appPanelModel.setActiveTab("secrets"); + }; + + if (isSecretError) { + return ( +
+
+
🔐
+
+

Secrets Required

+

+ This app requires secrets that must be configured. Please use the Secrets tab to set and bind + the required secrets for your app to run. +

+
+
{displayMsg}
+
+ +
+
+
+ ); + } + return (
diff --git a/pkg/buildercontroller/buildercontroller.go b/pkg/buildercontroller/buildercontroller.go index 11ccfe1642..69e1da2ceb 100644 --- a/pkg/buildercontroller/buildercontroller.go +++ b/pkg/buildercontroller/buildercontroller.go @@ -301,12 +301,12 @@ func (bc *BuilderController) runBuilderApp(ctx context.Context, appId string, ap secretBindings, err := waveappstore.ReadAppSecretBindings(appId) if err != nil { - return nil, fmt.Errorf("failed to read secret bindings: %w", err) + return nil, fmt.Errorf("failed to read secret bindings (ERR-SECRET): %w", err) } secretEnv, err := waveappstore.BuildAppSecretEnv(appId, manifest, secretBindings) if err != nil { - return nil, fmt.Errorf("failed to build secret environment: %w", err) + return nil, fmt.Errorf("failed to build secret environment (ERR-SECRET): %w", err) } if builderEnv == nil { @@ -579,7 +579,6 @@ func (bc *BuilderController) publishOutputLine(line string, reset bool) { }) } - func exitCodeFromWaitErr(waitErr error) int { if waitErr == nil { return 0 From edf3fa1152bf28b2e10c8ee620f7c43f8c999325 Mon Sep 17 00:00:00 2001 From: sawka Date: Tue, 18 Nov 2025 11:28:54 -0800 Subject: [PATCH 04/12] working on updated secrets UI --- frontend/app/modals/modalregistry.tsx | 2 + frontend/builder/tabs/builder-secrettab.tsx | 170 ++++++++++++++++++-- 2 files changed, 159 insertions(+), 13 deletions(-) diff --git a/frontend/app/modals/modalregistry.tsx b/frontend/app/modals/modalregistry.tsx index cb67820b6a..53fabde064 100644 --- a/frontend/app/modals/modalregistry.tsx +++ b/frontend/app/modals/modalregistry.tsx @@ -5,6 +5,7 @@ import { MessageModal } from "@/app/modals/messagemodal"; import { NewInstallOnboardingModal } from "@/app/onboarding/onboarding"; import { UpgradeOnboardingModal } from "@/app/onboarding/onboarding-upgrade"; import { DeleteFileModal, PublishAppModal, RenameFileModal } from "@/builder/builder-apppanel"; +import { SetSecretDialog } from "@/builder/tabs/builder-secrettab"; import { AboutModal } from "./about"; import { UserInputModal } from "./userinputmodal"; @@ -17,6 +18,7 @@ const modalRegistry: { [key: string]: React.ComponentType } = { [PublishAppModal.displayName || "PublishAppModal"]: PublishAppModal, [RenameFileModal.displayName || "RenameFileModal"]: RenameFileModal, [DeleteFileModal.displayName || "DeleteFileModal"]: DeleteFileModal, + [SetSecretDialog.displayName || "SetSecretDialog"]: SetSecretDialog, }; export const getModalComponent = (key: string): React.ComponentType | undefined => { diff --git a/frontend/builder/tabs/builder-secrettab.tsx b/frontend/builder/tabs/builder-secrettab.tsx index 72a4ab66c3..dbc2d215c0 100644 --- a/frontend/builder/tabs/builder-secrettab.tsx +++ b/frontend/builder/tabs/builder-secrettab.tsx @@ -7,18 +7,34 @@ import { TabRpcClient } from "@/app/store/wshrpcutil"; import { atoms } from "@/store/global"; import { globalStore } from "@/app/store/jotaiStore"; import { useAtomValue } from "jotai"; -import { memo, useState } from "react"; +import { memo, useState, useEffect } from "react"; +import { Check, AlertTriangle } from "lucide-react"; +import { Tooltip } from "@/app/element/tooltip"; +import { Modal } from "@/app/modals/modal"; +import { modalsModel } from "@/app/store/modalmodel"; type SecretRowProps = { secretName: string; secretMeta: SecretMeta; currentBinding: string; - onBindingChange: (secretName: string, binding: string) => void; + availableSecrets: string[]; + onMapDefault: (secretName: string) => void; + onSetAndMapDefault: (secretName: string) => void; }; -const SecretRow = memo(({ secretName, secretMeta, currentBinding, onBindingChange }: SecretRowProps) => { +const SecretRow = memo(({ secretName, secretMeta, currentBinding, availableSecrets, onMapDefault, onSetAndMapDefault }: SecretRowProps) => { + const isMapped = currentBinding.trim().length > 0; + const isValid = isMapped && availableSecrets.includes(currentBinding); + const hasMatchingSecret = availableSecrets.includes(secretName); + return (
+ +
+ {!isMapped && } + {isMapped && isValid && } +
+
{secretName} {!secretMeta.optional && ( @@ -29,14 +45,23 @@ const SecretRow = memo(({ secretName, secretMeta, currentBinding, onBindingChang )} {secretMeta.desc && — {secretMeta.desc}}
-
- onBindingChange(secretName, e.target.value)} - placeholder="Wave secret store name" - className="w-full px-3 py-2 bg-background border border-border rounded text-primary focus:outline-none focus:border-accent" - /> +
+ {!isMapped && hasMatchingSecret && ( + + )} + {!isMapped && !hasMatchingSecret && ( + + )}
); @@ -44,6 +69,94 @@ const SecretRow = memo(({ secretName, secretMeta, currentBinding, onBindingChang SecretRow.displayName = "SecretRow"; +type SetSecretDialogProps = { + secretName: string; + onSetAndMap: (secretName: string, secretValue: string) => Promise; +}; + +const SetSecretDialog = memo(({ secretName, onSetAndMap }: SetSecretDialogProps) => { + const [secretValue, setSecretValue] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async () => { + if (!secretValue.trim()) return; + setIsSubmitting(true); + setError(""); + try { + await onSetAndMap(secretName, secretValue); + modalsModel.popModal(); + } catch (err) { + console.error("Failed to set secret:", err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsSubmitting(false); + } + }; + + const handleClose = () => { + modalsModel.popModal(); + }; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + handleClose(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, []); + + if (error) { + return ( + +
+

Error Setting Secret

+
{error}
+
+
+ ); + } + + return ( + +
+

Set and Map Secret

+
+
+ Secret Name: {secretName} +
+