Compare commits

..

No commits in common. "3cd59266c23f34158e4ef1aa2c4922d568a74a11" and "81bf5fb6afc1229e22cad502774e7bb9ab8175d8" have entirely different histories.

15 changed files with 359 additions and 474 deletions

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DLinux</title> <title>Code containers</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,31 +0,0 @@
import { useEffect, useState } from "react";
import { Typography } from "@mui/material";
import { InfoData } from "../types";
export default function ContainerInfo() {
const [info, setInfo] = useState<InfoData>();
useEffect(() => {
const updateInfo = async () => {
const stats = await fetch(`https://api.ssh.surf/info`, {
// @ts-ignore
headers: { "x-ssh-auth": localStorage.getItem("key") },
});
const { data } = await stats.json();
setInfo(data);
};
updateInfo();
}, []);
if (!info) {
return "Fetching container info...";
}
return (
<>
<Typography>Hostname: {info.name}</Typography>
<Typography>Memory: {info.memory}</Typography>
</>
);
}

View File

@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
import { ContainerStats as ContainerStatsType } from "dockerode";
import { Typography } from "@mui/material";
export default function ContainerStats({ id }: { id: string }) {
const [stats, setStats] = useState<ContainerStatsType>();
useEffect(() => {
const sse = new EventSource(`/api/containers/${id}/stats`);
sse.onmessage = ({ data }) => {
const parsed = JSON.parse(data);
setStats(parsed);
};
return () => {
sse.close();
};
}, []);
if (!stats) {
return "Fetching container stats...";
}
const CPUPercentage =
((stats.cpu_stats.cpu_usage.total_usage -
stats.precpu_stats.cpu_usage.total_usage) /
(stats.cpu_stats.system_cpu_usage -
stats.precpu_stats.system_cpu_usage)) *
stats.cpu_stats.online_cpus *
100;
return (
<Typography>CPU Usage: {(CPUPercentage || 0).toFixed(2) + "%"}</Typography>
);
}

View File

@ -1,42 +0,0 @@
import { Button, Paper, TextField } from "@mui/material";
import { FormEvent } from "react";
export default function Login() {
async function onSubmit(e: FormEvent) {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const data = Object.fromEntries(formData);
const hello = await fetch(`https://api.ssh.surf/hello`, {
// @ts-ignore
headers: { "x-ssh-auth": data.key },
});
const json = await hello.json();
if (json.message.startsWith("Hello")) {
localStorage.setItem("key", data.key.toString());
localStorage.setItem("hostname", json.message.slice(7).replace("!", ""));
location.reload();
}
}
return (
<Paper
component="form"
autoComplete="off"
onSubmit={onSubmit}
sx={{
display: "flex",
flexDirection: "column",
margin: "auto",
width: 300,
height: 95,
}}
>
<TextField label="API Key" type="password" name="key" variant="filled" />
<Button type="submit">Log in</Button>
</Paper>
);
}

View File

