Skip to content

Commit

Permalink
Merge pull request #88 from gnmyt/features/app-store
Browse files Browse the repository at this point in the history
📦 App Store
  • Loading branch information
gnmyt authored Sep 11, 2024
2 parents b85de17 + ef7e85e commit f6f0a94
Show file tree
Hide file tree
Showing 63 changed files with 2,197 additions and 94 deletions.
2 changes: 2 additions & 0 deletions client/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Root from "@/common/layouts/Root.jsx";
import Servers from "@/pages/Servers";
import "@/common/styles/main.sass";
import Settings from "@/pages/Settings";
import Apps from "@/pages/Apps";

export const GITHUB_URL = "https://github.com/gnmyt/Nexterm";

Expand All @@ -20,6 +21,7 @@ const App = () => {
{ path: "/", element: <Navigate to="/servers" /> },
{ path: "/servers", element: <Servers /> },
{ path: "/settings/*", element: <Settings/> },
{ path: "/apps/*", element: <Apps /> }
],
},
]);
Expand Down
6 changes: 4 additions & 2 deletions client/src/common/components/Button/Button.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import "./styles.sass";
import Icon from "@mdi/react";

export const Button = ({onClick, text}) => {
export const Button = ({onClick, text, icon, disabled}) => {
return (
<button className="btn" onClick={onClick}>
<button className="btn" onClick={onClick} disabled={disabled}>
{icon ? <Icon path={icon} /> : null}
<h3>{text}</h3>
</button>
);
Expand Down
11 changes: 10 additions & 1 deletion client/src/common/components/Button/styles.sass
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
display: flex
align-items: center
justify-content: center
gap: 0.5rem
background-color: $primary-opacity
border: 1px solid $gray
color: $white
Expand All @@ -12,6 +13,10 @@
cursor: pointer
transition: all 0.2s

svg
width: 1.1rem
height: 1.1rem

h3
margin: 0
font-size: 1rem
Expand All @@ -22,4 +27,8 @@
filter: brightness(0.8)

&:active
transform: scale(0.97)
transform: scale(0.97)

&:disabled
background-color: $gray
cursor: not-allowed
5 changes: 3 additions & 2 deletions client/src/common/components/Sidebar/Sidebar.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "./styles.sass";
import NextermLogo from "@/common/img/logo.png";
import { mdiCog, mdiServerOutline } from "@mdi/js";
import { mdiCog, mdiPackageVariant, mdiServerOutline } from "@mdi/js";
import Icon from "@mdi/react";
import { Link, useLocation } from "react-router-dom";

Expand All @@ -10,7 +10,8 @@ export const Sidebar = () => {

const navigation = [
{ title: "Settings", path: "/settings", icon: mdiCog },
{ title: "Servers", path: "/servers", icon: mdiServerOutline }
{ title: "Servers", path: "/servers", icon: mdiServerOutline },
{ title: "Apps", path: "/apps", icon: mdiPackageVariant },
];

const isActive = (path) => {
Expand Down
10 changes: 9 additions & 1 deletion client/src/common/contexts/ServerContext.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ export const ServerProvider = ({ children }) => {
}
}

const retrieveServerById = async (serverId) => {
try {
return await getRequest(`/servers/${serverId}`);
} catch (error) {
console.error("Failed to retrieve server", error.message);
}
}

const getPVEServerById = (serverId, entries) => {
if (!entries) entries = servers;
for (const server of entries) {
Expand Down Expand Up @@ -77,7 +85,7 @@ export const ServerProvider = ({ children }) => {
}, [user]);

return (
<ServerContext.Provider value={{servers, loadServers, getServerById, getPVEServerById, getPVEContainerById}}>
<ServerContext.Provider value={{servers, loadServers, getServerById, getPVEServerById, getPVEContainerById, retrieveServerById}}>
{children}
</ServerContext.Provider>
)
Expand Down
117 changes: 117 additions & 0 deletions client/src/pages/Apps/Apps.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import "./styles.sass";
import { AppNavigation } from "@/pages/Apps/components/AppNavigation";
import StoreHeader from "@/pages/Apps/components/StoreHeader";
import AppItem from "@/pages/Apps/components/AppItem";
import { useEffect, useState } from "react";
import { getRequest } from "@/common/utils/RequestUtil.js";
import { mdiPackageVariant, mdiSignCaution } from "@mdi/js";
import Icon from "@mdi/react";
import AppInstaller from "@/pages/Apps/components/AppInstaller";
import { useLocation, useNavigate } from "react-router-dom";
import DeployServerDialog from "@/pages/Apps/components/DeployServerDialog";
import SourceDialog from "@/pages/Apps/components/SourceDialog";

export const Apps = () => {
const location = useLocation();
const navigate = useNavigate();

const [sourceDialogOpen, setSourceDialogOpen] = useState(false);

const [serverDialogOpen, setServerDialogOpen] = useState(false);
const [deployAppId, setDeployAppId] = useState(null);
const [serverId, setServerId] = useState(null);

const [installing, setInstalling] = useState(false);
const [selectedApp, setSelectedApp] = useState(null);
const [apps, setApps] = useState([]);

const [search, setSearch] = useState("");

const getCategory = () => {
const endPath = location.pathname.split("/").pop();
if (endPath === "apps") return null;

return endPath;
}

const updateSelectedApp = (id) => {
setSelectedApp(apps.find((app) => app.id === id));
}

const reloadList = () => {
if (search) {
getRequest("/apps?search=" + search).then((response) => {
setApps(response);
});
return;
}

const category = getCategory();

if (category) {
getRequest("/apps?category=" + category).then((response) => {
setApps(response);
});
return;
}

getRequest("/apps").then((response) => {
setApps(response);
});
};

useEffect(() => {
if (search !== "" && location.pathname !== "/apps/") {
navigate("/apps/");
return;
}
reloadList();
}, [search, location]);

const deployApp = (id) => {
setDeployAppId(id);
setServerDialogOpen(true);
}

const startDeployment = (serverId) => {
setServerId(serverId);
updateSelectedApp(deployAppId);
setDeployAppId(null);
}

return (
<div className="apps-page">
<AppNavigation search={search} setSearch={setSearch} />
<SourceDialog open={sourceDialogOpen} onClose={() => setSourceDialogOpen(false)} refreshApps={reloadList}/>
<DeployServerDialog open={serverDialogOpen} onClose={() => setServerDialogOpen(false)}
onDeploy={(serverId) => startDeployment(serverId)} app={apps.find((app) => app.id === deployAppId)} />
<div className="app-content">
<StoreHeader onSourceClick={() => setSourceDialogOpen(true)} />

<div className="app-grid">
<div className="app-list">
{apps.map((app) => {
return <AppItem key={app.id} icon={app.icon} id={app.id} description={app.description} installing={installing}
title={app.name} version={app.version} onClick={() => deployApp(app.id)} />
})}
{apps.length === 0 && <div className="no-apps">
<Icon path={mdiSignCaution} />
<h2>More apps coming soon</h2>
</div>
}
</div>

<div className="app-details">
{selectedApp !== null && <AppInstaller serverId={serverId} app={selectedApp} setInstalling={setInstalling} />}
{selectedApp === null && <div className="select-app">
<Icon path={mdiPackageVariant} />
<h3>Select app to continue</h3>
</div>}
</div>
</div>
</div>


</div>
);
};
164 changes: 164 additions & 0 deletions client/src/pages/Apps/components/AppInstaller/AppInstaller.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import Button from "@/common/components/Button";
import { mdiConsoleLine, mdiOpenInNew } from "@mdi/js";
import InstallStep from "@/pages/Apps/components/AppInstaller/components/InstallStep";
import "./styles.sass";
import { useContext, useEffect, useState } from "react";
import { UserContext } from "@/common/contexts/UserContext.jsx";
import DebianImage from "./os_images/debian.png";
import UbuntuImage from "./os_images/ubuntu.png";
import LinuxImage from "./os_images/linux.png";
import LogDialog from "@/pages/Apps/components/AppInstaller/components/LogDialog";
import { ServerContext } from "@/common/contexts/ServerContext.jsx";

export const AppInstaller = ({ serverId, app, setInstalling }) => {

const { retrieveServerById } = useContext(ServerContext);
const { sessionToken } = useContext(UserContext);

const [logOpen, setLogOpen] = useState(false);
const [logContent, setLogContent] = useState("");

const steps = ["Look up Linux distro", "Check permissions", "Install Docker Engine", "Download base image",
"Run pre-install command", "Start Docker container", "Run post-install command"];

const [foundOS, setFoundOS] = useState(null);
const [osImage, setOSImage] = useState(null);
const [currentStep, setCurrentStep] = useState(1);
const [failedStep, setFailedStep] = useState(null);
const [currentProgress, setCurrentProgress] = useState(null);

const loadImage = (os) => {
if (os === "debian") {
setOSImage(DebianImage);
return;
}

if (os === "ubuntu") {
setOSImage(UbuntuImage);
return;
}

setOSImage(LinuxImage);
};

const installApp = () => {
const protocol = location.protocol === "https:" ? "wss" : "ws";

const url = process.env.NODE_ENV === "production" ? `${window.location.host}/api/apps/installer` : "localhost:6989/api/apps/installer";
const ws = new WebSocket(`${protocol}://${url}?sessionToken=${sessionToken}&serverId=${serverId}&appId=${app?.id}`);

ws.onmessage = (event) => {
const data = event.data.toString();
const type = data.substring(0, 1);
const message = data.substring(1);

if (type === "\x01") {
setLogContent(logContent => logContent + message + "\n");
} else if (type === "\x02") {
let step = parseInt(message.split(",")[0]);

if (step === 1) {
let os = message.split(",")[1];
let osVersion = message.split(",")[2];
loadImage(os.toLowerCase());
setFoundOS(`${os} ${osVersion}`);
}

if ((step === 4 && !app.preInstallCommand) || (step === 7 && !app.postInstallCommand))
step++;

setLogContent(logContent => logContent + "Step " + step + " completed\n");

setCurrentStep(step + 1);
} else if (type === "\x03") {
setCurrentStep(currentStep => {
setFailedStep(currentStep);
setLogContent(logContent => logContent + "Step " + currentStep + " failed: " + message + "\n");
return currentStep;
});
} else if (type === "\x04") {
setCurrentProgress(parseInt(message));
}
};

ws.onclose = () => {
setLogContent(logContent => logContent + "Installation finished\n");
setInstalling(false);
};
};

const getTypeByIndex = (index) => {
if (isSkip(index)) return "skip";
if (index === failedStep - 1) return "error";
if (failedStep !== null && index > failedStep - 1) return "skip";

if (index === 0 && foundOS) return "image";

if (index < currentStep - 1) return "success";

if (index === 3 && currentProgress !== null) return "progress";

if (index === currentStep - 1) return "loading";

return "soon";
};

const isSkip = (index) => {
if (index === 4 && !app.preInstallCommand) return true;
if (index === 6 && !app.postInstallCommand) return true;
};

useEffect(() => {
setCurrentStep(1);
setFailedStep(null);
setCurrentProgress(null);
setFoundOS(null);
setOSImage(null);
setLogContent("");

setInstalling(true);

let timer = setTimeout(() => {
installApp();
}, 1000);

return () => {
clearTimeout(timer);
setInstalling(false);
};
}, [app]);

const openApp = async () => {
const server = await retrieveServerById(serverId);
window.open(`http://${server.ip}:${app.port}`);
};

return (
<div className="app-installer">
<LogDialog open={logOpen} onClose={() => setLogOpen(false)} content={logContent} />
<div className="install-header">
<div className="app-img">
<img src={app.icon} alt={app.name} />
</div>
<div className="install-info">
<h2>{app.name}</h2>
<p>{failedStep ? "Deployment failed" :
currentStep === steps.length ? "Deployment completed" : "Deploying..."}</p>
</div>
</div>

<div className="install-progress">
{steps.map((step, index) => {
return <InstallStep key={index} progressValue={currentProgress} imgContent={osImage}
type={getTypeByIndex(index)}
text={index === 0 && foundOS ? `Detected ${foundOS}` : step} />;
})}
</div>

<div className="install-actions">
<Button text="Logs" icon={mdiConsoleLine} onClick={() => setLogOpen(true)} />
{currentStep === steps.length && <Button text="Open" icon={mdiOpenInNew} onClick={openApp} />}
</div>
</div>
);
};
Loading

0 comments on commit f6f0a94

Please sign in to comment.