feat(cloud): Add support for custom cloud app URL configuration (#207)

* feat(cloud): Add support for custom cloud app URL configuration

- Extend CloudState and Config to include CloudAppURL
- Update RPC methods to handle both API and app URLs
- Modify cloud adoption and settings routes to support custom app URLs
- Remove hardcoded cloud app URL environment file
- Simplify cloud URL configuration in UI

* fix(cloud): Improve cloud URL configuration and adoption flow

- Update error handling in cloud URL configuration RPC method
- Modify cloud adoption route to support dynamic cloud URLs
- Remove hardcoded default cloud URLs in device access settings
- Refactor cloud adoption click handler to be more flexible

* refactor(cloud): Simplify cloud URL configuration RPC method

- Update rpcSetCloudUrl to return only an error
- Remove unnecessary boolean return value
- Improve error handling consistency

* refactor(ui): Simplify cloud provider configuration and URL handling
This commit is contained in:
Adam Shiervani 2025-02-28 13:48:52 +01:00 committed by GitHub
parent 482c64ad02
commit e4bb4f288c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 114 additions and 116 deletions

View File

@ -264,12 +264,14 @@ func RunWebsocketClient() {
type CloudState struct { type CloudState struct {
Connected bool `json:"connected"` Connected bool `json:"connected"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
AppURL string `json:"appUrl,omitempty"`
} }
func rpcGetCloudState() CloudState { func rpcGetCloudState() CloudState {
return CloudState{ return CloudState{
Connected: config.CloudToken != "" && config.CloudURL != "", Connected: config.CloudToken != "" && config.CloudURL != "",
URL: config.CloudURL, URL: config.CloudURL,
AppURL: config.CloudAppURL,
} }
} }

View File

@ -22,6 +22,7 @@ type UsbConfig struct {
type Config struct { type Config struct {
CloudURL string `json:"cloud_url"` CloudURL string `json:"cloud_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"` CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"` GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"` JigglerEnabled bool `json:"jiggler_enabled"`
@ -43,6 +44,7 @@ const configPath = "/userdata/kvm_config.json"
var defaultConfig = &Config{ var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com", CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "", ActiveExtension: "",
DisplayMaxBrightness: 64, DisplayMaxBrightness: 64,

View File

@ -753,37 +753,17 @@ func rpcSetSerialSettings(settings SerialSettings) error {
return nil return nil
} }
func rpcSetCloudUrl(url string) error { func rpcSetCloudUrl(apiUrl string, appUrl string) error {
if url == "" { config.CloudURL = apiUrl
// Reset to default by removing from config config.CloudAppURL = appUrl
config.CloudURL = defaultConfig.CloudURL
} else {
config.CloudURL = url
}
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err) return fmt.Errorf("failed to save config: %w", err)
} }
return nil return nil
} }
func rpcGetCloudUrl() (string, error) {
return config.CloudURL, nil
}
func rpcResetCloudUrl() error {
// Reset to default by removing from config
config.CloudURL = defaultConfig.CloudURL
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to reset cloud URL: %w", err)
}
return nil
}
func rpcGetDefaultCloudUrl() (string, error) {
return defaultConfig.CloudURL, nil
}
var rpcHandlers = map[string]RPCHandler{ var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing}, "ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID}, "getDeviceID": {Func: rpcGetDeviceID},
@ -842,8 +822,5 @@ var rpcHandlers = map[string]RPCHandler{
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"getSerialSettings": {Func: rpcGetSerialSettings}, "getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"url"}}, "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getCloudUrl": {Func: rpcGetCloudUrl},
"resetCloudUrl": {Func: rpcResetCloudUrl},
"getDefaultCloudUrl": {Func: rpcGetDefaultCloudUrl},
} }

View File

@ -1,2 +0,0 @@
# Used in settings page to know where to link to when user wants to adopt a device to the cloud
VITE_CLOUD_APP=http://app.jetkvm.com

View File