@ -1,66 +0,0 @@
import {
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import { useEffect, useState } from "react";
import { Process } from "../types";
export default function ProcessesTable({ container }: { container: string }) {
const [processes, setProcesses] = useState<Process[]>([]);
useEffect(() => {
const updateProcesses = async () => {
const processesForTable = [];
const apiCall = await fetch(
`https://process-list.syscall.lol/${container}`,
);
const processes = await apiCall.json();
for (const process of processes) {
const proc: Process = {
pid: process[1],
user: process[0],
command: process[7],
};
processesForTable.push(proc);
}
setProcesses(processesForTable);
};
updateProcesses();
setInterval(updateProcesses, 3000);
}, [container]);
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>PID</TableCell>
<TableCell>User</TableCell>
<TableCell>Command</TableCell>
</TableRow>
</TableHead>
<TableBody>
{processes.map((process) => (
<TableRow key={process.pid}>
<TableCell component="th" scope="row">
{process.pid}
</TableCell>
<TableCell>{process.user}</TableCell>
<TableCell>{process.command}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
}

View File

@ -4,61 +4,59 @@ import { AttachAddon } from "@xterm/addon-attach";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css"; import "@xterm/xterm/css/xterm.css";
import { useLoaderData } from "react-router"; import { useLoaderData } from "react-router";
import { InfoData } from "../types"; import { Container } from "../types";
export default function WebTerminal() { export default function WebTerminal() {
let container = useLoaderData(); const container = useLoaderData() as Container;
container = container.data as InfoData; const terminalRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<HTMLDivElement>(null); useEffect(() => {
document.title = `Terminal: ${container.name}`;
useEffect(() => { if (terminalRef.current) {
document.title = `Terminal: ${container.name}`; const terminal = new Terminal({ rows: 67 });
if (terminalRef.current) { const socket = new WebSocket(
const terminal = new Terminal({ rows: 67 }); `ws://127.0.0.1:3000/containers/${container.id}/terminal`,
);
const socket = new WebSocket( const fitAddon = new FitAddon();
`ws://127.0.0.1:3000/containers/${container.id}/terminal`, // TODO: Replace this with dlinux ssh connector terminal.loadAddon(fitAddon);
);
const fitAddon = new FitAddon(); terminal.open(terminalRef.current);
terminal.loadAddon(fitAddon); fitAddon.fit();
terminal.open(terminalRef.current); terminal.write("Connecting to the container\r\n");
fitAddon.fit();
terminal.write("Connecting to the container\r\n"); socket.onopen = () => {
const attachAddon = new AttachAddon(socket);
terminal.loadAddon(attachAddon);
socket.onopen = () => { terminal.clear();
const attachAddon = new AttachAddon(socket); terminal.focus();
terminal.loadAddon(attachAddon);
terminal.clear(); socket.send(
terminal.focus(); JSON.stringify({
rows: terminal.rows,
cols: terminal.cols,
}),
);
};
socket.send( window.addEventListener("resize", () => {
JSON.stringify({ fitAddon.fit();
rows: terminal.rows, });
cols: terminal.cols,
}),
);
};
window.addEventListener("resize", () => { terminal.onResize(({ rows, cols }) => {
fitAddon.fit(); socket.send(JSON.stringify({ rows, cols }));
}); });
terminal.onResize(({ rows, cols }) => { return () => {
socket.send(JSON.stringify({ rows, cols })); socket.close();
}); terminal.dispose();
};
}
}, []);
return () => { return <div ref={terminalRef} style={{ height: "auto" }} />;
socket.close();
terminal.dispose();
};
}
}, [container]);
return <div ref={terminalRef} style={{ height: "auto" }} />;
} }

View File

@ -3,46 +3,41 @@ import ReactDOM from "react-dom/client";
import App from "./pages/App.tsx"; import App from "./pages/App.tsx";
import "./index.css"; import "./index.css";
import { createBrowserRouter, RouterProvider } from "react-router"; import { createBrowserRouter, RouterProvider } from "react-router";
import ContainerPage from "./pages/container/index.tsx"; import Containers, {
import TerminalPage from "./pages/container/terminal.tsx"; Loader as ContainersLoader,
import ProcessesPage from "./pages/container/processes.tsx"; } from "./pages/containers/index.tsx";
import ContainerPage, {
async function loader() { Loader as ContainerDataLoader,
const info = await fetch(`https://api.ssh.surf/info`, { } from "./pages/containers/[name]/index.tsx";
// @ts-ignore import TerminalPage from "./pages/containers/[name]/terminal.tsx";
headers: { "x-ssh-auth": localStorage.getItem("key") },
});
const data = await info.json();
return data;
}
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
path: "/", path: "/",
element: <App />, element: <App />,
errorElement: <App error />, errorElement: <App error />,
children: [ children: [
{ {
path: "/container", path: "/containers",
element: <ContainerPage />, element: <Containers />,
loader, loader: ContainersLoader,
}, },
{ {
path: "/container/terminal", path: "/containers/:name",
element: <TerminalPage />, element: <ContainerPage />,
loader, loader: ContainerDataLoader,
}, },
{ {
path: "/container/processes", path: "/containers/:name/terminal",
element: <ProcessesPage />, element: <TerminalPage />,
}, loader: ContainerDataLoader,
], },
}, ],
},
]); ]);
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<RouterProvider router={router} /> <RouterProvider router={router} />
</React.StrictMode>, </React.StrictMode>,
); );

