feat: multi tab session

This commit is contained in:
Khairul Hidayat 2024-11-06 14:53:07 +07:00
parent 86eb5eb4e6
commit 5b37d7bae5
12 changed files with 182 additions and 7 deletions

1
frontend/.env.example Normal file
View File

@ -0,0 +1 @@
EXPO_PUBLIC_API_URL=http://localhost:3000

4
frontend/.gitignore vendored
View File

@ -17,4 +17,6 @@ web-build/
# The following patterns were generated by expo-cli
expo-env.d.ts
# @end expo-cli
# @end expo-cli
.env*
!.env.example

View File

@ -1,14 +1,66 @@
import { View, Text } from "react-native";
import React from "react";
import { View, Text, ScrollView, Button } from "react-native";
import React, { useState } from "react";
import { Stack } from "expo-router";
import Terminal from "@/components/containers/terminal";
import InteractiveSession from "@/components/containers/interactive-session";
import PagerView from "@/components/ui/pager-view";
let nextSession = 1;
const HomePage = () => {
const [sessions, setSessions] = useState<string[]>(["1"]);
const [curSession, setSession] = useState(0);
return (
<View style={{ flex: 1 }}>
<Stack.Screen options={{ title: "Home" }} />
<Terminal wsUrl="ws://10.0.0.100:3000/ws" />
<ScrollView
horizontal
style={{ flexGrow: 0 }}
contentContainerStyle={{ flexDirection: "row", gap: 8 }}
>
{sessions.map((session, idx) => (
<View
key={session}
style={{ flexDirection: "row", alignItems: "center" }}
>
<Button
title={"Session " + session}
color="#222"
onPress={() => setSession(idx)}
/>
<Button
title="X"
onPress={() => {
const newSessions = sessions.filter((s) => s !== session);
setSessions(newSessions);
setSession(
Math.min(Math.max(curSession, 0), newSessions.length - 1)
);
}}
/>
</View>
))}
<Button
title="[ + ]"
onPress={() => {
nextSession += 1;
setSessions([...sessions, nextSession.toString()]);
setSession(sessions.length);
}}
/>
</ScrollView>
<PagerView style={{ flex: 1 }} page={curSession}>
{sessions.map((session) => (
<InteractiveSession
key={session}
type="ssh"
options={{ serverId: session }}
/>
))}
</PagerView>
</View>
);
};

View File

@ -0,0 +1,31 @@
import { View, Text } from "react-native";
import React from "react";
import Terminal from "./terminal";
import { BASE_WS_URL } from "@/lib/api";
type SSHSessionProps = {
type: "ssh";
options: {
serverId: string;
};
};
type Props = SSHSessionProps;
const InteractiveSession = ({ type, options }: Props) => {
switch (type) {
case "ssh":
const params = new URLSearchParams({
serverId: options.serverId,
token: "token",
});
return <Terminal wsUrl={BASE_WS_URL + "/ws/ssh?" + params} />;
default:
throw new Error("Unknown interactive session type");
}
return null;
};
export default InteractiveSession;

View File

@ -69,10 +69,13 @@ const XTermJs = forwardRef<XTermRef, XTermJsProps>((props, ref) => {
}
function onOpen() {
console.log("WS Open");
resizeTerminal();
}
function onClose(e: CloseEvent) {
console.log("WS Closed", e.reason, e.code);
// Check if the close event was abnormal
if (!e.wasClean) {
const reason = e.reason || `Code: ${e.code}`;

View File

@ -0,0 +1,27 @@
import React, { ComponentPropsWithoutRef, useEffect, useRef } from "react";
import RNPagerView from "react-native-pager-view";
export type PagerViewProps = ComponentPropsWithoutRef<typeof RNPagerView> & {
page?: number;
onChangePage?: (page: number) => void;
};
const PagerView = ({ page, onChangePage, ...props }: PagerViewProps) => {
const ref = useRef<RNPagerView>(null);
useEffect(() => {
if (page != null) {
ref.current?.setPage(page);
}
}, [page]);
return (
<RNPagerView
ref={ref}
{...props}
onPageSelected={(e) => onChangePage?.(e.nativeEvent.position)}
/>
);
};
export default PagerView;

View File

@ -0,0 +1,39 @@
import React, { useEffect, useMemo, useState } from "react";
import { View } from "react-native";
import { PagerViewProps } from "./pager-view";
const PagerView = ({
className,
children,
page,
initialPage,
}: PagerViewProps) => {
const [curPage, setPage] = useState<number>(page || initialPage || 0);
useEffect(() => {
if (page != null) {
setPage(page);
}
}, [page]);
const content = useMemo(() => {
if (!Array.isArray(children)) {
return null;
}
return children.map((element, index) => {
return (
<View
key={element.key || index}
style={{ display: index === curPage ? "flex" : "none", flex: 1 }}
>
{element}
</View>
);
});
}, [curPage, children]);
return content;
};
export default PagerView;

2
frontend/lib/api.ts Normal file
View File

@ -0,0 +1,2 @@
export const BASE_API_URL = process.env.EXPO_PUBLIC_API_URL || ""; //"http://10.0.0.100:3000";
export const BASE_WS_URL = BASE_API_URL.replace("http", "ws");

View File

@ -38,6 +38,7 @@
"react-dom": "18.3.1",
"react-native": "0.76.1",
"react-native-gesture-handler": "~2.20.2",
"react-native-pager-view": "6.4.1",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "4.0.0-beta.16",

View File

@ -74,6 +74,9 @@ importers:
react-native-gesture-handler:
specifier: ~2.20.2
version: 2.20.2(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)
react-native-pager-view:
specifier: 6.4.1
version: 6.4.1(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)
react-native-reanimated:
specifier: ~3.16.1
version: 3.16.1(@babel/core@7.26.0)(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1)
@ -3667,6 +3670,12 @@ packages:
peerDependencies:
react: ^16.6.0 || ^17.0.0 || ^18.0.0
react-native-pager-view@6.4.1:
resolution: {integrity: sha512-HnDxXTRHnR6WJ/vnOitv0C32KG9MJjxLnxswuQlBJmQ7RxF2GWOHSPIRAdZ9fLxdLstV38z9Oz1C95+t+yXkcg==}
peerDependencies:
react: '*'
react-native: '*'
react-native-reanimated@3.16.1:
resolution: {integrity: sha512-Wnbo7toHZ6kPLAD8JWKoKCTfNoqYOMW5vUEP76Rr4RBmJCrdXj6oauYP0aZnZq8NCbiP5bwwu7+RECcWtoetnQ==}
peerDependencies:
@ -9141,6 +9150,11 @@ snapshots:
react-fast-compare: 3.2.2
shallowequal: 1.1.0
react-native-pager-view@6.4.1(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
react-native: 0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1)
react-native-reanimated@3.16.1(@babel/core@7.26.0)(react-native@0.76.1(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.3.12)(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/core': 7.26.0

1
server/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
tmp/

View File

@ -121,10 +121,12 @@ func sshWebSocketHandler(w http.ResponseWriter, r *http.Request) {
// Handle WebSocket to SSH data streaming
go func() {
defer session.Close()
for {
_, msg, err := wsConn.Read(context.Background())
if err != nil {
return
break
}
if strings.HasPrefix(string(msg), "\x01") {
@ -153,7 +155,7 @@ func sshWebSocketHandler(w http.ResponseWriter, r *http.Request) {
}
func main() {
http.HandleFunc("/ws", sshWebSocketHandler)
http.HandleFunc("/ws/ssh", sshWebSocketHandler)
log.Println("Server started on :3000")
log.Fatal(http.ListenAndServe(":3000", nil))
}