Add build queue

This commit is contained in:
ferreo 2024-07-29 23:04:08 +01:00
parent 10d7333454
commit a67969d7fe
4 changed files with 462 additions and 54 deletions

View File

@ -11,6 +11,7 @@ function App() {
<> <>
<Header /> <Header />
<Switch> <Switch>
<Route path="/queue" component={Queue} />
<Route path="/packages" component={Packages} /> <Route path="/packages" component={Packages} />
<Route component={Home}/> <Route component={Home}/>
</Switch> </Switch>

View File

@ -1,92 +1,108 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import { Link, useLocation } from 'wouter'; import { Link, useLocation } from "wouter";
import { Button } from '../ui/button'; import { Button } from "../ui/button";
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../ui/dropdown-menu'; } from "../ui/dropdown-menu";
import { Dialog, DialogContent, DialogTrigger } from '../ui/dialog'; import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog";
import { Input } from '../ui/input'; import { Input } from "../ui/input";
import { Label } from '../ui/label'; import { Label } from "../ui/label";
import { AlertCircle } from 'lucide-react'; import { AlertCircle } from "lucide-react";
import { Alert, AlertDescription } from '../ui/alert'; import { Alert, AlertDescription } from "../ui/alert";
const Header: React.FC = () => { const Header: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [, setLocation] = useLocation(); const [, setLocation] = useLocation();
const [username, setUsername] = useState(''); const [username, setUsername] = useState("");
const [password, setPassword] = useState(''); const [password, setPassword] = useState("");
const [error, setError] = useState(''); const [error, setError] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const checkLoginStatus = () => { const checkLoginStatus = () => {
const ptCookie = document.cookie.split('; ').find(row => row.startsWith('pt=')); const ptCookie = document.cookie
.split("; ")
.find((row) => row.startsWith("pt="));
setIsLoggedIn(!!ptCookie); setIsLoggedIn(!!ptCookie);
}; };
useEffect(() => { useEffect(() => {
checkLoginStatus(); checkLoginStatus();
window.addEventListener('focus', checkLoginStatus); window.addEventListener("focus", checkLoginStatus);
return () => { return () => {
window.removeEventListener('focus', checkLoginStatus); window.removeEventListener("focus", checkLoginStatus);
}; };
}, []); }, []);
const handleLogin = async (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError("");
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('username', username); formData.append("username", username);
formData.append('password', password); formData.append("password", password);
const response = await fetch('/api/login', { const response = await fetch("/api/login", {
method: 'POST', method: "POST",
body: formData, body: formData,
credentials: 'include', credentials: "include",
}); });
if (!response.ok) { if (!response.ok) {
const errorData = await response.json(); const errorData = await response.json();
throw new Error(errorData.message || 'Login failed'); throw new Error(errorData.message || "Login failed");
} }
checkLoginStatus(); checkLoginStatus();
setIsDialogOpen(false); setIsDialogOpen(false);
setUsername(''); setUsername("");
setPassword(''); setPassword("");
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Login failed. Please try again.'); setError(
err instanceof Error ? err.message : "Login failed. Please try again."
);
} }
}; };
const handleLogout = () => { const handleLogout = () => {
document.cookie = 'pt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; document.cookie = "pt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
setIsLoggedIn(false); setIsLoggedIn(false);
setLocation('/'); setLocation("/");
}; };
return ( return (
<header className="flex items-center justify-between p-4 bg-background text-foreground shadow-md"> <header className="flex items-center justify-between p-4 bg-background text-foreground shadow-md">
<nav> <nav>
<ul className="flex space-x-4"> <ul className="flex space-x-4">
<li><Link href="/" className="text-foreground hover:text-primary">Home</Link></li> <li>
<li><Link href="/packages" className="text-foreground hover:text-primary">Packages</Link></li> <Link href="/queue" className="text-foreground hover:text-primary">
Queue
</Link>
</li>
<li>
<Link
href="/packages"
className="text-foreground hover:text-primary"
>
Packages
</Link>
</li>
</ul> </ul>
</nav> </nav>
<div className="flex-grow flex justify-center"> <div className="flex-grow flex justify-center">
<Link href="/">
<img <img
src="https://git.pika-os.com/website/pika-branding/raw/branch/main/logos/pika-logo-text-dark.svg" src="https://git.pika-os.com/website/pika-branding/raw/branch/main/logos/pika-logo-text-dark.svg"
alt="PikaOS Logo" alt="PikaOS Logo"
className="h-16 w-auto max-w-full" className="h-16 w-auto max-w-full"
/> />
</Link>
</div> </div>
{isLoggedIn ? ( {isLoggedIn ? (
@ -98,18 +114,18 @@ const Header: React.FC = () => {
</Avatar> </Avatar>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuItem onSelect={() => setLocation('/profile')}> <DropdownMenuItem onSelect={() => setLocation("/profile")}>
Profile Profile
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onSelect={handleLogout}> <DropdownMenuItem onSelect={handleLogout}>Logout</DropdownMenuItem>
Logout
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : ( ) : (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" onClick={() => setIsDialogOpen(true)}>Login</Button> <Button variant="outline" onClick={() => setIsDialogOpen(true)}>
Login
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-[425px] bg-background text-foreground"> <DialogContent className="sm:max-w-[425px] bg-background text-foreground">
<form onSubmit={handleLogin} className="space-y-4"> <form onSubmit={handleLogin} className="space-y-4">
@ -137,12 +153,17 @@ const Header: React.FC = () => {
/> />
</div> </div>
{error && ( {error && (
<Alert variant="destructive" className="mt-4 bg-destructive text-destructive-foreground"> <Alert
variant="destructive"
className="mt-4 bg-destructive text-destructive-foreground"
>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
</Alert> </Alert>
)} )}
<Button type="submit" className="w-full">Login</Button> <Button type="submit" className="w-full">
Login
</Button>
</form> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@ -0,0 +1,291 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import { useMediaQuery } from "react-responsive";
import useSearchParams from "../../hooks/useSearchParams";
import useQueueData from "../../hooks/useQueueData";
import { Input } from "../ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../ui/table";
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "../ui/pagination";
const Queue: React.FC = () => {
const { getParam, setParam } = useSearchParams();
const { packages, totalCount, loading, fetchPackages } = useQueueData();
const [currentPage, setCurrentPage] = useState(
parseInt(getParam("page") || "1", 10)
);
const isMobile = useMediaQuery({ maxWidth: 767 });
const [pageSize, setPageSize] = useState(isMobile ? 100 : 250);
const [search, setSearch] = useState(getParam("search") || "");
const [filter, setFilter] = useState(getParam("filter") || "All");
const isInitialMount = useRef(true);
const prevDependencies = useRef({ currentPage, pageSize, search, filter });
useEffect(() => {
setPageSize(isMobile ? 100 : 250);
}, [isMobile]);
const loadPackages = useCallback(() => {
fetchPackages(currentPage, pageSize, search, filter);
setParam("page", currentPage.toString());
setParam("search", search);
setParam("filter", filter);
}, [currentPage, pageSize, search, filter, fetchPackages, setParam]);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
loadPackages();
} else {
const {
currentPage: prevPage,
pageSize: prevSize,
search: prevSearch,
filter: prevFilter,
} = prevDependencies.current;
if (
currentPage !== prevPage ||
pageSize !== prevSize ||
search !== prevSearch ||
filter !== prevFilter
) {
loadPackages();
prevDependencies.current = { currentPage, pageSize, search, filter };
}
}
}, [loadPackages, currentPage, pageSize, search, filter]);
const handleFilterChange = (value: string) => {
const newFilter = value === "All" ? "" : value;
setFilter(newFilter);
setCurrentPage(1);
};
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
setCurrentPage(1);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
const totalPages = Math.ceil(totalCount / pageSize);
return (
<div className="flex flex-col min-h-screen">
{/* Sticky Header */}
<div className="sticky top-0 z-10 bg-background border-b border-border">
<div className="container mx-auto p-4">
<div className="flex space-x-4">
<Input
placeholder="Search packages..."
value={search}
onChange={handleSearchChange}
className="max-w-sm"
/>
<Select value={filter} onValueChange={handleFilterChange}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="All">All</SelectItem>
{[
"Stale",
"Error",
"Missing",
"Current",
"Queued",
"Building",
].map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Table */}
<div className="flex-grow overflow-hidden">
<div className="container mx-0 px-0 h-full">
<div className="h-full">
<Table className="w-full">
<TableHeader className="top-0 bg-background z-10 hidden md:table-header-group">
<TableRow>
<TableHead className="w-1/2 text-left">Name</TableHead>
<TableHead className="w-1/4 text-left">Version</TableHead>
<TableHead className="w-1/4 text-left">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={3} className="text-center">
Loading...
</TableCell>
</TableRow>
) : Object.keys(packages).length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center">
No packages found.
</TableCell>
</TableRow>
) : (
Object.entries(packages).map(([pkgName, pkgInfo]) => {
return (
<TableRow
key={pkgName}
className="md:table-row border-b last:border-b-0"
>
<TableCell className="w-full md:w-1/2 py-2 md:py-4">
<div className="flex flex-col h-full md:h-auto">
<div className="flex justify-between items-start mb-2 md:mb-0">
<span className="font-medium truncate mr-2">
{pkgName} - {pkgInfo.Type}
</span>
<span className="text-sm md:hidden truncate">
{pkgInfo.Status}
</span>
</div>
<div className="md:hidden text-sm text-muted-foreground text-center">
<span className="truncate">
{pkgInfo.BuildVersion}
</span>
</div>
</div>
</TableCell>
<TableCell className="hidden md:table-cell md:w-1/4 py-4 text-left">
<div className="flex flex-col">
<span className="truncate">
{pkgInfo.BuildVersion}
</span>
</div>
</TableCell>
<TableCell className="hidden md:table-cell md:w-1/4 py-2 md:py-4 text-left">
<span className="truncate">{pkgInfo.Status}</span>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</div>
{/* Sticky Footer with Pagination */}
<div className="sticky bottom-0 w-full bg-background border-t border-border py-4">
<div className="container mx-auto px-2">
<Pagination>
<PaginationContent className="flex flex-wrap justify-center items-center gap-1">
<PaginationItem>
<PaginationPrevious
size="default"
onClick={() => handlePageChange(Math.max(currentPage - 1, 1))}
/>
</PaginationItem>
{currentPage > 2 && (
<PaginationItem className="hidden sm:inline-block">
<PaginationLink
size="default"
onClick={() => handlePageChange(1)}
>
1
</PaginationLink>
</PaginationItem>
)}
{currentPage > 3 && (
<PaginationItem className="hidden sm:inline-flex items-center">
<PaginationEllipsis />
</PaginationItem>
)}
{currentPage > 1 && (
<PaginationItem>
<PaginationLink
size="default"
onClick={() => handlePageChange(currentPage - 1)}
>
{currentPage - 1}
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationLink size="default" isActive>
{currentPage}
</PaginationLink>
</PaginationItem>
{currentPage < totalPages && (
<PaginationItem>
<PaginationLink
size="default"
onClick={() => handlePageChange(currentPage + 1)}
>
{currentPage + 1}
</PaginationLink>
</PaginationItem>
)}
{currentPage < totalPages - 2 && (
<PaginationItem className="hidden sm:inline-flex items-center">
<PaginationEllipsis />
</PaginationItem>
)}
{currentPage < totalPages - 1 && (
<PaginationItem className="hidden sm:inline-block">
<PaginationLink
size="default"
onClick={() => handlePageChange(totalPages)}
>
{totalPages}
</PaginationLink>
</PaginationItem>
)}
<PaginationItem>
<PaginationNext
size="default"
onClick={() =>
handlePageChange(Math.min(currentPage + 1, totalPages))
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
</div>
);
};
export default Queue;

95
src/hooks/useQueueData.ts Normal file
View File

@ -0,0 +1,95 @@
import { useState, useCallback, useRef } from 'react';
interface PackageStats {
stale: number;
missing: number;
built: number;
error: number;
queued: number;
building: number;
}
interface CountResponse {
lastUpdateTime: string;
counts: PackageStats;
}
interface QueueData {
Source: PackageData;
Type: string;
BuildVersion: string;
Status: string;
}
interface PackageData {
Name: string;
Packages: {
[key: string]: {
Version: string;
NewVersion: string;
Status: string;
};
};
}
interface PackagesResponse {
total: number;
packages: Record<string, QueueData>;
}
const usePackageData = () => {
const [stats, setStats] = useState<PackageStats | null>(null);
const [lastUpdated, setLastUpdated] = useState<string>('');
const [packages, setQueuePackages] = useState<Record<string, QueueData>>({});
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(false);
const fetchStatsAttempted = useRef(false);
const fetchStats = useCallback(async () => {
if (fetchStatsAttempted.current || stats) return;
fetchStatsAttempted.current = true;
setLoading(true);
try {
const response = await fetch('/api/counts');
const data: CountResponse = await response.json();
setStats(data.counts);
const dt = new Date(data.lastUpdateTime);
setLastUpdated(dt.toLocaleString());
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
}
}, [stats]);
const fetchPackages = useCallback(async (page: number, pageSize: number, search: string, filter: string) => {
setLoading(true);
try {
const filterParam = filter === "All" ? "" : filter;
const response = await fetch(
`/api/queue?page=${page}&pageSize=${pageSize}&search=${search}&filter=${filterParam}`
);
const data: PackagesResponse = await response.json();
setTotalCount(data.total);
setQueuePackages(data.packages);
} catch (error) {
console.error("Error fetching packages:", error);
setQueuePackages({});
setTotalCount(0);
} finally {
setLoading(false);
}
}, []);
return {
stats,
lastUpdated,
fetchStats,
packages,
totalCount,
loading,
fetchPackages,
};
};
export default usePackageData;