View File

@ -3,150 +3,103 @@ import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css"; import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css"; import "@fontsource/roboto/700.css";
import { import {
AppBar, AppBar,
Box, Box,
createTheme, Drawer,
Drawer, Icon,
FormControlLabel, IconButton,
Icon, List,
IconButton, ListItem,
List, ListItemButton,
ListItem, ListItemIcon,
ListItemButton, ListItemText,
ListItemIcon, Toolbar,
ListItemText, Typography,
Switch,
ThemeProvider,
Toolbar,
Tooltip,
Typography,
useColorScheme,
} from "@mui/material"; } from "@mui/material";
import * as Icons from "@mui/icons-material"; import * as Icons from "@mui/icons-material";
import { useState } from "react"; import { useState } from "react";
import { Link, Outlet } from "react-router"; import { Link, Outlet } from "react-router";
import ErrorPage from "./Error"; import ErrorPage from "./Error";
import Login from "../components/Login";
interface Item { interface Item {
title: string; title: string;
icon: keyof typeof Icons; icon: keyof typeof Icons;
href: string; href: string;
openInNewTab?: boolean;
} }
const sidebarItems: Item[] = [ const sidebarItems: Item[] = [
{ title: "Info", icon: "Info", href: "/container" }, { title: "Containers", icon: "Storage", href: "/containers" },
{ title: "Terminal", icon: "Terminal", href: "/container/terminal" },
{ title: "Processes", icon: "Memory", href: "/container/processes" },
]; ];
const drawerWidth = 240; const drawerWidth = 240;
const theme = createTheme({
colorSchemes: {
dark: true,
},
});
function App({ error }: { error?: boolean }) { function App({ error }: { error?: boolean }) {
const [isOpen, setOpen] = useState(false); const [isOpen, setOpen] = useState(false);
const { mode, setMode } = useColorScheme();
if (!mode) { return (
return null; <>
} <AppBar
position="fixed"
return ( sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}
<> >
<AppBar <Toolbar>
position="fixed" <IconButton
sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }} onClick={() => setOpen(!isOpen)}
> size="large"
<Toolbar> color="inherit"
<IconButton sx={{ mr: 2 }}
onClick={() => setOpen(!isOpen)} >
size="large" <Icons.Menu />
color="inherit" </IconButton>
sx={{ mr: 2 }} <Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
> Code containers
<Icons.Menu /> </Typography>
</IconButton> </Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}> </AppBar>
DLinux <Drawer
</Typography> variant="permanent"
<FormControlLabel sx={{
control={<Switch defaultChecked={mode === "system"} />} width: isOpen ? drawerWidth : 56,
label="Automatic theme" "& .MuiDrawer-paper": {
labelPlacement="start" width: isOpen ? drawerWidth : 56,
onChange={() => setMode(mode === "system" ? "light" : "system")} overflowX: "hidden",
/> transition: (theme) =>
{mode != "system" && ( theme.transitions.create("width", {
<IconButton easing: theme.transitions.easing.sharp,
onClick={() => setMode(mode === "dark" ? "light" : "dark")} duration: theme.transitions.duration.enteringScreen,
> }),
{mode === "dark" ? ( },
<Icons.LightMode /> }}
) : ( >
<Icons.DarkMode htmlColor="#fff" /> <Toolbar />
)} <List>
</IconButton> {sidebarItems.map((item) => (
)} <ListItem
</Toolbar> key={item.title}
</AppBar> component={Link}
<Drawer to={item.href}
variant="permanent" disablePadding
sx={{ >
width: isOpen ? drawerWidth : 56, <ListItemButton>
"& .MuiDrawer-paper": { <ListItemIcon>
width: isOpen ? drawerWidth : 56, <Icon component={Icons[item.icon]} />
overflowX: "hidden", </ListItemIcon>
transition: (theme) => <ListItemText primary={item.title} />
theme.transitions.create("width", { </ListItemButton>
easing: theme.transitions.easing.sharp, </ListItem>
duration: theme.transitions.duration.enteringScreen, ))}
}), </List>
}, </Drawer>
}} <Toolbar />
> <Box
<Toolbar /> component="main"
<List> sx={{ p: 2, marginLeft: isOpen ? "240px" : "56px" }}
{sidebarItems.map((item) => ( >
<Tooltip title={item.title} placement="left" arrow> {error && <ErrorPage />}
<ListItem <Outlet />
key={item.title} </Box>
component={Link} </>
to={item.href} );
target={item.openInNewTab ? "_blank" : "_self"}
disablePadding
>
<ListItemButton>
<ListItemIcon>
<Icon component={Icons[item.icon]} />
</ListItemIcon>
<ListItemText primary={item.title} />
</ListItemButton>
</ListItem>
</Tooltip>
))}
</List>
</Drawer>
<Toolbar />
<Box
component="main"
sx={{ p: 2, marginLeft: isOpen ? "240px" : "56px" }}
>
{error && <ErrorPage />}
{localStorage.getItem("key") ? <Outlet /> : <Login />}
</Box>
</>
);
} }
export default function ToggleColorMode() { export default App;
return (
<ThemeProvider theme={theme}>
<App />
</ThemeProvider>
);
}

