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
This commit is contained in:
Adam Shiervani 2025-02-28 01:23:20 +01:00
parent 482c64ad02
commit 69a25ce1e9
9 changed files with 136 additions and 133 deletions

View File

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

View File

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

View File

@ -753,35 +753,15 @@ func rpcSetSerialSettings(settings SerialSettings) error {
return nil
}
func rpcSetCloudUrl(url string) error {
if url == "" {
// Reset to default by removing from config
config.CloudURL = defaultConfig.CloudURL
} else {
config.CloudURL = url
}
func rpcSetCloudUrl(apiUrl string, appUrl string) (bool, error) {
config.CloudURL = apiUrl
config.CloudAppURL = appUrl
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
return false, err
}
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
return true, nil
}
var rpcHandlers = map[string]RPCHandler{
@ -842,8 +822,5 @@ var rpcHandlers = map[string]RPCHandler{
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"url"}},
"getCloudUrl": {Func: rpcGetCloudUrl},
"resetCloudUrl": {Func: rpcResetCloudUrl},
"getDefaultCloudUrl": {Func: rpcGetDefaultCloudUrl},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
}

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

@ -177,12 +177,11 @@ if (isOnDevice) {
},
],
},
{
path: "/adopt",
element: <AdoptRoute />,
loader: AdoptRoute.loader,
errorElement: <ErrorBoundary />,
loader: AdoptRoute.loader,
},
]);
} else {

View File

@ -1,6 +1,12 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom";
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 url = new URL(request.url);
@ -11,14 +17,21 @@ const loader = async ({ request }: LoaderFunctionArgs) => {
const oidcGoogle = searchParams.get("oidcGoogle");
const clientId = searchParams.get("clientId");
const res = await api.POST(`${DEVICE_API}/cloud/register`, {
token: tempToken,
oidcGoogle,
clientId,
});
const [cloudStateResponse, registerResponse] = await Promise.all([
api.GET(`${DEVICE_API}/cloud/state`),
api.POST(`${DEVICE_API}/cloud/register`, {
token: tempToken,
oidcGoogle,
clientId,
}),
]);
if (!res.ok) throw new Error("Failed to register device");
return redirect(CLOUD_APP + `/devices/${deviceId}/setup`);
if (!cloudStateResponse.ok) throw new Error("Failed to get cloud state");
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() {

View File

@ -2,7 +2,7 @@ import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { useLoaderData, useNavigate } from "react-router-dom";
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 { LocalDevice } from "./devices.$id";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
@ -15,6 +15,7 @@ import { InputFieldWithLabel } from "../components/InputField";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsSectionHeader } from "../components/SettingsSectionHeader";
import { isOnDevice } from "../main";
import { CloudState } from "./adopt";
export const loader = async () => {
if (isOnDevice) {
@ -26,6 +27,22 @@ export const loader = async () => {
return null;
};
// Define hardcoded cloud providers with both API and app URLs
const CLOUD_PROVIDERS = [
{
value: "jetkvm",
apiUrl: "https://api.jetkvm.com",
appUrl: "https://app.jetkvm.com",
label: "JetKVM Cloud",
},
{
value: "custom",
apiUrl: "",
appUrl: "",
label: "Custom",
},
];
export default function SettingsAccessIndexRoute() {
const loaderData = useLoaderData() as LocalDevice | null;
@ -36,38 +53,29 @@ export default function SettingsAccessIndexRoute() {
const [isAdopted, setAdopted] = useState(false);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [cloudUrl, setCloudUrl] = useState("");
const [cloudProviders, setCloudProviders] = useState<
{ value: string; label: string }[] | null
>([{ value: "https://api.jetkvm.com", label: "JetKVM Cloud" }]);
const [cloudApiUrl, setCloudApiUrl] = useState("https://api.jetkvm.com");
const [cloudAppUrl, setCloudAppUrl] = useState("https://app.jetkvm.com");
// The default value is just there so it doesn't flicker while we fetch the default Cloud URL and available providers
const [selectedUrlOption, setSelectedUrlOption] = useState<string>(
"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]);
// Use a simple string identifier for the selected provider
const [selectedProvider, setSelectedProvider] = useState<string>("jetkvm");
const getCloudState = useCallback(() => {
send("getCloudState", {}, resp => {
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);
setCloudApiUrl(cloudState.url);
if (cloudState.appUrl) setCloudAppUrl(cloudState.appUrl);
// Find if the API URL matches any of our predefined providers
const matchingProvider = CLOUD_PROVIDERS.find(p => p.apiUrl === cloudState.url);
if (matchingProvider && matchingProvider.value !== "custom") {
setSelectedProvider(matchingProvider.value);
} else {
setSelectedProvider("custom");
}
});
}, [send]);
@ -87,43 +95,44 @@ export default function SettingsAccessIndexRoute() {
});
};
const onCloudAdoptClick = useCallback(
(url: string) => {
if (!deviceId) {
notifications.error("No device ID available");
const onCloudAdoptClick = useCallback(() => {
if (!deviceId) {
notifications.error("No device ID available");
return;
}
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
send("setCloudUrl", { url }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
syncCloudUrl();
notifications.success("Cloud URL updated successfully");
getCloudState();
const returnTo = new URL(window.location.href);
returnTo.pathname = "/adopt";
returnTo.search = "";
returnTo.hash = "";
window.location.href =
CLOUD_APP + "/signup?deviceId=" + deviceId + `&returnTo=${returnTo.toString()}`;
});
},
[deviceId, syncCloudUrl, send],
);
const returnTo = new URL(window.location.href);
returnTo.pathname = "/adopt";
returnTo.search = "";
returnTo.hash = "";
window.location.href =
cloudAppUrl + "/signup?deviceId=" + deviceId + `&returnTo=${returnTo.toString()}`;
});
}, [deviceId, getCloudState, send, cloudApiUrl, cloudAppUrl]);
useEffect(() => {
if (!defaultCloudUrl) return;
setSelectedUrlOption(defaultCloudUrl);
setCloudProviders([
{ value: defaultCloudUrl, label: "JetKVM Cloud" },
{ value: "custom", label: "Custom" },
]);
}, [defaultCloudUrl]);
// Handle provider selection change
const handleProviderChange = (value: string) => {
setSelectedProvider(value);
// If selecting a predefined provider, update both URLs
const provider = CLOUD_PROVIDERS.find(p => p.value === value);
if (provider && value !== "custom") {
setCloudApiUrl(provider.apiUrl);
setCloudAppUrl(provider.appUrl);
}
};
// Fetch device ID and cloud state on component mount
useEffect(() => {
getCloudState();
@ -133,18 +142,6 @@ export default function SettingsAccessIndexRoute() {
});
}, [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 (
<div className="space-y-4">
<SettingsPageHeader
@ -219,34 +216,39 @@ export default function SettingsAccessIndexRoute() {
>
<SelectMenuBasic
size="SM"
value={selectedUrlOption}
onChange={e => {
const value = e.target.value;
setSelectedUrlOption(value);
}}
options={cloudProviders ?? []}
value={selectedProvider}
onChange={e => handleProviderChange(e.target.value)}
options={CLOUD_PROVIDERS.map(p => ({ value: p.value, label: p.label }))}
/>
</SettingsItem>
{selectedUrlOption === "custom" && (
<div className="mt-4 flex items-end gap-x-2 space-y-4">
<InputFieldWithLabel
size="SM"
label="Custom Cloud URL"
value={cloudUrl}
onChange={e => setCloudUrl(e.target.value)}
placeholder="https://api.example.com"
/>
{selectedProvider === "custom" && (
<div className="mt-4 space-y-4">
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud API URL"
value={cloudApiUrl}
onChange={e => setCloudApiUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</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>
)}
</>
)}
{/*
We do the harcoding here to avoid flickering when the default Cloud URL being fetched.
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") && (
{/* Show security info for JetKVM Cloud */}
{selectedProvider === "jetkvm" && (
<GridCard>
<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" />
@ -295,7 +297,7 @@ export default function SettingsAccessIndexRoute() {
{!isAdopted ? (
<div className="flex items-end gap-x-2">
<Button
onClick={() => onCloudAdoptClick(cloudUrl)}
onClick={onCloudAdoptClick}
size="SM"
theme="primary"
text="Adopt KVM to Cloud"

View File

@ -1,5 +1,4 @@
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
export const DEVICE_API = "";

11
web.go
View File

@ -100,6 +100,7 @@ func setupRouter() *gin.Engine {
{
protected.POST("/webrtc/session", handleWebRTCSession)
protected.POST("/cloud/register", handleCloudRegister)
protected.GET("/cloud/state", handleCloudState)
protected.GET("/device", handleDevice)
protected.POST("/auth/logout", handleLogout)
@ -359,6 +360,16 @@ func handleDeviceStatus(c *gin.Context) {
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) {
config, _ := json.Marshal(gin.H{
"CLOUD_API": config.CloudURL,