Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cloud.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
2 changes: 2 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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,
Expand Down
31 changes: 4 additions & 27 deletions jsonrpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -753,37 +753,17 @@ 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) error {
config.CloudURL = apiUrl
config.CloudAppURL = appUrl

if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", 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
}

var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID},
Expand Down Expand Up @@ -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"}},
}
2 changes: 0 additions & 2 deletions ui/.env.device

This file was deleted.

2 changes: 1 addition & 1 deletion ui/src/components/USBConfigDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { InputFieldWithLabel } from "./InputField";
import { UsbConfigState } from "@/hooks/stores";
import { useEffect, useCallback, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { USBConfig } from "../routes/devices.$id.settings.hardware";
import { USBConfig } from "./UsbConfigSetting";

export default function UpdateUsbConfigModal({
onSetUsbConfig,
Expand Down
1 change: 0 additions & 1 deletion ui/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,6 @@ if (isOnDevice) {
},
],
},

{
path: "/adopt",
element: <AdoptRoute />,
Expand Down
29 changes: 21 additions & 8 deletions ui/src/routes/adopt.tsx
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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 (!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");

if (!res.ok) throw new Error("Failed to register device");
return redirect(CLOUD_APP + `/devices/${deviceId}/setup`);
return redirect(cloudState.appUrl + `/devices/${deviceId}/setup`);
};

export default function AdoptRoute() {
Expand Down
147 changes: 72 additions & 75 deletions ui/src/routes/devices.$id.settings.access._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand All @@ -36,38 +37,30 @@ 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("");
const [cloudAppUrl, setCloudAppUrl] = useState("");

// 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 isAPIJetKVMProd = cloudState.url === "https://api.jetkvm.com";
const isAppJetKVMProd = cloudState.appUrl === "https://app.jetkvm.com";

if (isAPIJetKVMProd && isAppJetKVMProd) {
setSelectedProvider("jetkvm");
} else {
setSelectedProvider("custom");
}
});
}, [send]);

Expand All @@ -88,42 +81,50 @@ export default function SettingsAccessIndexRoute() {
};

const onCloudAdoptClick = useCallback(
(url: string) => {
(cloudApiUrl: string, cloudAppUrl: string) => {
if (!deviceId) {
notifications.error("No device ID available");
return;
}

send("setCloudUrl", { url }, resp => {
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, 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");

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()}`;
cloudAppUrl +
"/signup?deviceId=" +
deviceId +
`&returnTo=${returnTo.toString()}`;
});
},
[deviceId, syncCloudUrl, send],
[deviceId, send],
);

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
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(() => {
getCloudState();

Expand All @@ -133,18 +134,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
Expand Down Expand Up @@ -219,34 +208,42 @@ 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={[
{ value: "jetkvm", label: "JetKVM Cloud" },
{ value: "custom", label: "Custom" },
]}
/>
</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" />
Expand Down Expand Up @@ -295,7 +292,7 @@ export default function SettingsAccessIndexRoute() {
{!isAdopted ? (
<div className="flex items-end gap-x-2">
<Button
onClick={() => onCloudAdoptClick(cloudUrl)}
onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)}
size="SM"
theme="primary"
text="Adopt KVM to Cloud"
Expand All @@ -305,7 +302,7 @@ export default function SettingsAccessIndexRoute() {
<div>
<div className="space-y-2">
<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>
<div>
<Button
Expand Down
1 change: 0 additions & 1 deletion ui/src/ui.config.ts
Original file line number Diff line number Diff line change
@@ -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 = "";
Loading