View File

@ -1,61 +0,0 @@
import { useLoaderData, useRevalidator } from "react-router";
import { InfoData } from "../../types";
import { Button, ButtonGroup, Paper, Typography } from "@mui/material";
import { useState } from "react";
import ContainerInfo from "../../components/ContainerInfo";
export default function ContainerPage() {
let container = useLoaderData();
const revalidator = useRevalidator();
const [isPowerStateLocked, setPowerStateLocked] = useState<boolean>();
if (!container.data) {
return "Container not found";
}
container = container.data as InfoData;
return (
<Paper square sx={{ padding: 1 }}>
<Paper sx={{ display: "flex" }} variant="outlined">
<Typography variant="h6" sx={{ flexGrow: 1 }}>
State: {container.state.Status}
</Typography>
<ButtonGroup>
<Button
loading={isPowerStateLocked}
onClick={async () => {
await switchPowerState(
container.state.Status === "running" ? "stop" : "start",
);
}}
>
Power {container.state.Status === "running" ? "off" : "on"}
</Button>
<Button
onClick={async () => await switchPowerState("restart")}
loading={isPowerStateLocked}
disabled={container.state.Status === "exited"}
>
Restart
</Button>
</ButtonGroup>
</Paper>
<ContainerInfo />
</Paper>
);
async function switchPowerState(state: string) {
setPowerStateLocked(true);
const res = await fetch(`https://api.ssh.surf/${state}`, {
// @ts-ignore
headers: { "x-ssh-auth": localStorage.getItem("key") },
});
if (res.ok) {
setPowerStateLocked(false);
revalidator.revalidate();
}
}
}

View File

@ -1,6 +0,0 @@
import ProcessesTable from "../../components/ProcessesTable";
export default function ProcessesPage() {
const containerName = localStorage["hostname"];
return <ProcessesTable container={containerName} />;
}

View File

@ -1,25 +0,0 @@
import { useLoaderData } from "react-router";
import { InfoData } from "../../types";
import { Paper, Typography } from "@mui/material";
import WebTerminal from "../../components/WebTerminal";
export default function TerminalPage() {
let container = useLoaderData();
if (!container.data) {
return "Container not found";
}
container = container.data as InfoData;
return (
<Paper square sx={{ padding: 1 }}>
<Paper sx={{ display: "flex" }} variant="outlined">
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Terminal: {container.name}
</Typography>
</Paper>
<WebTerminal />
</Paper>
);
}

View File

