feat: add server list page

This commit is contained in:
2025-12-25 23:57:47 +01:00
parent e7fe00c48d
commit c07d6072fd
18 changed files with 2193 additions and 692 deletions

BIN
frontend/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

1
frontend/public/logo.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><ellipse cx="18" cy="26" fill="#99AAB5" rx="18" ry="10"/><ellipse cx="18" cy="24" fill="#CCD6DD" rx="18" ry="10"/><path fill="#F5F8FA" d="M18 31C3.042 31 1 16 1 12h34c0 2-1.958 19-17 19"/><path fill="#CCD6DD" d="M34.385 9.644c2.442-10.123-9.781-7.706-12.204-5.799A38 38 0 0 0 18 3.611c-9.389 0-17 3.229-17 8.444S8.611 21.5 18 21.5s17-4.229 17-9.444c0-.863-.226-1.664-.615-2.412m-2.503-2.692c-1.357-.938-3.102-1.694-5.121-2.25 1.875-.576 4.551-.309 5.121 2.25"/><ellipse cx="18" cy="13" fill="#8A4B38" rx="15" ry="7"/><path fill="#D99E82" d="M20 17a1 1 0 0 1-.707-.293c-2.337-2.337-2.376-4.885-.125-8.262.739-1.109.9-2.246.478-3.377-.461-1.236-1.438-1.996-1.731-2.077-.553 0-.958-.443-.958-.996S17.448 1 18 1c.997 0 2.395 1.153 3.183 2.625 1.034 1.933.91 4.039-.351 5.929-1.961 2.942-1.531 4.332-.125 5.738A.999.999 0 0 1 20 17m-6-2a1 1 0 0 1-.707-.293c-2.337-2.337-2.376-4.885-.125-8.262.727-1.091.893-2.083.494-2.947-.444-.961-1.431-1.469-1.684-1.499a.99.99 0 0 1-.989-1c0-.552.458-1 1.011-1 .997 0 2.585.974 3.36 2.423.481.899 1.052 2.761-.528 5.131-1.961 2.942-1.531 4.332-.125 5.738a1 1 0 0 1 0 1.414A1 1 0 0 1 14 15"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,21 +1,10 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"short_name": "CupControl",
"name": "Mange your Netcup Servers",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
"src": "logo.png",
"type": "image/png"
}
],
"start_url": ".",

View File

@@ -1,25 +1,14 @@
import * as React from "react";
import {
IconCamera,
IconChartBar,
IconDashboard,
IconDatabase,
IconFileAi,
IconFileDescription,
IconFileWord,
IconFolder,
IconHelp,
IconInnerShadowTop,
IconListDetails,
IconReport,
IconSearch,
IconSettings,
IconUsers,
IconServer,
} from "@tabler/icons-react";
import { NavDocuments } from "@/components/nav-documents";
import { NavMain } from "@/components/nav-main";
import { NavSecondary } from "@/components/nav-secondary";
import { NavUser } from "@/components/nav-user";
import {
Sidebar,
@@ -32,33 +21,19 @@ import {
} from "@/components/ui/sidebar";
import { useQuery } from "@tanstack/react-query";
import type { User } from "@/models/user";
import { Link } from "@tanstack/react-router";
const data = {
navMain: [
{
title: "Dashboard",
url: "#",
url: "/",
icon: IconDashboard,
},
{
title: "Lifecycle",
url: "#",
icon: IconListDetails,
},
{
title: "Analytics",
url: "#",
icon: IconChartBar,
},
{
title: "Projects",
url: "#",
icon: IconFolder,
},
{
title: "Team",
url: "#",
icon: IconUsers,
title: "Server List",
url: "/server_list",
icon: IconServer,
},
],
navClouds: [
@@ -109,40 +84,6 @@ const data = {
],
},
],
navSecondary: [
{
title: "Settings",
url: "#",
icon: IconSettings,
},
{
title: "Get Help",
url: "#",
icon: IconHelp,
},
{
title: "Search",
url: "#",
icon: IconSearch,
},
],
documents: [
{
name: "Data Library",
url: "#",
icon: IconDatabase,
},
{
name: "Reports",
url: "#",
icon: IconReport,
},
{
name: "Word Assistant",
url: "#",
icon: IconFileWord,
},
],
};
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
@@ -164,18 +105,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
asChild
className="data-[slot=sidebar-menu-button]:!p-1.5"
>
<a href="#">
<IconInnerShadowTop className="!size-5" />
<span className="text-base font-semibold">Acme Inc.</span>
</a>
<Link to="/">
<img
src="/logo.svg"
alt="CupControl Logo"
className="w-5 h-5 mr-2"
/>
<span className="text-base font-semibold">CupControl</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavDocuments items={data.documents} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser

View File

@@ -1,92 +0,0 @@
"use client"
import {
IconDots,
IconFolder,
IconShare3,
IconTrash,
type Icon,
} from "@tabler/icons-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarGroup,
SidebarGroupLabel,
SidebarMenu,
SidebarMenuAction,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar"
export function NavDocuments({
items,
}: {
items: {
name: string
url: string
icon: Icon
}[]
}) {
const { isMobile } = useSidebar()
return (
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
<SidebarGroupLabel>Documents</SidebarGroupLabel>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.name}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.name}</span>
</a>
</SidebarMenuButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuAction
showOnHover
className="data-[state=open]:bg-accent rounded-sm"
>
<IconDots />
<span className="sr-only">More</span>
</SidebarMenuAction>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-24 rounded-lg"
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<IconFolder />
<span>Open</span>
</DropdownMenuItem>
<DropdownMenuItem>
<IconShare3 />
<span>Share</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<IconTrash />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
))}
<SidebarMenuItem>
<SidebarMenuButton className="text-sidebar-foreground/70">
<IconDots className="text-sidebar-foreground/70" />
<span>More</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
)
}

View File

@@ -1,56 +1,48 @@
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react"
import { IconCirclePlusFilled, IconMail, type Icon } from "@tabler/icons-react";
import { Button } from "@/components/ui/button"
import { Button } from "@/components/ui/button";
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
} from "@/components/ui/sidebar";
import { Link, useLocation } from "@tanstack/react-router";
export function NavMain({
items,
}: {
items: {
title: string
url: string
icon?: Icon
}[]
title: string;
url: string;
icon?: Icon;
}[];
}) {
const location = useLocation();
return (
<SidebarGroup>
<SidebarGroupContent className="flex flex-col gap-2">
<SidebarMenu>
<SidebarMenuItem className="flex items-center gap-2">
<SidebarMenuButton
tooltip="Quick Create"
className="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
>
<IconCirclePlusFilled />
<span>Quick Create</span>
</SidebarMenuButton>
<Button
size="icon"
className="size-8 group-data-[collapsible=icon]:opacity-0"
variant="outline"
>
<IconMail />
<span className="sr-only">Inbox</span>
</Button>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton tooltip={item.title}>
<Link to={item.url}>
<SidebarMenuButton
tooltip={item.title}
className={
location.pathname === item.url
? "bg-pink-900 text-white"
: ""
}
>
{item.icon && <item.icon />}
<span>{item.title}</span>
</SidebarMenuButton>
</Link>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
);
}

View File

