Initial commit

This commit is contained in:
niku 2023-03-20 22:37:20 +01:00
parent 25d5321778
commit e54129908d
24 changed files with 8516 additions and 6996 deletions

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"tabWidth": 2,
"useTabs": false
}

13
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"tailwindCSS.experimental.classRegex": [
"tw`([^`]*)",
"tw\\.style\\(([^)]*)\\)"
]
}

18
App.tsx
View File

@ -1,18 +0,0 @@
import { StyleSheet, Text, View } from 'react-native';
export default function App() {
return (
<View style={styles.container}>
<Text>Open up App.tsx to start working on your app!</Text>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});

View File

@ -1,36 +1,15 @@
# TypeScript Example
# Expo Router Example
<p>
<!-- iOS -->
<img alt="Supports Expo iOS" longdesc="Supports Expo iOS" src="https://img.shields.io/badge/iOS-4630EB.svg?style=flat-square&logo=APPLE&labelColor=999999&logoColor=fff" />
<!-- Android -->
<img alt="Supports Expo Android" longdesc="Supports Expo Android" src="https://img.shields.io/badge/Android-4630EB.svg?style=flat-square&logo=ANDROID&labelColor=A4C639&logoColor=fff" />
<!-- Web -->
<img alt="Supports Expo Web" longdesc="Supports Expo Web" src="https://img.shields.io/badge/web-4630EB.svg?style=flat-square&logo=GOOGLE-CHROME&labelColor=4285F4&logoColor=fff" />
</p>
```sh
npx create-react-native-app -t with-typescript
```
TypeScript is a superset of JavaScript which gives you static types and powerful tooling in Visual Studio Code including autocompletion and useful inline warnings for type errors.
Use [`expo-router`](https://expo.github.io/router) to build native navigation using files in the `app/` directory.
## 🚀 How to use
#### Creating a new project
- Install the CLI: `npm i -g expo-cli`
- Create a project: `npx create-react-native-app -t with-typescript`
- `cd` into the project
### Adding TypeScript to existing projects
- Create a blank TypeScript config: `touch tsconfig.json`
- Run `yarn start` or `npm run start` to automatically configure TypeScript
- Rename files to TypeScript, `.tsx` for React components and `.ts` for plain typescript files
> 💡 You can disable the TypeScript setup in Expo CLI with the environment variable `EXPO_NO_TYPESCRIPT_SETUP=1 expo start`
```sh
npx create-react-native-app -t with-router
```
## 📝 Notes
- [Expo TypeScript guide](https://docs.expo.dev/versions/latest/guides/typescript/)
- [Expo Router: Docs](https://expo.github.io/router)
- [Expo Router: Repo](https://github.com/expo/router)
- [Request for Comments](https://github.com/expo/router/discussions/1)

View File

@ -1,5 +1,9 @@
{
"expo": {
"scheme": "pvern",
"web": {
"bundler": "metro"
},
"name": "pvern",
"slug": "pvern"
}

29
app/_layout.tsx Normal file
View File

@ -0,0 +1,29 @@
import { Stack } from "expo-router";
import { QueryClient, QueryClientProvider } from "react-query";
import Auth from "../components/Login";
import useAuthStore from "../stores/useAuthStore";
import Icon from "@expo/vector-icons/Feather";
const queryClient = new QueryClient();
function LogoutButton() {
const logout = useAuthStore((state) => state.logout);
return <Icon name="log-out" size={22} onPress={logout} />;
}
export default function Layout() {
const { isActive } = useAuthStore();
return (
<QueryClientProvider client={queryClient}>
{isActive ? (
<Stack
screenOptions={{ headerRight: LogoutButton, headerTitle: "PVERN" }}
initialRouteName="/test"
/>
) : (
<Auth />
)}
</QueryClientProvider>
);
}

5
app/index.tsx Normal file
View File

@ -0,0 +1,5 @@
import { Redirect } from "expo-router";
export default function RootPage() {
return <Redirect href="/nodes" />;
}

207
app/nodes/[node]/index.tsx Normal file
View File

@ -0,0 +1,207 @@
import { Link, Stack, useSearchParams } from "expo-router";
import { View, Text, SafeAreaView, ScrollView } from "react-native";
import { NodeResource, useNode } from "../../../hooks/useNode";
import Icon from "@expo/vector-icons/Feather";
import tw from "twrnc";
import { formatBytes, formatPercentage } from "../../../lib/helper/format";
import { useEffect, useMemo } from "react";
interface ResourceListItemProps {
type: "LXC" | "QEMU";
resource: NodeResource;
}
export function ResourceListItem({ type, resource }: ResourceListItemProps) {
return (
<View style={tw.style("flex-row items-center")}>
<View
style={tw.style(
"h-12 w-12 rounded-lg flex flex-row justify-center items-center",
resource.status === "running" ? "bg-green-200" : "bg-slate-200"
)}
>
{type === "LXC" ? (
<Icon name="package" size={22} />
) : (
<Icon name="monitor" size={22} />
)}
</View>
<View style={tw.style("ml-2")}>
<Text style={tw.style("text-xl")}>
{resource.vmid}: {resource.name}
</Text>
<View style={tw.style("flex flex-row")}>
<Text style={tw.style("pr-2")}>CPUs: {resource.cpus}</Text>
<Text style={tw.style("pr-2")}>
MEM: {formatBytes(resource.maxmem)}
</Text>
<Text>DISK: {formatBytes(resource.maxdisk)}</Text>
</View>
</View>
</View>
);
}
interface ProgressBarProps {
label: string;
value: number;
max: number;
formatFn?: (input: number) => string;
}
export function ProgressBar({ label, value, max, formatFn }: ProgressBarProps) {
const percentage = formatPercentage(value / max);
return (
<View>
<View
style={tw.style("flex flex-row justify-between items-center mb-1 px-1")}
>
<Text>
{label} ({percentage})
</Text>
{formatFn && (
<Text>
{formatFn(value)}/{formatFn(max)}
</Text>
)}
</View>
<View style={tw.style("h-3 w-full bg-slate-300 rounded-lg")}>
<View
style={tw.style("h-full bg-slate-700 rounded-lg", {
width: percentage,
})}
/>
</View>
</View>
);
}
interface GaugeProps {
label: string;
value: string;
}
export function Gauge({ label, value }: GaugeProps) {
return (
<View>
<Text>{label}</Text>
<Text style={tw.style("text-2xl")}>{value}</Text>
</View>
);
}
export default function NodePage() {
const { node: nodeName } = useSearchParams<{ node: string }>();
const node = useNode(nodeName);
const sortedLXCs = useMemo(() => {
if (!node.lxc.isSuccess) return [];
return node.lxc.data.sort((a, b) => a.vmid - b.vmid);
}, [node.lxc.data]);
const sortedVMs = useMemo(() => {
if (!node.qemu.isSuccess) return [];
return node.qemu.data.sort((a, b) => a.vmid - b.vmid);
}, [node.qemu.data]);
return (
<View>
<SafeAreaView>
<ScrollView>
<View
style={tw.style(
"bg-white m-2 p-3 rounded-lg border border-slate-200"
)}
>
<Text style={tw.style("text-2xl font-semibold")}>{nodeName}</Text>
{node.status.isSuccess && (
<View style={tw.style("mb-4")}>
<Text>{node.status.data.pveversion}</Text>
<Text>{node.status.data.kversion}</Text>
</View>
)}
{node.rdd.isSuccess && (
<View>
<View style={tw.style("mb-2")}>
<ProgressBar label="CPU" value={node.rdd.data.cpu} max={1} />
</View>
<View style={tw.style("mb-2")}>
<ProgressBar
label="Memory"
value={node.rdd.data.memused}
max={node.rdd.data.memtotal}
formatFn={formatBytes}
/>
</View>
<View style={tw.style("mb-2")}>
<ProgressBar
label="Storage"
value={node.rdd.data.rootused}
max={node.rdd.data.roottotal}
formatFn={formatBytes}
/>
</View>
<View
style={tw.style(
"flex flex-row justify-evenly items-center p-2"
)}
>
<View style={tw.style("flex-1")}>
<Gauge
label="Up"
value={`${formatBytes(node.rdd.data.netout)}/s`}
/>
</View>
<View style={tw.style("flex-1")}>
<Gauge
label="Down"
value={`${formatBytes(node.rdd.data.netin)}/s`}
/>
</View>
</View>
</View>
)}
</View>
<Text style={tw.style("ml-6 mt-4")}>LXC Containers</Text>
<View
style={tw.style(
"bg-white m-2 p-1 rounded-lg border border-slate-200"
)}
>
{sortedLXCs.map((lxc) => (
<Link
key={lxc.vmid}
href={{
pathname: "/nodes/[name]/lxc/[vmid]",
params: { name: nodeName, vmid: lxc.vmid },
}}
style={tw.style("m-2")}
>
<ResourceListItem type="LXC" resource={lxc} />
</Link>
))}
</View>
<Text style={tw.style("ml-6 mt-4")}>Virtual Machines</Text>
<View
style={tw.style(
"bg-white m-2 p-1 rounded-lg border border-slate-200"
)}
>
{sortedVMs.map((vm) => (
<Link
key={vm.vmid}
href={{
pathname: "/nodes/[node]/lxc/[vmid]",
params: { node: nodeName, vmid: vm.vmid },
}}
style={tw.style("m-2")}
>
<ResourceListItem type="QEMU" resource={vm} />
</Link>
))}
</View>
</ScrollView>
</SafeAreaView>
</View>
);
}

View File

@ -0,0 +1,42 @@
import { useSearchParams } from "expo-router";
import { SafeAreaView } from "react-native";
import { WebView } from "react-native-webview";
import useAuthStore from "../../../../../stores/useAuthStore";
function buildConsoleUrl(domain: string, node: string, vmid: string) {
const url = new URL(domain);
url.searchParams.append("node", node);
url.searchParams.append("vmid", vmid);
url.searchParams.append("resize", "1");
url.searchParams.append("console", "lxc");
url.searchParams.append("xtermjs", "1");
return url.toString();
}
export default function QEMUResourceConsolePage() {
const { name, vmid } = useSearchParams<{ name: string; vmid: string }>();
const { domain, ticketData } = useAuthStore();
return (
<SafeAreaView style={{ flex: 1 }}>
<WebView
source={{
uri: buildConsoleUrl(domain, name, vmid),
headers: {
Cookie: `PVEAuthCookie=${ticketData.data.ticket}`,
CSRFPreventionToken: ticketData.data.CSRFPreventionToken,
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
},
}}
allowsFullscreenVideo={true}
scalesPageToFit={false}
injectedJavaScript={`
const meta = document.createElement('meta');
meta.setAttribute('content', 'width=width, initial-scale=0.5, maximum-scale=0.5, user-scalable=2.0');
meta.setAttribute('name', 'viewport');
document.getElementsByTagName('head')[0].appendChild(meta);
`}
/>
</SafeAreaView>
);
}

View File

@ -0,0 +1,23 @@
import { View, Text } from "react-native";
import tw from "twrnc";
export default function LXCResourcePage() {
return (
<View>
<View
style={tw.style("bg-white m-2 p-3 rounded-lg border border-slate-200")}
>
<Text>Currently not supported</Text>
</View>
<Text style={tw.style("ml-6 mt-4")}>Config</Text>
<View
style={tw.style("bg-white m-2 p-1 rounded-lg border border-slate-200")}
>
<Text style={tw.style("px-2 py-7 flex flex-row text-center")}>
Currently not supported
</Text>
</View>
</View>
);
}

80
app/nodes/index.tsx Normal file
View File

@ -0,0 +1,80 @@
import { Link } from "expo-router";
import { View, Text, SafeAreaView } from "react-native";
import { ProxmoxNode, useNodes } from "../../hooks/useNodes";
import Icon from "@expo/vector-icons/Feather";
import tw from "twrnc";
import { formatPercentage } from "../../lib/helper/format";
import { ScrollView } from "react-native-gesture-handler";
export function NodeListItem({ node }: { node: ProxmoxNode }) {
return (
<View style={tw.style("flex-row items-center")}>
<View
style={tw.style(
"h-12 w-12 rounded-lg flex flex-row justify-center items-center",
node.status === "online" ? "bg-green-200" : "bg-slate-200"
)}
>
{node.status !== "unknown" ? (
<Icon name="server" size={22} />
) : (
<Text style={tw.style("text-xl")}>?</Text>
)}
</View>
<View style={tw.style("ml-2")}>
<Text style={tw.style("text-xl")}>{node.node}</Text>
<View style={tw.style("flex flex-row")}>
<Text style={tw.style("pr-2")}>
CPU: {formatPercentage(node.cpu)}
</Text>
<Text>MEM: {formatPercentage(node.mem / node.maxmem)}</Text>
</View>
</View>
</View>
);
}
export default function HomePage() {
const nodes = useNodes();
return (
<View>
<ScrollView>
<SafeAreaView>
<Text style={tw.style("ml-6 mt-4")}>Nodes</Text>
<View
style={tw.style(
"bg-white m-2 p-1 rounded-lg border border-slate-200"
)}
>
{nodes.isSuccess &&
nodes.data.map((node) => (
<Link
key={node.node}
href={{
pathname: "/nodes/[node]",
params: { node: node.node },
}}
disabled={node.status !== "online"}
style={tw.style("p-2")}
>
<NodeListItem node={node} />
</Link>
))}
</View>
<Text style={tw.style("ml-6 mt-4")}>Storage</Text>
<View
style={tw.style(
"bg-white m-2 p-1 rounded-lg border border-slate-200"
)}
>
<Text style={tw.style("px-2 py-7 flex flex-row text-center")}>
Currently not supported
</Text>
</View>
</SafeAreaView>
</ScrollView>
</View>
);
}

View File

@ -1,6 +1,11 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
presets: ["babel-preset-expo"],
plugins: [
"@babel/plugin-proposal-export-namespace-from",
"react-native-reanimated/plugin",
require.resolve("expo-router/babel"),
],
};
};

View File

@ -0,0 +1,39 @@
import { View, Button, Text, TextInput } from "react-native";
import React, { useState } from "react";
import useAuthStore from "../../stores/useAuthStore";
import { useTicketMut } from "../../hooks/useTicket";
export default function Login() {
const [domain, setDomain] = useState("");
const [username, setUsername] = useState("root@pam");
const [password, setPassword] = useState("");
const authStore = useAuthStore();
const ticketMut = useTicketMut({
onSuccess: ({ data: ticketData }) => {
console.log({ ticketData });
authStore.update({ domain, username, ticketData });
},
});
return (
<View>
<Text>Hello</Text>
<TextInput value={domain} onChangeText={setDomain} placeholder="Domain" />
<TextInput
value={username}
onChangeText={setUsername}
placeholder="Username"
/>
<TextInput
value={password}
onChangeText={setPassword}
placeholder="Password"
/>
<Button
title="Login"
onPress={() => ticketMut.mutate({ domain, username, password })}
/>
</View>
);
}

130
hooks/useNode.ts Normal file
View File

@ -0,0 +1,130 @@
import { useQueries, useQuery } from "react-query";
import useAuthStore from "../stores/useAuthStore";
export type ResourceType = "LXC" | "QEMU";
export interface NodeResource {
status: "stopped" | "running";
vmid: number;
name: string;
tags: string;
cpus: number;
maxdisk: number;
maxmem: number;
}
export interface NodeStatus {
pveversion: string;
kversion: string;
}
export interface NodeRDD {
netin: number;
netout: number;
rootused: number;
roottotal: number;
memused: number;
memtotal: number;
cpu: number;
}
export function useNode(name: string) {
// const http = useAuthStore((state) => state.http);
// const [rdd, status, lxc, qemu] = useQueries([
// {
// queryKey: ["nodes", name, "rdd"],
// queryFn: () =>
// http.get<{ data: NodeRDD[] }>(`/api2/json/nodes/${name}/rrddata`, {
// params: {
// timeframe: "hour",
// },
// }),
// enabled: !!name,
// select: (data): NodeRDD => data.data.data.at(-1),
// },
// {
// queryKey: ["nodes", name, "status"],
// queryFn: () =>
// http.get<{ data: NodeStatus }>(`/api2/json/nodes/${name}/status`),
// enabled: !!name,
// select: (data): NodeStatus => data.data.data,
// },
// {
// queryKey: ["nodes", name, "lxc"],
// queryFn: () =>
// http.get<{ data: NodeResource[] }>(`/api2/json/nodes/${name}/lxc`),
// enabled: !!name,
// select: (data): NodeResource[] => data.data.data,
// },
// {
// queryKey: ["nodes", name, "qemu"],
// queryFn: () =>
// http.get<{ data: NodeResource[] }>(`/api2/json/nodes/${name}/qemu`),
// enabled: !!name,
// select: (data): NodeResource[] => data.data.data,
// },
// ]);
// return {
// rdd,
// status,
// lxc,
// qemu,
// };
return {
rdd: {
data: {
memused: 21691995750.4,
roottotal: 100861726720,
swaptotal: 8589930496,
swapused: 315621376,
rootused: 8778427323.73333,
time: 1679347500,
memtotal: 29306216448,
iowait: 0.00668312957886097,
netout: 41114.2883333333,
loadavg: 0.586166666666667,
cpu: 0.0151996855422636,
maxcpu: 12,
netin: 29321.46,
},
isSuccess: true,
},
lxc: {
data: [
{
vmid: 101,
cpus: 2,
maxdisk: 8350298112,
maxmem: 1073741824,
name: "kibana",
status: "running",
tags: "",
},
],
isSuccess: true,
},
qemu: {
data: [
{
vmid: 201,
cpus: 2,
maxdisk: 8350298112,
maxmem: 1073741824,
name: "vm",
status: "running",
tags: "",
},
],
isSuccess: true,
},
status: {
data: {
pveversion: "pve-manager/7.3-4/d69b70d4",
kversion:
"Linux 5.15.83-1-pve #1 SMP PVE 5.15.83-1 (2022-12-15T00:00Z)",
},
isSuccess: true,
},
};
}

43
hooks/useNodes.ts Normal file
View File

@ -0,0 +1,43 @@
import { useQuery } from "react-query";
import useAuthStore from "../stores/useAuthStore";
export interface ProxmoxNode {
node: string;
status: "unknown" | "online" | "offline";
cpu?: number;
mem?: number;
maxcpu?: number;
maxmem?: number;
uptime?: number;
}
interface GetNodeResp {
data: ProxmoxNode[];
}
export function useNodes() {
// const http = useAuthStore((state) => state.http);
return useQuery(
["nodes"],
async () => {
// return http.get<GetNodeResp>("/api2/json/nodes")
return {
data: {
data: [
{
cpu: 0.0166442953020134,
mem: 21713018880,
maxmem: 29306216448,
uptime: 4854322,
status: "online",
maxcpu: 12,
node: "pve",
},
] as ProxmoxNode[],
},
isSuccess: true,
};
},
{ select: (data) => data.data.data, refetchInterval: 6000 }
);
}

29
hooks/useTicket.ts Normal file
View File

@ -0,0 +1,29 @@
import axios from "axios";
import { useMutation, UseMutationOptions } from "react-query";
interface CreateTicketOpts {
domain: string;
username: string;
password: string;
}
export interface CreateTicketResp {
data: {
ticket: string;
CSRFPreventionToken: string;
};
}
export async function createTicket({
domain,
username,
password,
}: CreateTicketOpts) {
const url = `${domain}/api2/json/access/ticket`;
const headers = { "Content-Type": "application/json" };
return axios.post<CreateTicketResp>(url, { username, password }, { headers });
}
export function useTicketMut(options: UseMutationOptions) {
return useMutation({ mutationFn: createTicket, ...options });
}

1
index.ts Normal file
View File

@ -0,0 +1 @@
import "expo-router/entry";

10
lib/helper/format.ts Normal file
View File

@ -0,0 +1,10 @@
import prettyBytes from "pretty-bytes";
export function formatPercentage(num?: number) {
if (!num) return "N/A";
return num.toLocaleString("en", { style: "percent" });
}
export function formatBytes(num?: number) {
return prettyBytes(num ?? 1);
}

View File

@ -1,24 +1,53 @@
{
"dependencies": {
"expo": "^47.0.0",
"react": "18.1.0",
"react-dom": "18.1.0",
"react-native": "0.70.5",
"react-native-web": "~0.18.7"
},
"devDependencies": {
"@babel/core": "^7.19.3",
"@types/react": "~18.0.24",
"@types/react-native": "~0.70.6",
"typescript": "^4.6.3"
},
"name": "pvern",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
}
"web": "expo start --web",
"lint": "rome check .",
"format": "rome format . --write"
},
"dependencies": {
"@open-draft/until": "^2.0.0",
"@types/react": "~18.0.27",
"axios": "^1.3.4",
"expo": "^48.0.4",
"expo-constants": "~14.2.1",
"expo-linking": "~4.0.1",
"expo-router": "^1.2.0",
"expo-splash-screen": "~0.18.1",
"expo-status-bar": "~1.4.2",
"immer": "^9.0.19",
"pretty-bytes": "^6.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.43.7",
"react-native": "0.71.3",
"react-native-gesture-handler": "~2.9.0",
"react-native-reanimated": "~2.14.4",
"react-native-safe-area-context": "4.5.0",
"react-native-screens": "~3.20.0",
"react-native-web": "~0.18.7",
"react-native-webview": "11.26.0",
"react-query": "^3.39.3",
"twrnc": "^3.6.0",
"typescript": "^4.9.4",
"zustand": "^4.3.6"
},
"devDependencies": {
"@babel/core": "^7.19.3",
"@babel/plugin-proposal-export-namespace-from": "^7.18.9",
"rome": "^11.0.0"
},
"resolutions": {
"metro": "^0.73.7",
"metro-resolver": "^0.73.7"
},
"overrides": {
"metro": "^0.73.7",
"metro-resolver": "^0.73.7"
},
"name": "pvern",
"version": "1.0.0",
"private": true
}

6923
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

53
stores/useAuthStore.ts Normal file
View File

@ -0,0 +1,53 @@
import { create } from "zustand";
import produce from "immer";
import axios, { AxiosInstance } from "axios";
import { CreateTicketResp } from "../hooks/useTicket";
interface Profile {
username: string;
domain: string;
ticketData: CreateTicketResp;
}
interface AuthStoreState extends Profile {
http: AxiosInstance;
isActive: boolean;
logout: () => void;
update: (state: Profile) => void;
}
const useAuthStore = create<AuthStoreState>((set) => ({
username: "",
domain: "",
ticketData: { data: { CSRFPreventionToken: "", ticket: "" } },
http: axios.create(),
isActive: false,
logout: () =>
set(
produce((state: AuthStoreState) => {
state.domain = "";
state.username = "";
state.http = axios.create();
state.isActive = false;
state.ticketData = { data: { CSRFPreventionToken: "", ticket: "" } };
})
),
update: ({ domain, username, ticketData }: Profile) =>
set(
produce((state: AuthStoreState) => {
state.domain = domain;
state.username = username;
state.ticketData = ticketData;
state.http = axios.create({
baseURL: domain,
headers: {
CSRFPreventionToken: ticketData.data.CSRFPreventionToken,
cookie: `PVEAuthCookie=${ticketData.data.ticket}`,
},
});
state.isActive = true;
})
),
}));
export default useAuthStore;

8
tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -1,4 +1,6 @@
{
"compilerOptions": {},
"compilerOptions": {
"module": "ESNext"
},
"extends": "expo/tsconfig.base"
}

7726
yarn.lock Normal file

File diff suppressed because it is too large Load Diff