@ -0,0 +1,73 @@
import { useLoaderData, useRevalidator } from "react-router";
import { Container } from "../../../types";
import { Button, ButtonGroup, Paper, Typography } from "@mui/material";
import { useState } from "react";
import ContainerStats from "../../../components/ContainerStats";
interface Params {
name: string;
}
export async function Loader({ params }: { params: Params }) {
const container = await fetch(`/api/containers/${params.name}`);
const data = await container.json();
return data;
}
export default function ContainerPage() {
const container = useLoaderData() as Container & { statusCode: number };
if (container.statusCode === 404) {
return "Container not found";
}
const revalidator = useRevalidator();
const [isPowerStateLocked, setPowerStateLocked] = useState<boolean>();
return (
<Paper square sx={{ padding: 1 }}>
<Paper sx={{ display: "flex" }} variant="outlined">
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Managing: {container.name}
</Typography>
<ButtonGroup>
<Button href={`/containers/${container.name}/terminal`}>
Terminal
</Button>
<Button
loading={isPowerStateLocked}
onClick={async () => {
await switchPowerState(
container.status === "running" ? "stop" : "start",
);
}}
>
Power {container.status === "running" ? "off" : "on"}
</Button>
<Button
onClick={async () => await switchPowerState("restart")}
loading={isPowerStateLocked}
disabled={container.status === "exited"}
>
Restart
</Button>
</ButtonGroup>
</Paper>
<ContainerStats id={container.id} />
</Paper>
);
async function switchPowerState(state: string) {
setPowerStateLocked(true);
const res = await fetch(`/api/containers/${container.name}/${state}`, {
method: "PUT",
});
if (res.ok) {
setPowerStateLocked(false);
revalidator.revalidate();
}
}
}

View File

@ -0,0 +1,34 @@
import { useLoaderData } from "react-router";
import { Container } from "../../../types";
import { Paper, Typography } from "@mui/material";
import WebTerminal from "../../../components/WebTerminal";
interface Params {
name: string;
}
export async function Loader({ params }: { params: Params }) {
const container = await fetch(`/api/containers/${params.name}`);
const data = await container.json();
return data;
}
export default function TerminalPage() {
const container = useLoaderData() as Container & { statusCode: number };
if (container.statusCode === 404) {
return "Container not found";
}
return (
<Paper square sx={{ padding: 1 }}>
<Paper sx={{ display: "flex" }} variant="outlined">
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Terminal: {container.name}
</Typography>
</Paper>
<WebTerminal />
</Paper>
);
}

View File

@ -0,0 +1,55 @@
import { useLoaderData } from "react-router";
import { Container } from "../../types";
import {
Button,
Card,
CardActions,
CardContent,
CardHeader,
Grid2 as Grid,
} from "@mui/material";
export async function Loader() {
const containers = await fetch("/api/containers");
const data = await containers.json();
return data;
}
export default function Containers() {
const containers = useLoaderData() as Container[];
return (
<>
<Grid container spacing={2}>
{containers.map((container) => (
<Card key={container.id}>
<CardHeader
title={container.name}
sx={{
textOverflow: "elipsis",
overflow: "hidden",
whiteSpace: "nowrap",
}}
/>
<CardContent>
Image: {container.image}
<br />
Status: {container.status}
</CardContent>
<CardActions>
<Button href={`/containers/${container.name}`}>Manage</Button>
<Button
href={`//${container.name}.${document.location.host}`}
target="_blank"
disabled={container.status === "exited"}
>
Open container
</Button>
</CardActions>
</Card>
))}
</Grid>
</>
);
}

View File

@ -1,35 +1,7 @@
export interface Info { export interface Container {
success: boolean; id: string;
data: InfoData; name: string;
} image: string;
status: string;
export interface InfoData { ip: string;
name: string;
IPAddress: string;
MacAddress: string;
memory: string;
cpus: string;
restartPolicy: { Name: string; MaximumRetryCount: number };
restarts: number;
state: {
Status: string;
Running: boolean;
Paused: boolean;
Restarting: boolean;
OOMKilled: boolean;
Dead: boolean;
Pid: number;
ExitCode: number;
Error: string;
StartedAt: string;
FinishedAt: string;
};
created: string;
image: string;
}
export interface Process {
pid: number;
user: string;
command: string;
} }