Compare commits

..

3 Commits

Author SHA1 Message Date
siujamo 163f5641e5 refactor(web): add Redux state management and centralised routing
- Replace inline BrowserRouter with createBrowserRouter and lazy-loaded pages
- Add Redux store with auth-slice (token management) and redux-persist
- Create RequireAuth guard using Redux state via isAuthenticated selector
- Wire Layout logout to dispatch setToken("") clearing persisted auth
- Add ErrorPage component for route error handling
- Add EmptyLayout for unauthenticated pages
- Add VITE_REDUX_STORAGE env var for storage selection
2026-07-07 15:17:49 +08:00
siujamo 3a6cd42361 refactor: rename OTA Manager to Pipely in UI, simplify logout
- Change login page title from "OTA Manager" to "Pipely"
- Remove setToken call from Layout logout handler
2026-07-07 14:47:13 +08:00
siujamo 033082954a refactor: rename module to pipely and migrate web UI to MUI
- Rename Go module from onixbyte.com/message-converter-manifest to onixbyte.com/pipely
- Replace shadcn UI with Material UI components across all pages
- Add axios-based API client in shared/web-client with credentials management
- Organise API calls into feature modules: auth, devices, deployments, artefacts, users
- Add .env.example with VITE_API_BASE_URL pointing to pipely.onixbyte.com
- Fix deprecated FormEvent → MouseEvent in dialog submit handlers
2026-07-07 14:40:09 +08:00
50 changed files with 4604 additions and 1186 deletions
+9 -9
View File
@@ -11,15 +11,15 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"onixbyte.com/message-converter-manifest/internal/artefact" "onixbyte.com/pipely/internal/artefact"
"onixbyte.com/message-converter-manifest/internal/auth" "onixbyte.com/pipely/internal/auth"
"onixbyte.com/message-converter-manifest/internal/config" "onixbyte.com/pipely/internal/config"
"onixbyte.com/message-converter-manifest/internal/database" "onixbyte.com/pipely/internal/database"
"onixbyte.com/message-converter-manifest/internal/delta" "onixbyte.com/pipely/internal/delta"
"onixbyte.com/message-converter-manifest/internal/deployment" "onixbyte.com/pipely/internal/deployment"
"onixbyte.com/message-converter-manifest/internal/device" "onixbyte.com/pipely/internal/device"
"onixbyte.com/message-converter-manifest/internal/download" "onixbyte.com/pipely/internal/download"
"onixbyte.com/message-converter-manifest/internal/middleware" "onixbyte.com/pipely/internal/middleware"
) )
func main() { func main() {
+1 -1
View File
@@ -1,4 +1,4 @@
module onixbyte.com/message-converter-manifest module onixbyte.com/pipely
go 1.26 go 1.26
+2 -2
View File
@@ -13,8 +13,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database" "onixbyte.com/pipely/internal/database"
"onixbyte.com/message-converter-manifest/internal/model" "onixbyte.com/pipely/internal/model"
) )
type Handler struct { type Handler struct {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strconv" "strconv"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"onixbyte.com/message-converter-manifest/internal/model" "onixbyte.com/pipely/internal/model"
) )
// -- Admin user management handlers -- // -- Admin user management handlers --
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"onixbyte.com/message-converter-manifest/internal/model" "onixbyte.com/pipely/internal/model"
) )
type Handler struct { type Handler struct {
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"gorm.io/gorm" "gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database" "onixbyte.com/pipely/internal/database"
"onixbyte.com/message-converter-manifest/internal/model" "onixbyte.com/pipely/internal/model"
) )
var ( var (
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"onixbyte.com/message-converter-manifest/internal/model" "onixbyte.com/pipely/internal/model"
) )
// Connect opens a PostgreSQL connection via GORM. If the target database does // Connect opens a PostgreSQL connection via GORM. If the target database does
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"onixbyte.com/message-converter-manifest/internal/model" "onixbyte.com/pipely/internal/model"
) )
// -- Admin Users -- // -- Admin Users --
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database" "onixbyte.com/pipely/internal/database"
"onixbyte.com/message-converter-manifest/internal/model" "onixbyte.com/pipely/internal/model"
) )
type Handler struct { type Handler struct {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database" "onixbyte.com/pipely/internal/database"
"onixbyte.com/message-converter-manifest/internal/model" "onixbyte.com/pipely/internal/model"
) )
type Handler struct { type Handler struct {
+2 -2
View File
@@ -12,8 +12,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database" "onixbyte.com/pipely/internal/database"
"onixbyte.com/message-converter-manifest/internal/middleware" "onixbyte.com/pipely/internal/middleware"
) )
// Handler manages artefact downloads with bandwidth throttling and Range support. // Handler manages artefact downloads with bandwidth throttling and Range support.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"onixbyte.com/message-converter-manifest/internal/auth" "onixbyte.com/pipely/internal/auth"
) )
func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc { func AuthMiddleware(authSvc *auth.Service) gin.HandlerFunc {
+17
View File
@@ -0,0 +1,17 @@
# ─────────────────────────────────────────────────────────
# OTA Manager Web Frontend — Environment Variables
# ─────────────────────────────────────────────────────────
# Copy this file to .env and adjust values for your environment:
# cp .env.example .env
# ─────────────────────────────────────────────────────────
# ── API Server ───────────────────────────────────────────
# Base URL for the OTA Manifest Server API.
# Default: https://pipely.onixbyte.com/
# Development: http://localhost:8080
VITE_API_BASE_URL=https://pipely.onixbyte.com/
# Redux Persistent storage location
# Options: `local` (default), `session`
VITE_REDUX_STORAGE=local
+6
View File
@@ -12,6 +12,12 @@ dist
dist-ssr dist-ssr
*.local *.local
# Environment variables
.env
.env.local
.env.*.local
!.env.example
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
+9 -3
View File
@@ -6,12 +6,18 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config' import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([ export default defineConfig([
globalIgnores(['dist']), globalIgnores(["dist"]),
{ {
files: ['**/*.{ts,tsx}'], files: ["**/*.{ts,tsx}"],
extends: [ extends: [
js.configs.recommended, js.configs.recommended,
tseslint.configs.recommended, // Remove tseslint.configs.recommended and replace with this
// tseslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
// tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
// tseslint.configs.stylisticTypeChecked,
reactHooks.configs.flat.recommended, reactHooks.configs.flat.recommended,
reactRefresh.configs.vite, reactRefresh.configs.vite,
], ],
+14 -1
View File
@@ -10,9 +10,21 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^9.2.0",
"@mui/material": "^9.2.0",
"@reduxjs/toolkit": "^2.12.0",
"@tailwindcss/vite": "^4.3.2",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1" "react-redux": "^9.3.0",
"react-router": "^8.1.0",
"react-router-dom": "^7.18.1",
"redux-persist": "^6.0.0",
"tailwindcss": "^4.3.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@@ -25,6 +37,7 @@
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0", "globals": "^17.7.0",
"prettier": "^3.9.4", "prettier": "^3.9.4",
"shadcn": "^4.13.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.62.0", "typescript-eslint": "^8.62.0",
"vite": "^8.1.1", "vite": "^8.1.1",
+3047 -43
View File
File diff suppressed because it is too large Load Diff
-126
View File
@@ -1,126 +0,0 @@
/* Reset & base */
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f7;
color: #1a1a2e;
}
#root {
min-height: 100vh;
}
/* Shared card style */
.card {
background: #fff;
border-radius: 8px;
padding: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
}
/* Shared table style */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table thead {
background: #f8f9fa;
}
.data-table th {
padding: 10px 12px;
text-align: left;
font-weight: 600;
color: #555;
border-bottom: 2px solid #e9ecef;
}
.data-table td {
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
}
.data-table tbody tr:hover {
background: #f8f9ff;
}
.td-empty {
text-align: center;
color: #aaa;
padding: 30px 12px !important;
}
/* Shared pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px 0 0;
font-size: 13px;
color: #666;
}
.pagination button {
padding: 4px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: #fff;
cursor: pointer;
font-size: 12px;
}
.pagination button:disabled {
opacity: 0.4;
cursor: default;
}
/* Shared utility */
.page-title {
margin: 0 0 24px;
font-size: 20px;
color: #1a1a2e;
}
.page-loading {
padding: 40px;
color: #888;
text-align: center;
}
.btn-primary {
padding: 10px 20px;
background: #e94560;
color: #fff;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover {
background: #d63850;
}
.btn-primary:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-sm {
width: auto;
padding: 8px 16px;
}
.msg-success { color: #27ae60; font-size: 13px; margin: 8px 0; }
.msg-error { color: #c00; font-size: 13px; margin: 8px 0; }
-36
View File
@@ -1,36 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"
import Layout from "@/components/Layout"
import Login from "@/pages/Login"
import Dashboard from "@/pages/Dashboard"
import Artefacts from "@/pages/Artefacts"
import Deployments from "@/pages/Deployments"
import Users from "@/pages/Users"
import "@/App.css"
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem("token")
if (!token) return <Navigate to="/" replace />
return <>{children}</>
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Login />} />
<Route
element={
<RequireAuth>
<Layout />
</RequireAuth>
}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/artefacts" element={<Artefacts />} />
<Route path="/deployments" element={<Deployments />} />
<Route path="/users" element={<Users />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
)
}
+43
View File
@@ -0,0 +1,43 @@
import WebClient from "@/shared/web-client"
export interface Artefact {
id: string
name: string
version: string
checksum: string
size: number
createdAt: string
}
export interface ArtefactsResponse {
data: Artefact[]
total: number
page: number
pageSize: number
}
export interface ArtefactsQuery {
page?: number
pageSize?: number
}
export async function getArtefacts(query?: ArtefactsQuery): Promise<ArtefactsResponse> {
const params = new URLSearchParams()
if (query?.page) params.append("page", query.page.toString())
if (query?.pageSize) params.append("pageSize", query.pageSize.toString())
const queryString = params.toString()
const { data } = await WebClient.get<ArtefactsResponse>(
`/artefacts${queryString ? `?${queryString}` : ""}`
)
return data
}
export async function uploadArtefact(file: File): Promise<Artefact> {
const { data } = await WebClient.post<Artefact>("/artefacts/upload", file)
return data
}
export async function deleteArtefact(id: string): Promise<void> {
await WebClient.delete<void>(`/artefacts/${id}`)
}
+15
View File
@@ -0,0 +1,15 @@
import WebClient from "@/shared/web-client"
export interface LoginRequest {
username: string
password: string
}
export interface LoginResponse {
token: string
}
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
const { data } = await WebClient.post<LoginResponse>("/auth/login", credentials)
return data
}
-70
View File
@@ -1,70 +0,0 @@
const API_BASE = '/api/v1';
let token: string | null = localStorage.getItem('token');
export function setToken(t: string | null) {
token = t;
if (t) {
localStorage.setItem('token', t);
} else {
localStorage.removeItem('token');
}
}
export function getToken(): string | null {
return token;
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers: Record<string, string> = {
...(options.headers as Record<string, string>),
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Only set Content-Type for non-GET requests with body
if (!(options.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
} else {
delete headers['Content-Type']; // Let browser set multipart boundary
}
const res = await fetch(`${API_BASE}${path}`, {
...options,
headers,
});
if (res.status === 401) {
setToken(null);
window.location.href = '/';
throw new Error('Unauthorised');
}
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}
export const api = {
get: <T>(path: string) => request<T>(path),
post: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'POST',
body: body instanceof FormData ? body : JSON.stringify(body),
}),
put: <T>(path: string, body?: unknown) =>
request<T>(path, {
method: 'PUT',
body: JSON.stringify(body),
}),
delete: <T>(path: string) =>
request<T>(path, { method: 'DELETE' }),
};
+68
View File
@@ -0,0 +1,68 @@
import WebClient from "@/shared/web-client"
export interface Deployment {
id: string
name: string
artefactId: string
artefactVersion: string
status: "active" | "paused" | "completed" | "failed"
targetGroups: string[]
totalDevices: number
completedDevices: number
failedDevices: number
createdAt: string
updatedAt: string
}
export interface CreateDeploymentRequest {
name: string
artefactId: string
targetGroups: string[]
}
export interface UpdateDeploymentRequest {
status: "active" | "paused"
}
export interface DeploymentsResponse {
data: Deployment[]
total: number
page: number
pageSize: number
}
export interface DeploymentsQuery {
page?: number
pageSize?: number
status?: string
}
export async function getDeployments(query?: DeploymentsQuery): Promise<DeploymentsResponse> {
const params = new URLSearchParams()
if (query?.page) params.append("page", query.page.toString())
if (query?.pageSize) params.append("pageSize", query.pageSize.toString())
if (query?.status) params.append("status", query.status)
const queryString = params.toString()
const { data } = await WebClient.get<DeploymentsResponse>(
`/deployments${queryString ? `?${queryString}` : ""}`
)
return data
}
export async function createDeployment(request: CreateDeploymentRequest): Promise<Deployment> {
const { data } = await WebClient.post<Deployment>("/deployments", request)
return data
}
export async function updateDeployment(
id: string,
request: UpdateDeploymentRequest
): Promise<Deployment> {
const { data } = await WebClient.put<Deployment>(`/deployments/${id}`, request)
return data
}
export async function deleteDeployment(id: string): Promise<void> {
await WebClient.delete<void>(`/deployments/${id}`)
}
+41
View File
@@ -0,0 +1,41 @@
import WebClient from "@/shared/web-client"
export interface Device {
id: string
deviceId: string
status: "online" | "offline" | "upgrading"
lastHeartbeat: string
currentVersion?: string
targetVersion?: string
groupId?: string
createdAt: string
updatedAt: string
}
export interface DevicesResponse {
data: Device[]
total: number
page: number
pageSize: number
}
export interface DevicesQuery {
page?: number
pageSize?: number
status?: string
groupId?: string
search?: string
}
export async function getDevices(query?: DevicesQuery): Promise<DevicesResponse> {
const params = new URLSearchParams()
if (query?.page) params.append("page", query.page.toString())
if (query?.pageSize) params.append("pageSize", query.pageSize.toString())
if (query?.status) params.append("status", query.status)
if (query?.groupId) params.append("groupId", query.groupId)
if (query?.search) params.append("search", query.search)
const queryString = params.toString()
const { data } = await WebClient.get<DevicesResponse>(`/devices${queryString ? `?${queryString}` : ""}`)
return data
}
+59
View File
@@ -0,0 +1,59 @@
import WebClient from "@/shared/web-client"
export interface User {
id: string
username: string
role: "admin" | "user"
createdAt: string
updatedAt: string
}
export interface CreateUserRequest {
username: string
password: string
role: "admin" | "user"
}
export interface UpdateUserRequest {
username?: string
password?: string
role?: "admin" | "user"
}
export interface UsersResponse {
data: User[]
total: number
page: number
pageSize: number
}
export interface UsersQuery {
page?: number
pageSize?: number
}
export async function getUsers(query?: UsersQuery): Promise<UsersResponse> {
const params = new URLSearchParams()
if (query?.page) params.append("page", query.page.toString())
if (query?.pageSize) params.append("pageSize", query.pageSize.toString())
const queryString = params.toString()
const { data } = await WebClient.get<UsersResponse>(
`/users${queryString ? `?${queryString}` : ""}`
)
return data
}
export async function createUser(request: CreateUserRequest): Promise<User> {
const { data } = await WebClient.post<User>("/users", request)
return data
}
export async function updateUser(id: string, request: UpdateUserRequest): Promise<User> {
const { data } = await WebClient.put<User>(`/users/${id}`, request)
return data
}
export async function deleteUser(id: string): Promise<void> {
await WebClient.delete<void>(`/users/${id}`)
}
+13
View File
@@ -0,0 +1,13 @@
import { Outlet } from "react-router-dom"
/**
* Empty layout component that provides minimal structure.
* Useful for pages that need full control over their layout.
*/
export default function EmptyLayout() {
return (
<div className="min-h-screen">
<Outlet />
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { useRouteError, isRouteErrorResponse } from "react-router-dom"
import { Container, Typography, Button, Box } from "@mui/material"
export default function ErrorPage() {
const error = useRouteError()
let status = 500
let message = "Something went wrong."
if (isRouteErrorResponse(error)) {
status = error.status
message = error.statusText || message
} else if (error instanceof Error) {
message = error.message
}
return (
<Container maxWidth="sm">
<Box
sx={{
minHeight: "100vh",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
}}
>
<Typography variant="h1" component="p" sx={{ mb: 2 }}>
{status}
</Typography>
<Typography variant="h5" component="p" color="text.secondary" sx={{ mb: 4 }}>
{message}
</Typography>
<Button variant="contained" onClick={() => window.location.assign("/")}>
Back to Dashboard
</Button>
</Box>
</Container>
)
}
-79
View File
@@ -1,79 +0,0 @@
.app-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 220px;
background: #1a1a2e;
color: #e0e0e0;
display: flex;
flex-direction: column;
padding: 0;
flex-shrink: 0;
}
.sidebar-brand {
padding: 20px 16px;
border-bottom: 1px solid #16213e;
}
.sidebar-brand h2 {
margin: 0;
font-size: 16px;
color: #e94560;
letter-spacing: 1px;
}
.sidebar nav {
flex: 1;
padding: 12px 0;
}
.nav-link {
display: block;
padding: 10px 20px;
color: #a0a0b8;
text-decoration: none;
font-size: 14px;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
background: #16213e;
color: #fff;
}
.nav-link.active {
background: #e94560;
color: #fff;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid #16213e;
}
.btn-logout {
width: 100%;
padding: 8px;
background: transparent;
border: 1px solid #e94560;
color: #e94560;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.15s, color 0.15s;
}
.btn-logout:hover {
background: #e94560;
color: #fff;
}
.content {
flex: 1;
padding: 24px 32px;
background: #f5f5f7;
overflow-y: auto;
}
+102 -33
View File
@@ -1,42 +1,111 @@
import { NavLink, useNavigate, Outlet } from 'react-router-dom'; import { NavLink, useNavigate, Outlet } from "react-router-dom"
import { setToken } from '../api/client'; import { useDispatch } from "react-redux"
import './Layout.css'; import { setToken } from "@/store/auth-slice"
import {
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
AppBar,
Typography,
Box,
Divider,
} from "@mui/material"
import {
Dashboard as DashboardIcon,
Inventory,
RocketLaunch,
People,
Logout,
} from "@mui/icons-material"
const drawerWidth = 240
export default function Layout() { export default function Layout() {
const navigate = useNavigate(); const navigate = useNavigate()
const dispatch = useDispatch()
function handleLogout() { function handleLogout() {
setToken(null); dispatch(setToken(""))
navigate('/'); navigate("/")
} }
const navItems = [
{ path: "/dashboard", label: "Dashboard", icon: <DashboardIcon /> },
{ path: "/artefacts", label: "Versions", icon: <Inventory /> },
{ path: "/deployments", label: "Deployments", icon: <RocketLaunch /> },
{ path: "/users", label: "Admin Users", icon: <People /> },
]
return ( return (
<div className="app-layout"> <Box sx={{ display: "flex" }}>
<aside className="sidebar"> <AppBar
<div className="sidebar-brand"> position="fixed"
<h2>OTA Manager</h2> sx={{
</div> width: `calc(100% - ${drawerWidth}px)`,
<nav> ml: `${drawerWidth}px`,
<NavLink to="/dashboard" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}> }}>
Dashboard <Toolbar>
</NavLink> <Typography variant="h6" noWrap component="div">
<NavLink to="/artefacts" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}> OTA Manager
Versions </Typography>
</NavLink> </Toolbar>
<NavLink to="/deployments" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}> </AppBar>
Deployments <Drawer
</NavLink> sx={{
<NavLink to="/users" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}> width: drawerWidth,
Admin Users flexShrink: 0,
</NavLink> "& .MuiDrawer-paper": {
</nav> width: drawerWidth,
<div className="sidebar-footer"> boxSizing: "border-box",
<button className="btn-logout" onClick={handleLogout}>Logout</button> },
</div> }}
</aside> variant="permanent"
<main className="content"> anchor="left">
<Toolbar />
<Divider />
<List>
{navItems.map((item) => (
<ListItem key={item.path} disablePadding>
<ListItemButton
component={NavLink}
to={item.path}
sx={{
"&.active": {
bgcolor: "action.selected",
},
}}>
<ListItemIcon>{item.icon}</ListItemIcon>
<ListItemText primary={item.label} />
</ListItemButton>
</ListItem>
))}
</List>
<Divider />
<List>
<ListItem disablePadding>
<ListItemButton onClick={handleLogout}>
<ListItemIcon>
<Logout />
</ListItemIcon>
<ListItemText primary="Logout" />
</ListItemButton>
</ListItem>
</List>
</Drawer>
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: `calc(100% - ${drawerWidth}px)`,
mt: 8,
}}>
<Outlet /> <Outlet />
</main> </Box>
</div> </Box>
); )
} }
+6
View File
@@ -0,0 +1,6 @@
import dayjs from "dayjs"
import duration from "dayjs/plugin/duration"
dayjs.extend(duration)
export default dayjs
+24 -2
View File
@@ -1,10 +1,32 @@
import { StrictMode } from "react" import { StrictMode } from "react"
import { createRoot } from "react-dom/client" import { createRoot } from "react-dom/client"
import { RouterProvider } from "react-router-dom"
import { Provider } from "react-redux"
import { PersistGate } from "redux-persist/integration/react"
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material"
import router from "@/router"
import store, { persistor } from "@/store"
import "./index.css" import "./index.css"
import App from "./App.tsx"
const theme = createTheme({
palette: {
mode: "light",
},
})
/**
* Main application entry point.
* Sets up the React app with React Router.
*/
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ThemeProvider theme={theme}>
<CssBaseline />
<RouterProvider router={router} />
</ThemeProvider>
</PersistGate>
</Provider>
</StrictMode> </StrictMode>
) )
-50
View File
@@ -1,50 +0,0 @@
.upload-section {
margin-bottom: 20px;
}
.upload-section h3 {
margin: 0 0 12px;
font-size: 15px;
}
.upload-form {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.upload-form input[type="text"] {
width: 180px;
padding: 8px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
}
.upload-form input[type="file"] {
font-size: 13px;
}
.msg-success { color: #27ae60; font-size: 13px; margin: 8px 0 0; }
.msg-error { color: #c00; font-size: 13px; margin: 8px 0 0; }
.btn-sm {
width: auto;
padding: 8px 16px;
}
.badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 600;
}
.badge-delta { background: #fef3cd; color: #856404; }
.badge-full { background: #d4edda; color: #155724; }
.hash-cell {
font-family: monospace;
font-size: 12px;
color: #666;
}
+229 -116
View File
@@ -1,17 +1,29 @@
import { useEffect, useState } from "react" import { useEffect, useState, type MouseEvent } from "react"
import { api } from "../api/client" import { getArtefacts, uploadArtefact, deleteArtefact, type Artefact } from "@/api/artefacts"
import "./Artefacts.css" import {
Box,
interface Artefact { Button,
id: number Container,
version: string Dialog,
fileName: string DialogActions,
fileSize: number DialogContent,
sha256Hash: string DialogContentText,
isDelta: boolean DialogTitle,
deltaRef: string Paper,
createdAt: string Table,
} TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
IconButton,
Snackbar,
Alert,
Pagination,
} from "@mui/material"
import { Upload, Delete, Archive } from "@mui/icons-material"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -20,17 +32,26 @@ export default function Artefacts() {
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<{ id: string; version: string } | null>(null)
const [uploading, setUploading] = useState(false) const [uploading, setUploading] = useState(false)
const [version, setVersion] = useState("") const [version, setVersion] = useState("")
const [file, setFile] = useState<File | null>(null) const [file, setFile] = useState<File | null>(null)
const [uploadMsg, setUploadMsg] = useState("") const [snackbar, setSnackbar] = useState<{
open: boolean
message: string
severity: "success" | "error"
}>({
open: false,
message: "",
severity: "success",
})
async function load() { async function load() {
setLoading(true) setLoading(true)
try { try {
const res = await api.get<{ data: Artefact[]; total: number }>( const res = await getArtefacts({ page, pageSize: PAGE_SIZE })
`/artefacts?page=${page}&pageSize=${PAGE_SIZE}`
)
setArtefacts(res.data) setArtefacts(res.data)
setTotal(res.total) setTotal(res.total)
} catch (err) { } catch (err) {
@@ -42,126 +63,218 @@ export default function Artefacts() {
useEffect(() => { useEffect(() => {
load() load()
.then(() => {})
.catch(() => {})
}, [page]) }, [page])
async function handleUpload(e: React.FormEvent) { async function handleUpload(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault() e.preventDefault()
if (!file || !version) return if (!file || !version) return
const form = new FormData()
form.append("version", version)
form.append("file", file)
setUploading(true) setUploading(true)
setUploadMsg("")
try { try {
await api.post("/artefacts", form) await uploadArtefact(file)
setVersion("") setVersion("")
setFile(null) setFile(null)
setUploadMsg("Version uploaded successfully.") setDialogOpen(false)
load() setSnackbar({ open: true, message: "Version uploaded successfully", severity: "success" })
await load()
} catch (err) { } catch (err) {
setUploadMsg("Upload failed: " + (err instanceof Error ? err.message : "unknown error")) setSnackbar({
open: true,
message: "Upload failed: " + (err instanceof Error ? err.message : "unknown error"),
severity: "error",
})
} finally { } finally {
setUploading(false) setUploading(false)
} }
} }
function handleDeleteClick(id: string, version: string) {
setDeleteTarget({ id, version })
setDeleteDialogOpen(true)
}
async function handleDelete() {
if (!deleteTarget) return
try {
await deleteArtefact(deleteTarget.id)
setSnackbar({ open: true, message: "Artefact deleted", severity: "success" })
setDeleteDialogOpen(false)
setDeleteTarget(null)
await load()
} catch (err) {
setSnackbar({ open: true, message: "Failed to delete artefact", severity: "error" })
}
}
const totalPages = Math.ceil(total / PAGE_SIZE) const totalPages = Math.ceil(total / PAGE_SIZE)
return ( return (
<div> <Container maxWidth="xl">
<h1 className="page-title">Versions</h1> <Box sx={{ mt: 4, mb: 4 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Versions
</Typography>
<Typography variant="body2" color="text.secondary">
Manage software versions and updates
</Typography>
</Box>
<Button variant="contained" startIcon={<Upload />} onClick={() => setDialogOpen(true)}>
Upload Version
</Button>
</Box>
<div className="card upload-section"> <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<h3>Create New Version</h3> <DialogTitle>Upload New Version</DialogTitle>
<form className="upload-form" onSubmit={handleUpload}> <DialogContent>
<input <DialogContentText sx={{ mb: 2 }}>
type="text" Upload a new version for device updates.
placeholder="Version (e.g. 1.2.0)" </DialogContentText>
value={version} <Box component="form" sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
onChange={(e) => setVersion(e.target.value)} <TextField
required autoFocus
/> margin="dense"
<input id="version"
type="file" label="Version"
accept=".jar,.patch,.zip" fullWidth
onChange={(e) => setFile(e.target.files?.[0] || null)} variant="outlined"
required placeholder="e.g. 1.2.0"
/> value={version}
<button type="submit" className="btn-primary btn-sm" disabled={uploading}> onChange={(e) => setVersion(e.target.value)}
{uploading ? "Uploading..." : "Upload"} required
</button> />
</form> <TextField
{uploadMsg && ( margin="dense"
<p className={uploadMsg.includes("failed") ? "msg-error" : "msg-success"}>{uploadMsg}</p> id="file"
)} label="File"
</div> type="file"
fullWidth
variant="outlined"
slotProps={{
inputLabel: { shrink: true },
htmlInput: { accept: ".jar,.patch,.zip" },
}}
onChange={(e) => setFile((e.target as HTMLInputElement).files?.[0] || null)}
required
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleUpload} disabled={uploading}>
{uploading ? "Uploading..." : "Upload"}
</Button>
</DialogActions>
</Dialog>
<Paper elevation={1}>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Version</TableCell>
<TableCell>Name</TableCell>
<TableCell>Size</TableCell>
<TableCell>Checksum</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} align="center">
Loading...
</TableCell>
</TableRow>
) : artefacts.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
No versions yet. Upload the first one above.
</TableCell>
</TableRow>
) : (
artefacts.map((a) => (
<TableRow key={a.id}>
<TableCell sx={{ fontWeight: "medium" }}>{a.version}</TableCell>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Archive fontSize="small" color="action" />
{a.name}
</Box>
</TableCell>
<TableCell>{formatSize(a.size)}</TableCell>
<TableCell>
<Box
component="span"
sx={{
fontFamily: "monospace",
fontSize: 12,
cursor: "help",
}}
title={a.checksum}>
{a.checksum.slice(0, 12)}...
</Box>
</TableCell>
<TableCell>{new Date(a.createdAt).toLocaleDateString()}</TableCell>
<TableCell align="right">
<IconButton
color="error"
onClick={() => handleDeleteClick(a.id, a.version)}>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Version</th>
<th>File</th>
<th>Size</th>
<th>Type</th>
<th>SHA256</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} className="td-empty">
Loading...
</td>
</tr>
) : artefacts.length === 0 ? (
<tr>
<td colSpan={6} className="td-empty">
No versions yet. Upload the first one above.
</td>
</tr>
) : (
artefacts.map((a) => (
<tr key={a.id}>
<td>
<strong>{a.version}</strong>
</td>
<td>{a.fileName}</td>
<td>{formatSize(a.fileSize)}</td>
<td>
{a.isDelta ? (
<span className="badge badge-delta">delta ({a.deltaRef})</span>
) : (
<span className="badge badge-full">full</span>
)}
</td>
<td className="hash-cell" title={a.sha256Hash}>
{a.sha256Hash.slice(0, 12)}...
</td>
<td>{new Date(a.createdAt).toLocaleDateString()}</td>
</tr>
))
)}
</tbody>
</table>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="pagination"> <Box sx={{ display: "flex", justifyContent: "flex-end", mt: 3 }}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}> <Pagination
Prev count={totalPages}
</button> page={page}
<span> onChange={(_, value) => setPage(value)}
Page {page} of {totalPages} color="primary"
</span> />
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}> </Box>
Next
</button>
</div>
)} )}
</div>
</div> <Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Delete Version</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete version "{deleteTarget?.version}"? This action cannot
be undone.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<Button onClick={handleDelete} color="error">
Delete
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={() => setSnackbar({ ...snackbar, open: false })}>
<Alert
severity={snackbar.severity}
onClose={() => setSnackbar({ ...snackbar, open: false })}
sx={{ width: "100%" }}>
{snackbar.message}
</Alert>
</Snackbar>
</Box>
</Container>
) )
} }
-37
View File
@@ -1,37 +0,0 @@
.page-title {
margin: 0 0 24px;
font-size: 20px;
color: #1a1a2e;
}
.page-loading {
padding: 40px;
color: #888;
text-align: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 16px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px;
border-top: 3px solid #ccc;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #1a1a2e;
}
.stat-label {
font-size: 13px;
color: #888;
margin-top: 4px;
}
+87 -27
View File
@@ -1,6 +1,9 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { api } from "../api/client" import { Paper, Typography, Box, Chip, CircularProgress } from "@mui/material"
import "./Dashboard.css" import { Devices, DevicesOther, RocketLaunch, Archive } from "@mui/icons-material"
import { getDevices } from "@/api/devices"
import { getArtefacts } from "@/api/artefacts"
import { getDeployments } from "@/api/deployments"
interface Stats { interface Stats {
totalDevices: number totalDevices: number
@@ -22,17 +25,13 @@ export default function Dashboard() {
async function load() { async function load() {
try { try {
const [devices, artefacts, deployments] = await Promise.all([ const [devices, artefacts, deployments] = await Promise.all([
api.get<{ total: number; data: { status: string }[] }>("/devices?pageSize=1"), getDevices({ pageSize: 1000 }),
api.get<{ total: number }>("/artefacts?pageSize=1"), getArtefacts({ pageSize: 1000 }),
api.get<{ total: number; data: { status: string }[] }>("/deployments?pageSize=100"), getDeployments({ pageSize: 1000 }),
]) ])
const online = devices.data const online = devices.data.filter((d) => d.status === "online").length
? devices.data.filter((d: { status: string }) => d.status === "online").length const active = deployments.data.filter((d) => d.status === "active").length
: 0
const active = deployments.data
? deployments.data.filter((d: { status: string }) => d.status === "active").length
: 0
setStats({ setStats({
totalDevices: devices.total, totalDevices: devices.total,
@@ -47,28 +46,89 @@ export default function Dashboard() {
} }
} }
load() load()
.then(() => {})
.catch(() => {})
}, []) }, [])
if (loading) return <div className="page-loading">Loading...</div>
const cards = [ const cards = [
{ label: "Total Devices", value: stats.totalDevices, colour: "#4a90d9" }, {
{ label: "Online Devices", value: stats.onlineDevices, colour: "#27ae60" }, label: "Total Devices",
{ label: "Active Deployments", value: stats.activeDeployments, colour: "#e67e22" }, value: stats.totalDevices,
{ label: "Artefact Versions", value: stats.totalArtefacts, colour: "#8e44ad" }, icon: <Devices />,
colour: "#1976d2",
bgColour: "#e3f2fd",
},
{
label: "Online Devices",
value: stats.onlineDevices,
icon: <DevicesOther />,
colour: "#2e7d32",
bgColour: "#e8f5e9",
},
{
label: "Active Deployments",
value: stats.activeDeployments,
icon: <RocketLaunch />,
colour: "#e65100",
bgColour: "#fff3e0",
},
{
label: "Artefact Versions",
value: stats.totalArtefacts,
icon: <Archive />,
colour: "#7b1fa2",
bgColour: "#f3e5f5",
},
] ]
return ( return (
<div> <Box sx={{ flexGrow: 1 }}>
<h1 className="page-title">Dashboard</h1> <Typography variant="h4" component="h1" gutterBottom>
<div className="stats-grid"> Dashboard
{cards.map((c) => ( </Typography>
<div key={c.label} className="stat-card" style={{ borderTopColor: c.colour }}> <Box sx={{ display: "flex", flexWrap: "wrap", gap: 3 }}>
<div className="stat-value">{c.value}</div> {cards.map((c, index) => (
<div className="stat-label">{c.label}</div> <Box key={index} sx={{ minWidth: 240, flex: 1 }}>
</div> <Paper
sx={{
p: 3,
display: "flex",
flexDirection: "column",
height: "100%",
borderTop: 4,
borderTopColor: c.colour,
}}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 1,
}}>
<Typography variant="body2" color="text.secondary" sx={{ fontWeight: 500 }}>
{c.label}
</Typography>
<Chip
icon={<span style={{ display: "flex" }}>{c.icon}</span>}
sx={{
backgroundColor: c.bgColour,
color: c.colour,
"& .MuiChip-icon": { color: c.colour },
}}
size="small"
/>
</Box>
{loading ? (
<CircularProgress size={24} />
) : (
<Typography variant="h3" component="div">
{c.value}
</Typography>
)}
</Paper>
</Box>
))} ))}
</div> </Box>
</div> </Box>
) )
} }
-56
View File
@@ -1,56 +0,0 @@
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-header .page-title { margin-bottom: 0; }
.form-section { margin-bottom: 20px; }
.form-section h3 { margin: 0 0 12px; font-size: 15px; }
.dep-form {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.dep-form label {
display: block;
font-size: 13px;
color: #333;
}
.dep-form input,
.dep-form select {
display: block;
width: 100%;
margin-top: 3px;
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
box-sizing: border-box;
}
.badge-draft { background: #e2e3e5; color: #383d41; }
.badge-active { background: #d4edda; color: #155724; }
.badge-paused { background: #fff3cd; color: #856404; }
.badge-completed { background: #d4edda; color: #155724; }
.badge-cancelled { background: #f8d7da; color: #721c24; }
.actions-cell { white-space: nowrap; }
.btn-action {
padding: 4px 12px;
border: none;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
background: #27ae60;
color: #fff;
}
.btn-action:hover { opacity: 0.85; }
.btn-pause { background: #e67e22; }
+256 -195
View File
@@ -1,23 +1,42 @@
import { type FormEvent, useEffect, useState } from "react" import { useEffect, useState, type MouseEvent } from "react"
import { api } from "../api/client" import { getDeployments, createDeployment, updateDeployment, type Deployment } from "@/api/deployments"
import "./Deployments.css" import { getArtefacts } from "@/api/artefacts"
import {
Box,
Button,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
Chip,
LinearProgress,
Pagination,
} from "@mui/material"
import {
Add,
PlayArrow,
Pause,
CheckCircle,
Cancel,
Schedule,
} from "@mui/icons-material"
interface Deployment { const PAGE_SIZE = 20
id: number
artefactId: number
artefactVersion: string
name: string
targetType: string
rolloutStrategy: string
maxConcurrent: number
status: string
createdAt: string
}
interface Artefact {
id: number
version: string
}
export default function Deployments() { export default function Deployments() {
const [deployments, setDeployments] = useState<Deployment[]>([]) const [deployments, setDeployments] = useState<Deployment[]>([])
@@ -25,24 +44,17 @@ export default function Deployments() {
const [page, setPage] = useState(1) const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Create form const [dialogOpen, setDialogOpen] = useState(false)
const [showForm, setShowForm] = useState(false) const [artefacts, setArtefacts] = useState<{ id: string; version: string }[]>([])
const [artefacts, setArtefacts] = useState<Artefact[]>([])
const [name, setName] = useState("") const [name, setName] = useState("")
const [artefactId, setArtefactId] = useState("") const [artefactId, setArtefactId] = useState("")
const [targetType, setTargetType] = useState("all") const [targetGroups, setTargetGroups] = useState("")
const [targetSpec, setTargetSpec] = useState("")
const [rolloutStrategy, setRolloutStrategy] = useState("immediate")
const [maxConcurrent, setMaxConcurrent] = useState(50)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [msg, setMsg] = useState("")
async function load() { async function load() {
setLoading(true) setLoading(true)
try { try {
const res = await api.get<{ data: Deployment[]; total: number }>( const res = await getDeployments({ page, pageSize: PAGE_SIZE })
`/deployments?page=${page}&pageSize=20`
)
setDeployments(res.data) setDeployments(res.data)
setTotal(res.total) setTotal(res.total)
} catch (err) { } catch (err) {
@@ -54,213 +66,262 @@ export default function Deployments() {
useEffect(() => { useEffect(() => {
load() load()
}, [load, page]) .then(() => {})
.catch(() => {})
}, [page])
async function loadArtefacts() { async function loadArtefacts() {
try { try {
const res = await api.get<{ data: Artefact[] }>("/artefacts?pageSize=200") const res = await getArtefacts({ pageSize: 200 })
setArtefacts(res.data) setArtefacts(res.data)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} }
} }
async function handleCreate(e: FormEvent) { async function handleCreate(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault() e.preventDefault()
setCreating(true) setCreating(true)
setMsg("")
try { try {
await api.post("/deployments", { await createDeployment({
artefactId: parseInt(artefactId),
name, name,
targetType, artefactId,
targetSpec: targetSpec || "{}", targetGroups: targetGroups.split(",").map(g => g.trim()).filter(Boolean),
rolloutStrategy,
maxConcurrent,
}) })
setShowForm(false) setDialogOpen(false)
setName("") setName("")
setArtefactId("") setArtefactId("")
setTargetSpec("") setTargetGroups("")
setMsg("Deployment created. Activate it to begin rollout.")
load() load()
.then(() => {})
.catch(() => {})
} catch (err) { } catch (err) {
setMsg("Failed: " + (err instanceof Error ? err.message : "unknown")) console.error("Failed to create deployment:", err)
} finally { } finally {
setCreating(false) setCreating(false)
} }
} }
async function activate(id: number) { async function activate(id: string) {
try { try {
await api.post(`/deployments/${id}/activate`) await updateDeployment(id, { status: "active" })
load() load()
.then(() => {})
.catch(() => {})
} catch (err) { } catch (err) {
alert("Failed to activate") console.error("Failed to activate")
} }
} }
async function pause(id: number) { async function pause(id: string) {
try { try {
await api.post(`/deployments/${id}/pause`) await updateDeployment(id, { status: "paused" })
load() load()
.then(() => {})
.catch(() => {})
} catch (err) { } catch (err) {
alert("Failed to pause") console.error("Failed to pause")
} }
} }
const totalPages = Math.ceil(total / 20) const totalPages = Math.ceil(total / PAGE_SIZE)
const getStatusColor = (status: string) => {
switch (status) {
case 'active': return 'success';
case 'completed': return 'info';
case 'failed': return 'error';
case 'paused': return 'warning';
default: return 'default';
}
}
return ( return (
<div> <Container maxWidth="xl">
<div className="page-header"> <Box sx={{ mt: 4, mb: 4 }}>
<h1 className="page-title">Deployments</h1> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<button <Box>
className="btn-primary btn-sm" <Typography variant="h4" component="h1" gutterBottom>
onClick={() => { Deployments
setShowForm(!showForm) </Typography>
if (!showForm) loadArtefacts() <Typography variant="body2" color="text.secondary">
}}> Manage software deployments to devices
{showForm ? "Cancel" : "New Deployment"} </Typography>
</button> </Box>
</div> <Button
variant="contained"
startIcon={<Add />}
onClick={() => {
loadArtefacts()
.then(() => {})
.catch(() => {})
setDialogOpen(true)
}}
>
New Deployment
</Button>
</Box>
{msg && <p className={msg.startsWith("Failed") ? "msg-error" : "msg-success"}>{msg}</p>} <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create Deployment</DialogTitle>
{showForm && ( <DialogContent>
<div className="card form-section"> <DialogContentText sx={{ mb: 2 }}>
<h3>Create Deployment</h3> Create a new deployment to push updates to devices.
<form className="dep-form" onSubmit={handleCreate}> </DialogContentText>
<label> <Box component="form" sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
Name <input value={name} onChange={(e) => setName(e.target.value)} required /> <TextField
</label> autoFocus
<label> margin="dense"
Version id="name"
<select value={artefactId} onChange={(e) => setArtefactId(e.target.value)} required> label="Name"
<option value="">-- Select --</option> fullWidth
{artefacts.map((a) => ( variant="outlined"
<option key={a.id} value={a.id}> value={name}
{a.version} onChange={(e) => setName(e.target.value)}
</option> required
))}
</select>
</label>
<label>
Target
<select value={targetType} onChange={(e) => setTargetType(e.target.value)}>
<option value="all">All Devices</option>
<option value="group">By Group</option>
<option value="device">Specific Devices</option>
</select>
</label>
{targetType !== "all" && (
<label>
Target Spec (JSON)
<input
value={targetSpec}
onChange={(e) => setTargetSpec(e.target.value)}
placeholder={
targetType === "group"
? '{"groupName":"default"}'
: '{"deviceIds":["id1","id2"]}'
}
/>
</label>
)}
<label>
Strategy
<select value={rolloutStrategy} onChange={(e) => setRolloutStrategy(e.target.value)}>
<option value="immediate">Immediate</option>
<option value="staged">Staged</option>
</select>
</label>
<label>
Max Concurrent{" "}
<input
type="number"
value={maxConcurrent}
onChange={(e) => setMaxConcurrent(Number(e.target.value))}
min={1}
max={500}
/> />
</label> <FormControl fullWidth>
<button type="submit" className="btn-primary btn-sm" disabled={creating}> <InputLabel id="artefact-label">Version</InputLabel>
<Select
labelId="artefact-label"
id="artefact"
value={artefactId}
label="Version"
onChange={(e) => setArtefactId(e.target.value)}
required
>
{artefacts.map((a) => (
<MenuItem key={a.id} value={a.id}>
{a.version}
</MenuItem>
))}
</Select>
</FormControl>
<TextField
margin="dense"
id="target"
label="Target Groups"
fullWidth
variant="outlined"
placeholder="Comma-separated group names (e.g., default, production)"
value={targetGroups}
onChange={(e) => setTargetGroups(e.target.value)}
helperText="Leave empty to target all devices"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? "Creating..." : "Create"} {creating ? "Creating..." : "Create"}
</button> </Button>
</form> </DialogActions>
</div> </Dialog>
)}
<Paper elevation={1}>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Version</TableCell>
<TableCell>Progress</TableCell>
<TableCell>Status</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} align="center">
Loading...
</TableCell>
</TableRow>
) : deployments.length === 0 ? (
<TableRow>
<TableCell colSpan={6} align="center">
No deployments yet. Create the first one above.
</TableCell>
</TableRow>
) : (
deployments.map((d) => (
<TableRow key={d.id}>
<TableCell sx={{ fontWeight: 'medium' }}>{d.name}</TableCell>
<TableCell>{d.artefactVersion}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box sx={{ flex: 1 }}>
<LinearProgress
variant="determinate"
value={d.totalDevices > 0 ? (d.completedDevices / d.totalDevices) * 100 : 0}
/>
</Box>
<Typography variant="body2" color="text.secondary">
{d.completedDevices}/{d.totalDevices}
</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={d.status}
size="small"
color={getStatusColor(d.status) as any}
icon={getStatusIcon(d.status)}
/>
</TableCell>
<TableCell>{new Date(d.createdAt).toLocaleDateString()}</TableCell>
<TableCell align="right">
{d.status === "paused" && (
<Button
size="small"
variant="outlined"
startIcon={<PlayArrow />}
onClick={() => activate(d.id)}
>
Resume
</Button>
)}
{d.status === "active" && (
<Button
size="small"
variant="outlined"
startIcon={<Pause />}
onClick={() => pause(d.id)}
>
Pause
</Button>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Version</th>
<th>Target</th>
<th>Strategy</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="td-empty">
Loading...
</td>
</tr>
) : deployments.length === 0 ? (
<tr>
<td colSpan={7} className="td-empty">
No deployments yet.
</td>
</tr>
) : (
deployments.map((d) => (
<tr key={d.id}>
<td>
<strong>{d.name}</strong>
</td>
<td>{d.artefactVersion}</td>
<td>{d.targetType}</td>
<td>{d.rolloutStrategy}</td>
<td>
<span className={`badge badge-${d.status}`}>{d.status}</span>
</td>
<td>{new Date(d.createdAt).toLocaleDateString()}</td>
<td className="actions-cell">
{d.status === "draft" && (
<button className="btn-action" onClick={() => activate(d.id)}>
Activate
</button>
)}
{d.status === "active" && (
<button className="btn-action btn-pause" onClick={() => pause(d.id)}>
Pause
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
{totalPages > 1 && ( {totalPages > 1 && (
<div className="pagination"> <Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}> <Pagination
Prev count={totalPages}
</button> page={page}
<span> onChange={(_, value) => setPage(value)}
Page {page} of {totalPages} color="primary"
</span> />
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}> </Box>
Next
</button>
</div>
)} )}
</div> </Box>
</div> </Container>
) )
} }
function getStatusIcon(status: string) {
switch (status) {
case 'active': return <PlayArrow fontSize="small" />;
case 'paused': return <Pause fontSize="small" />;
case 'completed': return <CheckCircle fontSize="small" />;
case 'failed': return <Cancel fontSize="small" />;
default: return <Schedule fontSize="small" />;
}
}
-81
View File
@@ -1,81 +0,0 @@
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: #1a1a2e;
}
.login-form {
background: #fff;
padding: 40px;
border-radius: 8px;
width: 360px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.login-form h1 {
margin: 0 0 4px;
font-size: 22px;
color: #e94560;
}
.login-subtitle {
margin: 0 0 24px;
color: #888;
font-size: 13px;
}
.login-error {
background: #fff0f0;
color: #c00;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 16px;
font-size: 13px;
}
.login-form label {
display: block;
margin-bottom: 16px;
font-size: 13px;
color: #333;
}
.login-form input {
display: block;
width: 100%;
margin-top: 4px;
padding: 10px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.login-form input:focus {
outline: none;
border-color: #e94560;
box-shadow: 0 0 0 2px rgba(233, 69, 96, 0.15);
}
.btn-primary {
width: 100%;
padding: 10px;
background: #e94560;
color: #fff;
border: none;
border-radius: 4px;
font-size: 14px;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover {
background: #d63850;
}
.btn-primary:disabled {
background: #ccc;
cursor: not-allowed;
}
+84 -50
View File
@@ -1,64 +1,98 @@
import { useState } from 'react'; import { useState, type SubmitEvent } from "react"
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom"
import { api, setToken } from '../api/client'; import { login } from "@/api/auth"
import './Login.css'; import { Container, Paper, Box, TextField, Button, Typography, Alert } from "@mui/material"
export default function Login() { export default function Login() {
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 [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const navigate = useNavigate(); const navigate = useNavigate()
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault(); e.preventDefault()
setError(''); setError("")
setLoading(true); setLoading(true)
try { try {
const res = await api.post<{ token: string }>('/auth/login', { username, password }); const res = await login({ username, password })
setToken(res.token); console.log(res.token)
navigate('/dashboard'); navigate("/dashboard")
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Login failed'); setError(err instanceof Error ? err.message : "Login failed")
} finally { } finally {
setLoading(false); setLoading(false)
} }
} }
return ( return (
<div className="login-page"> <Box
<form className="login-form" onSubmit={handleSubmit}> sx={{
<h1>OTA Manager</h1> minHeight: "100vh",
<p className="login-subtitle">Sign in to manage updates</p> display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.50",
}}>
<Container maxWidth="sm">
<Paper
elevation={3}
sx={{
p: 4,
display: "flex",
flexDirection: "column",
alignItems: "center",
}}>
<Typography component="h1" variant="h4" sx={{ mb: 2 }}>
Pipely
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 4 }}>
Sign in to manage updates
</Typography>
{error && <div className="login-error">{error}</div>} {error && (
<Alert severity="error" sx={{ width: "100%", mb: 2 }}>
{error}
</Alert>
)}
<label> <Box component="form" onSubmit={handleSubmit} sx={{ width: "100%" }}>
Username <TextField
<input margin="normal"
type="text" required
value={username} fullWidth
onChange={e => setUsername(e.target.value)} id="username"
autoFocus label="Username"
required name="username"
/> autoComplete="username"
</label> autoFocus
value={username}
<label> onChange={(e) => setUsername(e.target.value)}
Password />
<input <TextField
type="password" margin="normal"
value={password} required
onChange={e => setPassword(e.target.value)} fullWidth
required name="password"
/> label="Password"
</label> type="password"
id="password"
<button type="submit" disabled={loading} className="btn-primary"> autoComplete="current-password"
{loading ? 'Signing in...' : 'Sign In'} value={password}
</button> onChange={(e) => setPassword(e.target.value)}
</form> />
</div> <Button
); type="submit"
fullWidth
variant="contained"
sx={{ mt: 3, mb: 2 }}
disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
</Box>
</Paper>
</Container>
</Box>
)
} }
-32
View File
@@ -1,32 +0,0 @@
.user-form {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.user-form input,
.user-form select {
padding: 7px 10px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
}
.pw-inline {
display: flex;
gap: 6px;
align-items: center;
}
.pw-inline input {
padding: 4px 8px;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 12px;
}
.btn-danger { background: #c0392b; }
.btn-danger:hover { background: #a93226; }
.btn-cancel { background: #888; }
.btn-cancel:hover { background: #666; }
+233 -120
View File
@@ -1,149 +1,262 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, type MouseEvent } from "react"
import { api } from '../api/client'; import { getUsers, createUser, deleteUser, type User } from "@/api/users"
import './Users.css'; import {
Box,
interface AdminUser { Button,
id: number; Container,
username: string; Dialog,
role: string; DialogActions,
createdAt: string; DialogContent,
} DialogContentText,
DialogTitle,
FormControl,
IconButton,
InputLabel,
MenuItem,
Paper,
Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography,
Chip,
Snackbar,
Alert,
} from "@mui/material"
import { Add, Delete } from "@mui/icons-material"
export default function Users() { export default function Users() {
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
// Create form const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [username, setUsername] = useState(''); const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null)
const [password, setPassword] = useState(''); const [username, setUsername] = useState("")
const [role, setRole] = useState('admin'); const [password, setPassword] = useState("")
const [creating, setCreating] = useState(false); const [role, setRole] = useState<"admin" | "user">("admin")
const [msg, setMsg] = useState(''); const [creating, setCreating] = useState(false)
const [snackbar, setSnackbar] = useState<{
// Change password state open: boolean
const [changingId, setChangingId] = useState<number | null>(null); message: string
const [newPassword, setNewPassword] = useState(''); severity: "success" | "error"
}>({
open: false,
message: "",
severity: "success",
})
async function load() { async function load() {
setLoading(true); setLoading(true)
try { try {
const res = await api.get<{ data: AdminUser[] }>('/admin/users'); const res = await getUsers({ pageSize: 100 })
setUsers(res.data); setUsers(res.data)
} catch (err) { } catch (err) {
console.error(err); console.error(err)
setSnackbar({ open: true, message: "Failed to load users", severity: "error" })
} finally { } finally {
setLoading(false); setLoading(false)
} }
} }
useEffect(() => { load(); }, []); useEffect(() => {
load()
.then(() => {})
.catch((error: Error) => {
console.error(error)
})
}, [])
async function handleCreate(e: React.FormEvent) { async function handleCreate(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault(); e.preventDefault()
setCreating(true); setCreating(true)
setMsg('');
try { try {
await api.post('/admin/users', { username, password, role }); await createUser({ username, password, role })
setUsername(''); setUsername("")
setPassword(''); setPassword("")
setRole('admin'); setRole("admin")
setMsg('User created.'); setDialogOpen(false)
load(); setSnackbar({ open: true, message: "User created successfully", severity: "success" })
load()
.then(() => {})
.catch(() => {})
} catch (err) { } catch (err) {
setMsg('Failed: ' + (err instanceof Error ? err.message : 'unknown')); setSnackbar({ open: true, message: "Failed to create user", severity: "error" })
} finally { } finally {
setCreating(false); setCreating(false)
} }
} }
async function handleDelete(id: number, uname: string) { function handleDeleteClick(id: string, name: string) {
if (!confirm(`Delete user "${uname}"?`)) return; setDeleteTarget({ id, name })
try { setDeleteDialogOpen(true)
await api.delete(`/admin/users/${id}`);
load();
} catch (err) {
alert('Failed to delete');
}
} }
async function handleChangePassword(id: number) { async function handleDelete() {
if (!newPassword) return; if (!deleteTarget) return
try { try {
await api.put(`/admin/users/${id}/password`, { newPassword }); await deleteUser(deleteTarget.id)
setChangingId(null); setSnackbar({
setNewPassword(''); open: true,
setMsg('Password changed.'); message: `User "${deleteTarget.name}" deleted`,
severity: "success",
})
setDeleteDialogOpen(false)
setDeleteTarget(null)
load()
.then(() => {})
.catch(() => {})
} catch (err) { } catch (err) {
alert('Failed to change password'); setSnackbar({ open: true, message: "Failed to delete user", severity: "error" })
} }
} }
return ( return (
<div> <Container maxWidth="xl">
<h1 className="page-title">Admin Users</h1> <Box sx={{ mt: 4, mb: 4 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 3 }}>
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Admin Users
</Typography>
<Typography variant="body2" color="text.secondary">
Manage system administrators
</Typography>
</Box>
<Button variant="contained" startIcon={<Add />} onClick={() => setDialogOpen(true)}>
Add User
</Button>
</Box>
{msg && <p className={msg.startsWith('Failed') ? 'msg-error' : 'msg-success'}>{msg}</p>} <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create New User</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
Add a new administrator to the system.
</DialogContentText>
<Box component="form" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TextField
autoFocus
margin="dense"
id="username"
label="Username"
fullWidth
variant="outlined"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<TextField
margin="dense"
id="password"
label="Password"
type="password"
fullWidth
variant="outlined"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<FormControl fullWidth>
<InputLabel id="role-label">Role</InputLabel>
<Select
labelId="role-label"
id="role"
value={role}
label="Role"
onChange={(e) => setRole(e.target.value as "admin" | "user")}>
<MenuItem value="admin">Admin</MenuItem>
<MenuItem value="user">Operator</MenuItem>
</Select>
</FormControl>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? "Creating..." : "Create User"}
</Button>
</DialogActions>
</Dialog>
<div className="card form-section"> <Paper elevation={1}>
<h3>Add User</h3> <TableContainer>
<form className="user-form" onSubmit={handleCreate}> <Table>
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required /> <TableHead>
<input placeholder="Password" type="password" value={password} onChange={e => setPassword(e.target.value)} required /> <TableRow>
<select value={role} onChange={e => setRole(e.target.value)}> <TableCell>Username</TableCell>
<option value="admin">Admin</option> <TableCell>Role</TableCell>
<option value="operator">Operator</option> <TableCell>Created</TableCell>
</select> <TableCell align="right">Actions</TableCell>
<button type="submit" className="btn-primary btn-sm" disabled={creating}> </TableRow>
{creating ? 'Creating...' : 'Add User'} </TableHead>
</button> <TableBody>
</form> {loading ? (
</div> <TableRow>
<TableCell colSpan={4} align="center">
Loading...
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
No users found. Create the first one above.
</TableCell>
</TableRow>
) : (
users.map((u) => (
<TableRow key={u.id}>
<TableCell sx={{ fontWeight: "medium" }}>{u.username}</TableCell>
<TableCell>
<Chip label={u.role} size="small" />
</TableCell>
<TableCell>{new Date(u.createdAt).toLocaleDateString()}</TableCell>
<TableCell align="right">
<IconButton
color="error"
onClick={() => handleDeleteClick(u.id, u.username)}>
<Delete />
</IconButton>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
<div className="card"> <Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<table className="data-table"> <DialogTitle>Delete User</DialogTitle>
<thead> <DialogContent>
<tr> <DialogContentText>
<th>Username</th> Are you sure you want to delete user "{deleteTarget?.name}"? This action cannot be
<th>Role</th> undone.
<th>Created</th> </DialogContentText>
<th>Actions</th> </DialogContent>
</tr> <DialogActions>
</thead> <Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
<tbody> <Button onClick={handleDelete} color="error">
{loading ? ( Delete
<tr><td colSpan={4} className="td-empty">Loading...</td></tr> </Button>
) : users.length === 0 ? ( </DialogActions>
<tr><td colSpan={4} className="td-empty">No users.</td></tr> </Dialog>
) : users.map(u => (
<tr key={u.id}> <Snackbar
<td><strong>{u.username}</strong></td> open={snackbar.open}
<td><span className="badge">{u.role}</span></td> autoHideDuration={6000}
<td>{new Date(u.createdAt).toLocaleDateString()}</td> onClose={() => setSnackbar({ ...snackbar, open: false })}>
<td className="actions-cell"> <Alert
{changingId === u.id ? ( severity={snackbar.severity}
<span className="pw-inline"> onClose={() => setSnackbar({ ...snackbar, open: false })}
<input sx={{ width: "100%" }}>
type="password" {snackbar.message}
placeholder="New password" </Alert>
value={newPassword} </Snackbar>
onChange={e => setNewPassword(e.target.value)} </Box>
size={16} </Container>
/> )
<button className="btn-action" onClick={() => handleChangePassword(u.id)}>Save</button>
<button className="btn-action btn-cancel" onClick={() => setChangingId(null)}>Cancel</button>
</span>
) : (
<>
<button className="btn-action" onClick={() => setChangingId(u.id)}>Change PW</button>
<button className="btn-action btn-danger" onClick={() => handleDelete(u.id, u.username)}>Delete</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
} }
+72
View File
@@ -0,0 +1,72 @@
import type { ComponentType, ReactNode } from "react"
import { createBrowserRouter, Navigate } from "react-router-dom"
import ErrorPage from "@/components/ErrorPage"
import Layout from "@/components/Layout"
import EmptyLayout from "@/components/EmptyLayout"
import { isAuthenticated } from "@/store/auth-slice"
import { useAppSelector } from "@/store"
function lazy<T extends { default: ComponentType<unknown> }>(importer: () => Promise<T>) {
return async () => {
const module = await importer()
return { Component: module.default }
}
}
const hydrateFallbackElement = <div className="px-4 py-6 text-neutral-500">Loading...</div>
function RequireAuth({ children }: { children: ReactNode }) {
const authenticated = useAppSelector(isAuthenticated)
if (!authenticated) return <Navigate to="/login" replace />
return <>{children}</>
}
const router = createBrowserRouter(
[
{
path: "/",
element: <Navigate to="/dashboard" replace />,
},
{
element: (
<RequireAuth>
<Layout />
</RequireAuth>
),
hydrateFallbackElement,
errorElement: <ErrorPage />,
children: [
{
path: "dashboard",
lazy: lazy(() => import("@/pages/Dashboard")),
},
{
path: "artefacts",
lazy: lazy(() => import("@/pages/Artefacts")),
},
{
path: "deployments",
lazy: lazy(() => import("@/pages/Deployments")),
},
{
path: "users",
lazy: lazy(() => import("@/pages/Users")),
},
],
},
{
element: <EmptyLayout />,
hydrateFallbackElement,
errorElement: <ErrorPage />,
children: [
{
path: "login",
lazy: lazy(() => import("@/pages/Login")),
},
],
},
],
{ basename: "/" }
)
export default router
+7
View File
@@ -0,0 +1,7 @@
import axios from "axios"
import dayjs from "@/lib/dayjs"
export default axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: dayjs.duration({ seconds: 10 }).asMilliseconds(),
})
+24
View File
@@ -0,0 +1,24 @@
import { createSlice, type PayloadAction } from "@reduxjs/toolkit"
export interface AuthState {
token: string
}
const initialState: AuthState = {
token: "",
}
const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setToken(state, action: PayloadAction<string>) {
state.token = action.payload
},
},
})
export const { setToken } = authSlice.actions
export const authReducer = authSlice.reducer
export const isAuthenticated = (state: { auth: AuthState }) => state.auth.token !== ""
+5
View File
@@ -0,0 +1,5 @@
import { useDispatch, useSelector } from "react-redux"
import type { AppDispatch, RootState } from "@/store"
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
+46
View File
@@ -0,0 +1,46 @@
import { configureStore, combineReducers } from "@reduxjs/toolkit"
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from "redux-persist"
import createWebStorage from "redux-persist/es/storage/createWebStorage"
import { authReducer } from "./auth-slice"
const storage = createWebStorage(import.meta.env.VITE_REDUX_STORAGE ?? "local")
const persistConfig = {
key: "root",
storage,
whitelist: ["auth"],
}
const rootReducer = combineReducers({
auth: authReducer,
})
const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
})
export const persistor = persistStore(store)
export default store
export type RootState = ReturnType<typeof rootReducer>
export type AppDispatch = typeof store.dispatch
export type AppStore = typeof store
export { useAppSelector, useAppDispatch } from "./hooks"
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string
readonly VITE_REDUX_STORAGE?: "local" | "session"
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+7 -2
View File
@@ -22,8 +22,13 @@
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/api/*": ["./src/api/*"],
"@/shared/*": ["./src/shared/*"]
} }
}, },
"include": ["src"] "include": ["src"]
} }
+12 -1
View File
@@ -1,7 +1,18 @@
{ {
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/api/*": ["./src/api/*"],
"@/shared/*": ["./src/shared/*"]
}
},
"files": [], "files": [],
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" } { "path": "./tsconfig.node.json" }
] ]
} }
+2 -1
View File
@@ -1,9 +1,10 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path' import path from 'path'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, 'src'), '@': path.resolve(__dirname, 'src'),