@@ -1,42 +0,0 @@
"use client"
import * as React from "react"
import { type Icon } from "@tabler/icons-react"
import {
SidebarGroup,
SidebarGroupContent,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function NavSecondary({
items,
...props
}: {
items: {
title: string
url: string
icon: Icon
}[]
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild>
<a href={item.url}>
<item.icon />
<span>{item.title}</span>
</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)
}

View File

@@ -1,13 +1,15 @@
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { ModeToggle } from "./mode-toggle";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useIsFetching, useQuery, useQueryClient } from "@tanstack/react-query";
import { Button } from "./ui/button";
import { useEffect, useState } from "react";
import { Spinner } from "./ui/spinner";
export function SiteHeader() {
const [oldLoginState, setOldLoginState] = useState(false);
const queryClient = useQueryClient();
const isFetching = useIsFetching();
const { data: pingResult } = useQuery({
queryKey: ["ping"],
@@ -44,6 +46,10 @@ export function SiteHeader() {
}
}
async function refresh() {
queryClient.invalidateQueries();
}
return (
<header className="flex h-(--header-height) shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height)">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
@@ -77,6 +83,10 @@ export function SiteHeader() {
/>
<h1 className="text-base font-medium">Documents</h1>
<div className="ml-auto flex items-center gap-2">
{isFetching ? <Spinner /> : null}
<Button variant="outline" size="sm" onClick={refresh}>
Refresh
</Button>
<ModeToggle />
</div>
</div>

View File

@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@@ -0,0 +1,15 @@
export type MinimalServers = MinimalServer[];
export interface MinimalServer {
id: number;
name: string;
hostname: string;
nickname: string;
disabled: boolean;
template: Template;
}
export interface Template {
id: number;
name: string;
}

View File

@@ -9,8 +9,14 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as Server_listRouteImport } from './routes/server_list'
import { Route as IndexRouteImport } from './routes/index'
const Server_listRoute = Server_listRouteImport.update({
id: '/server_list',
path: '/server_list',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -19,28 +25,39 @@ const IndexRoute = IndexRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/server_list': typeof Server_listRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/server_list': typeof Server_listRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/server_list': typeof Server_listRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/'
fullPaths: '/' | '/server_list'
fileRoutesByTo: FileRoutesByTo
to: '/'
id: '__root__' | '/'
to: '/' | '/server_list'
id: '__root__' | '/' | '/server_list'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
Server_listRoute: typeof Server_listRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/server_list': {
id: '/server_list'
path: '/server_list'
fullPath: '/server_list'
preLoaderRoute: typeof Server_listRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -53,6 +70,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
Server_listRoute: Server_listRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -0,0 +1,62 @@
import { Card } from "@/components/ui/card";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { MinimalServers } from "@/models/minimal_servers";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/server_list")({
component: RouteComponent,
});
function RouteComponent() {
const { data: servers } = useQuery({
queryKey: ["servers"],
queryFn: async () => {
let res = await fetch("/api/servers");
return (await res.json()) as MinimalServers;
},
});
return (
<div className="p-2 flex justify-center items-center w-full">
<Card className="w-full max-w-225 p-2">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]">Name</TableHead>
<TableHead>Hostname</TableHead>
<TableHead>Nickame</TableHead>
<TableHead className="text-right">Type/Template</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{servers?.map((server) => (
<TableRow key={server.id}>
<TableCell className="font-medium">{server.name}</TableCell>
<TableCell>{server.hostname}</TableCell>
<TableCell>{server.nickname}</TableCell>
<TableCell className="text-right">
{server.template.name}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3}>Total Servers</TableCell>
<TableCell className="text-right">{servers?.length}</TableCell>
</TableRow>
</TableFooter>
</Table>
</Card>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,8 @@
use std::{env, str::FromStr, sync::Mutex, time::Duration};
use sqlx::{
ConnectOptions, Sqlite, SqlitePool,
pool::PoolConnection,
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
SqlitePool,
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqliteSynchronous},
};
static DATABASE_POOL: Mutex<Option<SqlitePool>> = Mutex::new(None);

View File

@@ -19,14 +19,15 @@ pub async fn get_access_token() -> Result<String, Box<dyn Error>> {
let pool = db::get_pool().await?;
let tok = query!("SELECT * FROM tokens WHERE is_refresh = 0 ORDER BY expires_at DESC LIMIT 1")
.fetch_one(&pool)
.await?
.token;
.await?;
Ok(tok)
dbg!(&tok);
Ok(tok.token)
}
pub async fn get_authed_api_config() -> Result<configuration::Configuration, Box<dyn Error>> {
let mut conf = configuration::Configuration::default();
conf.bearer_access_token = Some(get_access_token().await?);
Ok(conf)
}

View File

@@ -8,11 +8,13 @@ use crate::{auth, db};
pub fn init_scheduler(rt: Handle) -> Scheduler {
let scheduler = Scheduler::new();
let trigger_every_5min =
Trigger::with_identity("5min_trigger", "default_group").every(Duration::from_mins(5));
let trigger_every_5min = Trigger::with_identity("5min_trigger", "default_group")
.every(Duration::from_mins(5))
.repeat(u32::max_value());
let trigger_every_3min =
Trigger::with_identity("3min_trigger", "default_group").every(Duration::from_mins(3));
let trigger_every_3min = Trigger::with_identity("3min_trigger", "default_group")
.every(Duration::from_mins(3))
.repeat(u32::max_value());
let rt_cleanup = rt.clone();
let cleanup_tokens_job = Job::with_identity("cleanup_tokens_job", "default_group", move || {

View File

@@ -10,6 +10,7 @@ mod db;
mod helper;
mod jobs;
mod models;
mod servers;
#[get("/api/hello")]
async fn hello() -> impl Responder {
@@ -47,6 +48,7 @@ async fn main() -> std::io::Result<()> {
.service(auth::is_scp_logged_in)
.service(auth::start_flow)
.service(auth::get_user)
.service(servers::list_servers)
.service(
Files::new("/", "./frontend/dist")
.index_file("index.html")

14
src/servers.rs Normal file
View File

@@ -0,0 +1,14 @@
use actix_web::{Responder, get};
use crate::helper;
#[get("/api/servers")]
pub async fn list_servers() -> impl Responder {
let config = helper::get_authed_api_config().await.unwrap();
let response =
scp_core::apis::default_api::api_v1_servers_get(&config, None, None, None, None, None)
.await
.unwrap();
serde_json::to_string(&response).unwrap()
}