|
| 1 | +// Copyright 2025, Command Line Inc. |
| 2 | +// SPDX-License-Identifier: Apache-2.0 |
| 3 | + |
| 4 | +import { BlockNodeModel } from "@/app/block/blocktypes"; |
| 5 | +import { atoms, globalStore, WOS } from "@/app/store/global"; |
| 6 | +import { waveEventSubscribe } from "@/app/store/wps"; |
| 7 | +import { RpcApi } from "@/app/store/wshclientapi"; |
| 8 | +import { TabRpcClient } from "@/app/store/wshrpcutil"; |
| 9 | +import * as services from "@/store/services"; |
| 10 | +import * as jotai from "jotai"; |
| 11 | +import { memo, useEffect } from "react"; |
| 12 | + |
| 13 | +class TsunamiViewModel implements ViewModel { |
| 14 | + viewType: string; |
| 15 | + blockAtom: jotai.Atom<Block>; |
| 16 | + blockId: string; |
| 17 | + viewIcon: jotai.Atom<string>; |
| 18 | + viewName: jotai.Atom<string>; |
| 19 | + shellProcFullStatus: jotai.PrimitiveAtom<BlockControllerRuntimeStatus>; |
| 20 | + shellProcStatusUnsubFn: () => void; |
| 21 | + isRestarting: jotai.PrimitiveAtom<boolean>; |
| 22 | + |
| 23 | + constructor(blockId: string, nodeModel: BlockNodeModel) { |
| 24 | + this.viewType = "tsunami"; |
| 25 | + this.blockId = blockId; |
| 26 | + this.blockAtom = WOS.getWaveObjectAtom<Block>(`block:${blockId}`); |
| 27 | + this.viewIcon = jotai.atom("cube"); |
| 28 | + this.viewName = jotai.atom("Tsunami"); |
| 29 | + this.isRestarting = jotai.atom(false); |
| 30 | + |
| 31 | + this.shellProcFullStatus = jotai.atom(null) as jotai.PrimitiveAtom<BlockControllerRuntimeStatus>; |
| 32 | + const initialShellProcStatus = services.BlockService.GetControllerStatus(blockId); |
| 33 | + initialShellProcStatus.then((rts) => { |
| 34 | + this.updateShellProcStatus(rts); |
| 35 | + }); |
| 36 | + this.shellProcStatusUnsubFn = waveEventSubscribe({ |
| 37 | + eventType: "controllerstatus", |
| 38 | + scope: WOS.makeORef("block", blockId), |
| 39 | + handler: (event) => { |
| 40 | + let bcRTS: BlockControllerRuntimeStatus = event.data; |
| 41 | + this.updateShellProcStatus(bcRTS); |
| 42 | + }, |
| 43 | + }); |
| 44 | + } |
| 45 | + |
| 46 | + get viewComponent(): ViewComponent { |
| 47 | + return TsunamiView; |
| 48 | + } |
| 49 | + |
| 50 | + updateShellProcStatus(fullStatus: BlockControllerRuntimeStatus) { |
| 51 | + console.log("tsunami-status", fullStatus); |
| 52 | + if (fullStatus == null) { |
| 53 | + return; |
| 54 | + } |
| 55 | + const curStatus = globalStore.get(this.shellProcFullStatus); |
| 56 | + if (curStatus == null || curStatus.version < fullStatus.version) { |
| 57 | + globalStore.set(this.shellProcFullStatus, fullStatus); |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + triggerRestartAtom() { |
| 62 | + globalStore.set(this.isRestarting, true); |
| 63 | + setTimeout(() => { |
| 64 | + globalStore.set(this.isRestarting, false); |
| 65 | + }, 300); |
| 66 | + } |
| 67 | + |
| 68 | + resyncController() { |
| 69 | + const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { |
| 70 | + tabid: globalStore.get(atoms.staticTabId), |
| 71 | + blockid: this.blockId, |
| 72 | + forcerestart: false, |
| 73 | + }); |
| 74 | + prtn.catch((e) => console.log("error controller resync", e)); |
| 75 | + } |
| 76 | + |
| 77 | + forceRestartController() { |
| 78 | + if (globalStore.get(this.isRestarting)) { |
| 79 | + return; |
| 80 | + } |
| 81 | + this.triggerRestartAtom(); |
| 82 | + const prtn = RpcApi.ControllerResyncCommand(TabRpcClient, { |
| 83 | + tabid: globalStore.get(atoms.staticTabId), |
| 84 | + blockid: this.blockId, |
| 85 | + forcerestart: true, |
| 86 | + }); |
| 87 | + prtn.catch((e) => console.log("error controller resync (force restart)", e)); |
| 88 | + } |
| 89 | + |
| 90 | + dispose() { |
| 91 | + if (this.shellProcStatusUnsubFn) { |
| 92 | + this.shellProcStatusUnsubFn(); |
| 93 | + } |
| 94 | + } |
| 95 | + |
| 96 | + getSettingsMenuItems(): ContextMenuItem[] { |
| 97 | + return []; |
| 98 | + } |
| 99 | +} |
| 100 | + |
| 101 | +type TsunamiViewProps = { |
| 102 | + model: TsunamiViewModel; |
| 103 | +}; |
| 104 | + |
| 105 | +const TsunamiView = memo(({ model }: TsunamiViewProps) => { |
| 106 | + const shellProcFullStatus = jotai.useAtomValue(model.shellProcFullStatus); |
| 107 | + const blockData = jotai.useAtomValue(model.blockAtom); |
| 108 | + const isRestarting = jotai.useAtomValue(model.isRestarting); |
| 109 | + |
| 110 | + useEffect(() => { |
| 111 | + model.resyncController(); |
| 112 | + }, [model]); |
| 113 | + |
| 114 | + const appPath = blockData?.meta?.["tsunami:apppath"]; |
| 115 | + const controller = blockData?.meta?.controller; |
| 116 | + |
| 117 | + // Check for configuration errors |
| 118 | + const errors = []; |
| 119 | + if (!appPath) { |
| 120 | + errors.push("App path must be set (tsunami:apppath)"); |
| 121 | + } |
| 122 | + if (controller !== "tsunami") { |
| 123 | + errors.push("Invalid controller (must be 'tsunami')"); |
| 124 | + } |
| 125 | + |
| 126 | + // Show errors if any exist |
| 127 | + if (errors.length > 0) { |
| 128 | + return ( |
| 129 | + <div className="w-full h-full flex flex-col items-center justify-center gap-4"> |
| 130 | + <h1 className="text-4xl font-bold text-main-text-color">Tsunami</h1> |
| 131 | + <div className="flex flex-col gap-2"> |
| 132 | + {errors.map((error, index) => ( |
| 133 | + <div key={index} className="text-sm" style={{ color: "var(--color-error)" }}> |
| 134 | + {error} |
| 135 | + </div> |
| 136 | + ))} |
| 137 | + </div> |
| 138 | + </div> |
| 139 | + ); |
| 140 | + } |
| 141 | + |
| 142 | + // Check if we should show the iframe |
| 143 | + const shouldShowIframe = |
| 144 | + shellProcFullStatus?.shellprocstatus === "running" && |
| 145 | + shellProcFullStatus?.tsunamiport && |
| 146 | + shellProcFullStatus.tsunamiport !== 0; |
| 147 | + |
| 148 | + if (shouldShowIframe) { |
| 149 | + const iframeUrl = `http://localhost:${shellProcFullStatus.tsunamiport}/?clientid=wave:${model.blockId}`; |
| 150 | + return <iframe src={iframeUrl} className="w-full h-full border-0" title="Tsunami Application" name={`tsunami:${shellProcFullStatus.tsunamiport}:${model.blockId}`} />; |
| 151 | + } |
| 152 | + |
| 153 | + const status = shellProcFullStatus?.shellprocstatus ?? "init"; |
| 154 | + const isNotRunning = status === "done" || status === "init"; |
| 155 | + |
| 156 | + return ( |
| 157 | + <div className="w-full h-full flex flex-col items-center justify-center gap-4"> |
| 158 | + <h1 className="text-4xl font-bold text-main-text-color">Tsunami</h1> |
| 159 | + {appPath && <div className="text-sm text-main-text-color opacity-70">{appPath}</div>} |
| 160 | + {isNotRunning && !isRestarting && ( |
| 161 | + <button |
| 162 | + onClick={() => model.forceRestartController()} |
| 163 | + className="px-4 py-2 bg-accent-color text-primary-text-color rounded hover:bg-accent-color/80 transition-colors cursor-pointer" |
| 164 | + > |
| 165 | + Start |
| 166 | + </button> |
| 167 | + )} |
| 168 | + {isRestarting && <div className="text-sm text-success-color">Starting...</div>} |
| 169 | + </div> |
| 170 | + ); |
| 171 | +}); |
| 172 | + |
| 173 | +TsunamiView.displayName = "TsunamiView"; |
| 174 | + |
| 175 | +export { TsunamiViewModel }; |
0 commit comments