@ -3,7 +3,7 @@ import { InputFieldWithLabel } from "./InputField";
import { UsbConfigState } from "@/hooks/stores"; import { UsbConfigState } from "@/hooks/stores";
import { useEffect, useCallback, useState } from "react"; import { useEffect, useCallback, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc"; import { useJsonRpc } from "../hooks/useJsonRpc";
import { USBConfig } from "../routes/devices.$id.settings.hardware"; import { USBConfig } from "./UsbConfigSetting";
export default function UpdateUsbConfigModal({ export default function UpdateUsbConfigModal({
onSetUsbConfig, onSetUsbConfig,

View File

@ -177,7 +177,6 @@ if (isOnDevice) {
}, },
], ],
}, },
{ {
path: "/adopt", path: "/adopt",
element: <AdoptRoute />, element: <AdoptRoute />,

View File

@ -1,6 +1,12 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom"; import { LoaderFunctionArgs, redirect } from "react-router-dom";
import api from "../api"; import api from "../api";
import { CLOUD_APP, DEVICE_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
export interface CloudState {
connected: boolean;
url: string;
appUrl: string;
}
const loader = async ({ request }: LoaderFunctionArgs) => { const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
@ -11,14 +17,21 @@ const loader = async ({ request }: LoaderFunctionArgs) => {
const oidcGoogle = searchParams.get("oidcGoogle"); const oidcGoogle = searchParams.get("oidcGoogle");
const clientId = searchParams.get("clientId"); const clientId = searchParams.get("clientId");
const res = await api.POST(`${DEVICE_API}/cloud/register`, { const [cloudStateResponse, registerResponse] = await Promise.all([
api.GET(`${DEVICE_API}/cloud/state`),
api.POST(`${DEVICE_API}/cloud/register`, {
token: tempToken, token: tempToken,
oidcGoogle, oidcGoogle,
clientId, clientId,
}); }),
]);
if (!res.ok) throw new Error("Failed to register device"); if (!cloudStateResponse.ok) throw new Error("Failed to get cloud state");
return redirect(CLOUD_APP + `/devices/${deviceId}/setup`); const cloudState = (await cloudStateResponse.json()) as CloudState;
if (!registerResponse.ok) throw new Error("Failed to register device");
return redirect(cloudState.appUrl + `/devices/${deviceId}/setup`);
}; };
export default function AdoptRoute() { export default function AdoptRoute() {

View File

@ -2,7 +2,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings"; import { SettingsItem } from "./devices.$id.settings";
import { useLoaderData, useNavigate } from "react-router-dom"; import { useLoaderData, useNavigate } from "react-router-dom";
import { Button, LinkButton } from "../components/Button"; import { Button, LinkButton } from "../components/Button";
import { CLOUD_APP, DEVICE_API } from "../ui.config"; import { DEVICE_API } from "../ui.config";
import api from "../api"; import api from "../api";
import { LocalDevice } from "./devices.$id"; import { LocalDevice } from "./devices.$id";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
@ -15,6 +15,7 @@ import { InputFieldWithLabel } from "../components/InputField";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsSectionHeader } from "../components/SettingsSectionHeader"; import { SettingsSectionHeader } from "../components/SettingsSectionHeader";
import { isOnDevice } from "../main"; import { isOnDevice } from "../main";
import { CloudState } from "./adopt";
export const loader = async () => { export const loader = async () => {
if (isOnDevice) { if (isOnDevice) {
@ -36,38 +37,30 @@ export default function SettingsAccessIndexRoute() {
const [isAdopted, setAdopted] = useState(false); const [isAdopted, setAdopted] = useState(false);
const [deviceId, setDeviceId] = useState<string | null>(null); const [deviceId, setDeviceId] = useState<string | null>(null);
const [cloudUrl, setCloudUrl] = useState(""); const [cloudApiUrl, setCloudApiUrl] = useState("");
const [cloudProviders, setCloudProviders] = useState< const [cloudAppUrl, setCloudAppUrl] = useState("");
{ value: string; label: string }[] | null
>([{ value: "https://api.jetkvm.com", label: "JetKVM Cloud" }]);
// The default value is just there so it doesn't flicker while we fetch the default Cloud URL and available providers // Use a simple string identifier for the selected provider
const [selectedUrlOption, setSelectedUrlOption] = useState<string>( const [selectedProvider, setSelectedProvider] = useState<string>("jetkvm");
"https://api.jetkvm.com",
);
const [defaultCloudUrl, setDefaultCloudUrl] = useState<string>("");
const syncCloudUrl = useCallback(() => {
send("getCloudUrl", {}, resp => {
if ("error" in resp) return;
const url = resp.result as string;
setCloudUrl(url);
// Check if the URL matches any predefined option
if (cloudProviders?.some(provider => provider.value === url)) {
setSelectedUrlOption(url);
} else {
setSelectedUrlOption("custom");
// setCustomCloudUrl(url);
}
});
}, [cloudProviders, send]);
const getCloudState = useCallback(() => { const getCloudState = useCallback(() => {
send("getCloudState", {}, resp => { send("getCloudState", {}, resp => {
if ("error" in resp) return console.error(resp.error); if ("error" in resp) return console.error(resp.error);
const cloudState = resp.result as { connected: boolean }; const cloudState = resp.result as CloudState;
setAdopted(cloudState.connected); setAdopted(cloudState.connected);
setCloudApiUrl(cloudState.url);
if (cloudState.appUrl) setCloudAppUrl(cloudState.appUrl);
// Find if the API URL matches any of our predefined providers
const isAPIJetKVMProd = cloudState.url === "https://api.jetkvm.com";
const isAppJetKVMProd = cloudState.appUrl === "https://app.jetkvm.com";
if (isAPIJetKVMProd && isAppJetKVMProd) {
setSelectedProvider("jetkvm");
} else {
setSelectedProvider("custom");
}
}); });
}, [send]); }, [send]);
@ -88,42 +81,50 @@ export default function SettingsAccessIndexRoute() {
}; };
const onCloudAdoptClick = useCallback( const onCloudAdoptClick = useCallback(
(url: string) => { (cloudApiUrl: string, cloudAppUrl: string) => {
if (!deviceId) { if (!deviceId) {
notifications.error("No device ID available"); notifications.error("No device ID available");
return; return;
} }
send("setCloudUrl", { url }, resp => { send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, resp => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`, `Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
); );
return; return;
} }
syncCloudUrl();
notifications.success("Cloud URL updated successfully");
const returnTo = new URL(window.location.href); const returnTo = new URL(window.location.href);
returnTo.pathname = "/adopt"; returnTo.pathname = "/adopt";
returnTo.search = ""; returnTo.search = "";
returnTo.hash = ""; returnTo.hash = "";
window.location.href = window.location.href =
CLOUD_APP + "/signup?deviceId=" + deviceId + `&returnTo=${returnTo.toString()}`; cloudAppUrl +
"/signup?deviceId=" +
deviceId +
`&returnTo=${returnTo.toString()}`;
}); });
}, },
[deviceId, syncCloudUrl, send], [deviceId, send],
); );
useEffect(() => { // Handle provider selection change
if (!defaultCloudUrl) return; const handleProviderChange = (value: string) => {
setSelectedUrlOption(defaultCloudUrl); setSelectedProvider(value);
setCloudProviders([
{ value: defaultCloudUrl, label: "JetKVM Cloud" },
{ value: "custom", label: "Custom" },
]);
}, [defaultCloudUrl]);
// If selecting a predefined provider, update both URLs
if (value === "jetkvm") {
setCloudApiUrl("https://api.jetkvm.com");
setCloudAppUrl("https://app.jetkvm.com");
} else {
if (cloudApiUrl || cloudAppUrl) return;
setCloudApiUrl("");
setCloudAppUrl("");
}
};
// Fetch device ID and cloud state on component mount
useEffect(() => { useEffect(() => {
getCloudState(); getCloudState();
@ -133,18 +134,6 @@ export default function SettingsAccessIndexRoute() {
}); });
}, [send, getCloudState]); }, [send, getCloudState]);
useEffect(() => {
send("getDefaultCloudUrl", {}, resp => {
if ("error" in resp) return console.error(resp.error);
setDefaultCloudUrl(resp.result as string);
});
}, [cloudProviders, syncCloudUrl, send]);
useEffect(() => {
if (!cloudProviders?.length) return;
syncCloudUrl();
}, [cloudProviders, syncCloudUrl]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
@ -219,34 +208,42 @@ export default function SettingsAccessIndexRoute() {
> >
<SelectMenuBasic <SelectMenuBasic
size="SM" size="SM"
value={selectedUrlOption} value={selectedProvider}
onChange={e => { onChange={e => handleProviderChange(e.target.value)}
const value = e.target.value; options={[
setSelectedUrlOption(value); { value: "jetkvm", label: "JetKVM Cloud" },
}} { value: "custom", label: "Custom" },
options={cloudProviders ?? []} ]}
/> />
</SettingsItem> </SettingsItem>
{selectedUrlOption === "custom" && ( {selectedProvider === "custom" && (
<div className="mt-4 flex items-end gap-x-2 space-y-4"> <div className="mt-4 space-y-4">
<div className="flex items-end gap-x-2">
<InputFieldWithLabel <InputFieldWithLabel
size="SM" size="SM"
label="Custom Cloud URL" label="Cloud API URL"
value={cloudUrl} value={cloudApiUrl}
onChange={e => setCloudUrl(e.target.value)} onChange={e => setCloudApiUrl(e.target.value)}
placeholder="https://api.example.com" placeholder="https://api.example.com"
/> />
</div> </div>
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud App URL"
value={cloudAppUrl}
onChange={e => setCloudAppUrl(e.target.value)}
placeholder="https://app.example.com"
/>
</div>
</div>
)} )}
</> </>
)} )}
{/* {/* Show security info for JetKVM Cloud */}
We do the harcoding here to avoid flickering when the default Cloud URL being fetched. {selectedProvider === "jetkvm" && (
I've tried to avoid harcoding api.jetkvm.com, but it's the only reasonable way I could think of to avoid flickering for now.
*/}
{selectedUrlOption === (defaultCloudUrl || "https://api.jetkvm.com") && (
<GridCard> <GridCard>
<div className="flex items-start gap-x-4 p-4"> <div className="flex items-start gap-x-4 p-4">
<ShieldCheckIcon className="mt-1 h-8 w-8 shrink-0 text-blue-600 dark:text-blue-500" /> <ShieldCheckIcon className="mt-1 h-8 w-8 shrink-0 text-blue-600 dark:text-blue-500" />
@ -295,7 +292,7 @@ export default function SettingsAccessIndexRoute() {
{!isAdopted ? ( {!isAdopted ? (
<div className="flex items-end gap-x-2"> <div className="flex items-end gap-x-2">
<Button <Button
onClick={() => onCloudAdoptClick(cloudUrl)} onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)}
size="SM" size="SM"
theme="primary" theme="primary"
text="Adopt KVM to Cloud" text="Adopt KVM to Cloud"
@ -305,7 +302,7 @@ export default function SettingsAccessIndexRoute() {
<div> <div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300"> <p className="text-sm text-slate-600 dark:text-slate-300">
Your device is adopted to JetKVM Cloud Your device is adopted to the Cloud
</p> </p>
<div> <div>
<Button <Button

View File

@ -1,5 +1,4 @@
export const CLOUD_API = import.meta.env.VITE_CLOUD_API; export const CLOUD_API = import.meta.env.VITE_CLOUD_API;
export const CLOUD_APP = import.meta.env.VITE_CLOUD_APP;
// In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint // In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint
export const DEVICE_API = ""; export const DEVICE_API = "";

11
web.go
View File

@ -100,6 +100,7 @@ func setupRouter() *gin.Engine {
{ {
protected.POST("/webrtc/session", handleWebRTCSession) protected.POST("/webrtc/session", handleWebRTCSession)
protected.POST("/cloud/register", handleCloudRegister) protected.POST("/cloud/register", handleCloudRegister)
protected.GET("/cloud/state", handleCloudState)
protected.GET("/device", handleDevice) protected.GET("/device", handleDevice)
protected.POST("/auth/logout", handleLogout) protected.POST("/auth/logout", handleLogout)
@ -359,6 +360,16 @@ func handleDeviceStatus(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
func handleCloudState(c *gin.Context) {
response := CloudState{
Connected: config.CloudToken != "",
URL: config.CloudURL,
AppURL: config.CloudAppURL,
}
c.JSON(http.StatusOK, response)
}
func handleDeviceUIConfig(c *gin.Context) { func handleDeviceUIConfig(c *gin.Context) {
config, _ := json.Marshal(gin.H{ config, _ := json.Marshal(gin.H{
"CLOUD_API": config.CloudURL, "CLOUD_API": config.CloudURL,