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"
"onixbyte.com/message-converter-manifest/internal/artefact"
"onixbyte.com/message-converter-manifest/internal/auth"
"onixbyte.com/message-converter-manifest/internal/config"
"onixbyte.com/message-converter-manifest/internal/database"
"onixbyte.com/message-converter-manifest/internal/delta"
"onixbyte.com/message-converter-manifest/internal/deployment"
"onixbyte.com/message-converter-manifest/internal/device"
"onixbyte.com/message-converter-manifest/internal/download"
"onixbyte.com/message-converter-manifest/internal/middleware"
"onixbyte.com/pipely/internal/artefact"
"onixbyte.com/pipely/internal/auth"
"onixbyte.com/pipely/internal/config"
"onixbyte.com/pipely/internal/database"
"onixbyte.com/pipely/internal/delta"
"onixbyte.com/pipely/internal/deployment"
"onixbyte.com/pipely/internal/device"
"onixbyte.com/pipely/internal/download"
"onixbyte.com/pipely/internal/middleware"
)
func main() {
+1 -1
View File
@@ -1,4 +1,4 @@
module onixbyte.com/message-converter-manifest
module onixbyte.com/pipely
go 1.26
+2 -2
View File
@@ -13,8 +13,8 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database"
"onixbyte.com/message-converter-manifest/internal/model"
"onixbyte.com/pipely/internal/database"
"onixbyte.com/pipely/internal/model"
)
type Handler struct {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strconv"
"github.com/gin-gonic/gin"
"onixbyte.com/message-converter-manifest/internal/model"
"onixbyte.com/pipely/internal/model"
)
// -- Admin user management handlers --
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"onixbyte.com/message-converter-manifest/internal/model"
"onixbyte.com/pipely/internal/model"
)
type Handler struct {
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database"
"onixbyte.com/message-converter-manifest/internal/model"
"onixbyte.com/pipely/internal/database"
"onixbyte.com/pipely/internal/model"
)
var (
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"gorm.io/gorm"
"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
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
"onixbyte.com/message-converter-manifest/internal/model"
"onixbyte.com/pipely/internal/model"
)
// -- Admin Users --
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database"
"onixbyte.com/message-converter-manifest/internal/model"
"onixbyte.com/pipely/internal/database"
"onixbyte.com/pipely/internal/model"
)
type Handler struct {
+2 -2
View File
@@ -8,8 +8,8 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database"
"onixbyte.com/message-converter-manifest/internal/model"
"onixbyte.com/pipely/internal/database"
"onixbyte.com/pipely/internal/model"
)
type Handler struct {
+2 -2
View File
@@ -12,8 +12,8 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"onixbyte.com/message-converter-manifest/internal/database"
"onixbyte.com/message-converter-manifest/internal/middleware"
"onixbyte.com/pipely/internal/database"
"onixbyte.com/pipely/internal/middleware"
)
// Handler manages artefact downloads with bandwidth throttling and Range support.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"onixbyte.com/message-converter-manifest/internal/auth"
"onixbyte.com/pipely/internal/auth"
)
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
*.local
# Environment variables
.env
.env.local
.env.*.local
!.env.example
# Editor directories and files
.vscode/*
!.vscode/extensions.json
+9 -3
View File
@@ -6,12 +6,18 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(["dist"]),
{
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
extends: [
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,
reactRefresh.configs.vite,
],
+14 -1
View File
@@ -10,9 +10,21 @@
"preview": "vite preview"
},
"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-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": {
"@eslint/js": "^10.0.1",
@@ -25,6 +37,7 @@
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"prettier": "^3.9.4",
"shadcn": "^4.13.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.62.0",
"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 { setToken } from '../api/client';
import './Layout.css';
import { NavLink, useNavigate, Outlet } from "react-router-dom"
import { useDispatch } from "react-redux"
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() {
const navigate = useNavigate();
const navigate = useNavigate()
const dispatch = useDispatch()
function handleLogout() {
setToken(null);
navigate('/');
dispatch(setToken(""))
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 (
<div className="app-layout">
<aside className="sidebar">
<div className="sidebar-brand">
<h2>OTA Manager</h2>
</div>
<nav>
<NavLink to="/dashboard" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>
Dashboard
</NavLink>
<NavLink to="/artefacts" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>
Versions
</NavLink>
<NavLink to="/deployments" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>
Deployments
</NavLink>
<NavLink to="/users" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>
Admin Users
</NavLink>
</nav>
<div className="sidebar-footer">
<button className="btn-logout" onClick={handleLogout}>Logout</button>
</div>
</aside>
<main className="content">
<Box sx={{ display: "flex" }}>
<AppBar
position="fixed"
sx={{
width: `calc(100% - ${drawerWidth}px)`,
ml: `${drawerWidth}px`,
}}>
<Toolbar>
<Typography variant="h6" noWrap component="div">
OTA Manager
</Typography>
</Toolbar>
</AppBar>
<Drawer
sx={{
width: drawerWidth,
flexShrink: 0,
"& .MuiDrawer-paper": {
width: drawerWidth,
boxSizing: "border-box",
},
}}
variant="permanent"
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 />
</main>
</div>
);
</Box>
</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 { 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 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(
<StrictMode>
<App />
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ThemeProvider theme={theme}>
<CssBaseline />
<RouterProvider router={router} />
</ThemeProvider>
</PersistGate>
</Provider>
</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;
}
+213 -100
View File
@@ -1,17 +1,29 @@
import { useEffect, useState } from "react"
import { api } from "../api/client"
import "./Artefacts.css"
interface Artefact {
id: number
version: string
fileName: string
fileSize: number
sha256Hash: string
isDelta: boolean
deltaRef: string
createdAt: string
}
import { useEffect, useState, type MouseEvent } from "react"
import { getArtefacts, uploadArtefact, deleteArtefact, type Artefact } from "@/api/artefacts"
import {
Box,
Button,
Container,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Paper,
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
@@ -20,17 +32,26 @@ export default function Artefacts() {
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
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 [version, setVersion] = useState("")
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() {
setLoading(true)
try {
const res = await api.get<{ data: Artefact[]; total: number }>(
`/artefacts?page=${page}&pageSize=${PAGE_SIZE}`
)
const res = await getArtefacts({ page, pageSize: PAGE_SIZE })
setArtefacts(res.data)
setTotal(res.total)
} catch (err) {
@@ -42,126 +63,218 @@ export default function Artefacts() {
useEffect(() => {
load()
.then(() => {})
.catch(() => {})
}, [page])
async function handleUpload(e: React.FormEvent) {
async function handleUpload(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault()
if (!file || !version) return
const form = new FormData()
form.append("version", version)
form.append("file", file)
setUploading(true)
setUploadMsg("")
try {
await api.post("/artefacts", form)
await uploadArtefact(file)
setVersion("")
setFile(null)
setUploadMsg("Version uploaded successfully.")
load()
setDialogOpen(false)
setSnackbar({ open: true, message: "Version uploaded successfully", severity: "success" })
await load()
} 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 {
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)
return (
<div>
<h1 className="page-title">Versions</h1>
<Container maxWidth="xl">
<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">
<h3>Create New Version</h3>
<form className="upload-form" onSubmit={handleUpload}>
<input
type="text"
placeholder="Version (e.g. 1.2.0)"
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Upload New Version</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
Upload a new version for device updates.
</DialogContentText>
<Box component="form" sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1 }}>
<TextField
autoFocus
margin="dense"
id="version"
label="Version"
fullWidth
variant="outlined"
placeholder="e.g. 1.2.0"
value={version}
onChange={(e) => setVersion(e.target.value)}
required
/>
<input
<TextField
margin="dense"
id="file"
label="File"
type="file"
accept=".jar,.patch,.zip"
onChange={(e) => setFile(e.target.files?.[0] || null)}
fullWidth
variant="outlined"
slotProps={{
inputLabel: { shrink: true },
htmlInput: { accept: ".jar,.patch,.zip" },
}}
onChange={(e) => setFile((e.target as HTMLInputElement).files?.[0] || null)}
required
/>
<button type="submit" className="btn-primary btn-sm" disabled={uploading}>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleUpload} disabled={uploading}>
{uploading ? "Uploading..." : "Upload"}
</button>
</form>
{uploadMsg && (
<p className={uploadMsg.includes("failed") ? "msg-error" : "msg-success"}>{uploadMsg}</p>
)}
</div>
</Button>
</DialogActions>
</Dialog>
<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>
<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 ? (
<tr>
<td colSpan={6} className="td-empty">
<TableRow>
<TableCell colSpan={6} align="center">
Loading...
</td>
</tr>
</TableCell>
</TableRow>
) : artefacts.length === 0 ? (
<tr>
<td colSpan={6} className="td-empty">
<TableRow>
<TableCell colSpan={6} align="center">
No versions yet. Upload the first one above.
</td>
</tr>
</TableCell>
</TableRow>
) : (
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>
<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>
))
)}
</tbody>
</table>
</TableBody>
</Table>
</TableContainer>
</Paper>
{totalPages > 1 && (
<div className="pagination">
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Prev
</button>
<span>
Page {page} of {totalPages}
</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Next
</button>
</div>
<Box sx={{ display: "flex", justifyContent: "flex-end", mt: 3 }}>
<Pagination
count={totalPages}
page={page}
onChange={(_, value) => setPage(value)}
color="primary"
/>
</Box>
)}
</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 { api } from "../api/client"
import "./Dashboard.css"
import { Paper, Typography, Box, Chip, CircularProgress } from "@mui/material"
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 {
totalDevices: number
@@ -22,17 +25,13 @@ export default function Dashboard() {
async function load() {
try {
const [devices, artefacts, deployments] = await Promise.all([
api.get<{ total: number; data: { status: string }[] }>("/devices?pageSize=1"),
api.get<{ total: number }>("/artefacts?pageSize=1"),
api.get<{ total: number; data: { status: string }[] }>("/deployments?pageSize=100"),
getDevices({ pageSize: 1000 }),
getArtefacts({ pageSize: 1000 }),
getDeployments({ pageSize: 1000 }),
])
const online = devices.data
? devices.data.filter((d: { status: string }) => d.status === "online").length
: 0
const active = deployments.data
? deployments.data.filter((d: { status: string }) => d.status === "active").length
: 0
const online = devices.data.filter((d) => d.status === "online").length
const active = deployments.data.filter((d) => d.status === "active").length
setStats({
totalDevices: devices.total,
@@ -47,28 +46,89 @@ export default function Dashboard() {
}
}
load()
.then(() => {})
.catch(() => {})
}, [])
if (loading) return <div className="page-loading">Loading...</div>
const cards = [
{ label: "Total Devices", value: stats.totalDevices, colour: "#4a90d9" },
{ label: "Online Devices", value: stats.onlineDevices, colour: "#27ae60" },
{ label: "Active Deployments", value: stats.activeDeployments, colour: "#e67e22" },
{ label: "Artefact Versions", value: stats.totalArtefacts, colour: "#8e44ad" },
{
label: "Total Devices",
value: stats.totalDevices,
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 (
<div>
<h1 className="page-title">Dashboard</h1>
<div className="stats-grid">
{cards.map((c) => (
<div key={c.label} className="stat-card" style={{ borderTopColor: c.colour }}>
<div className="stat-value">{c.value}</div>
<div className="stat-label">{c.label}</div>
</div>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h4" component="h1" gutterBottom>
Dashboard
</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 3 }}>
{cards.map((c, index) => (
<Box key={index} sx={{ minWidth: 240, flex: 1 }}>
<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>
</div>
</Box>
</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; }
+241 -180
View File
@@ -1,23 +1,42 @@
import { type FormEvent, useEffect, useState } from "react"
import { api } from "../api/client"
import "./Deployments.css"
import { useEffect, useState, type MouseEvent } from "react"
import { getDeployments, createDeployment, updateDeployment, type Deployment } from "@/api/deployments"
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 {
id: number
artefactId: number
artefactVersion: string
name: string
targetType: string
rolloutStrategy: string
maxConcurrent: number
status: string
createdAt: string
}
interface Artefact {
id: number
version: string
}
const PAGE_SIZE = 20
export default function Deployments() {
const [deployments, setDeployments] = useState<Deployment[]>([])
@@ -25,24 +44,17 @@ export default function Deployments() {
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
// Create form
const [showForm, setShowForm] = useState(false)
const [artefacts, setArtefacts] = useState<Artefact[]>([])
const [dialogOpen, setDialogOpen] = useState(false)
const [artefacts, setArtefacts] = useState<{ id: string; version: string }[]>([])
const [name, setName] = useState("")
const [artefactId, setArtefactId] = useState("")
const [targetType, setTargetType] = useState("all")
const [targetSpec, setTargetSpec] = useState("")
const [rolloutStrategy, setRolloutStrategy] = useState("immediate")
const [maxConcurrent, setMaxConcurrent] = useState(50)
const [targetGroups, setTargetGroups] = useState("")
const [creating, setCreating] = useState(false)
const [msg, setMsg] = useState("")
async function load() {
setLoading(true)
try {
const res = await api.get<{ data: Deployment[]; total: number }>(
`/deployments?page=${page}&pageSize=20`
)
const res = await getDeployments({ page, pageSize: PAGE_SIZE })
setDeployments(res.data)
setTotal(res.total)
} catch (err) {
@@ -54,213 +66,262 @@ export default function Deployments() {
useEffect(() => {
load()
}, [load, page])
.then(() => {})
.catch(() => {})
}, [page])
async function loadArtefacts() {
try {
const res = await api.get<{ data: Artefact[] }>("/artefacts?pageSize=200")
const res = await getArtefacts({ pageSize: 200 })
setArtefacts(res.data)
} catch (err) {
console.error(err)
}
}
async function handleCreate(e: FormEvent) {
async function handleCreate(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault()
setCreating(true)
setMsg("")
try {
await api.post("/deployments", {
artefactId: parseInt(artefactId),
await createDeployment({
name,
targetType,
targetSpec: targetSpec || "{}",
rolloutStrategy,
maxConcurrent,
artefactId,
targetGroups: targetGroups.split(",").map(g => g.trim()).filter(Boolean),
})
setShowForm(false)
setDialogOpen(false)
setName("")
setArtefactId("")
setTargetSpec("")
setMsg("Deployment created. Activate it to begin rollout.")
setTargetGroups("")
load()
.then(() => {})
.catch(() => {})
} catch (err) {
setMsg("Failed: " + (err instanceof Error ? err.message : "unknown"))
console.error("Failed to create deployment:", err)
} finally {
setCreating(false)
}
}
async function activate(id: number) {
async function activate(id: string) {
try {
await api.post(`/deployments/${id}/activate`)
await updateDeployment(id, { status: "active" })
load()
.then(() => {})
.catch(() => {})
} catch (err) {
alert("Failed to activate")
console.error("Failed to activate")
}
}
async function pause(id: number) {
async function pause(id: string) {
try {
await api.post(`/deployments/${id}/pause`)
await updateDeployment(id, { status: "paused" })
load()
.then(() => {})
.catch(() => {})
} 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 (
<div>
<div className="page-header">
<h1 className="page-title">Deployments</h1>
<button
className="btn-primary btn-sm"
<Container maxWidth="xl">
<Box sx={{ mt: 4, mb: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Deployments
</Typography>
<Typography variant="body2" color="text.secondary">
Manage software deployments to devices
</Typography>
</Box>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => {
setShowForm(!showForm)
if (!showForm) loadArtefacts()
}}>
{showForm ? "Cancel" : "New Deployment"}
</button>
</div>
loadArtefacts()
.then(() => {})
.catch(() => {})
setDialogOpen(true)
}}
>
New Deployment
</Button>
</Box>
{msg && <p className={msg.startsWith("Failed") ? "msg-error" : "msg-success"}>{msg}</p>}
{showForm && (
<div className="card form-section">
<h3>Create Deployment</h3>
<form className="dep-form" onSubmit={handleCreate}>
<label>
Name <input value={name} onChange={(e) => setName(e.target.value)} required />
</label>
<label>
Version
<select value={artefactId} onChange={(e) => setArtefactId(e.target.value)} required>
<option value="">-- Select --</option>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create Deployment</DialogTitle>
<DialogContent>
<DialogContentText sx={{ mb: 2 }}>
Create a new deployment to push updates to devices.
</DialogContentText>
<Box component="form" sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
autoFocus
margin="dense"
id="name"
label="Name"
fullWidth
variant="outlined"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<FormControl fullWidth>
<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) => (
<option key={a.id} value={a.id}>
<MenuItem key={a.id} value={a.id}>
{a.version}
</option>
</MenuItem>
))}
</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"]}'
}
</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"
/>
</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>
<button type="submit" className="btn-primary btn-sm" disabled={creating}>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreate} disabled={creating}>
{creating ? "Creating..." : "Create"}
</button>
</form>
</div>
)}
</Button>
</DialogActions>
</Dialog>
<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>
<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 ? (
<tr>
<td colSpan={7} className="td-empty">
<TableRow>
<TableCell colSpan={6} align="center">
Loading...
</td>
</tr>
</TableCell>
</TableRow>
) : deployments.length === 0 ? (
<tr>
<td colSpan={7} className="td-empty">
No deployments yet.
</td>
</tr>
<TableRow>
<TableCell colSpan={6} align="center">
No deployments yet. Create the first one above.
</TableCell>
</TableRow>
) : (
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>
<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 className="btn-action btn-pause" onClick={() => pause(d.id)}>
<Button
size="small"
variant="outlined"
startIcon={<Pause />}
onClick={() => pause(d.id)}
>
Pause
</button>
</Button>
)}
</td>
</tr>
</TableCell>
</TableRow>
))
)}
</tbody>
</table>
</TableBody>
</Table>
</TableContainer>
</Paper>
{totalPages > 1 && (
<div className="pagination">
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Prev
</button>
<span>
Page {page} of {totalPages}
</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Next
</button>
</div>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 3 }}>
<Pagination
count={totalPages}
page={page}
onChange={(_, value) => setPage(value)}
color="primary"
/>
</Box>
)}
</div>
</div>
</Box>
</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;
}
+79 -45
View File
@@ -1,64 +1,98 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, setToken } from '../api/client';
import './Login.css';
import { useState, type SubmitEvent } from "react"
import { useNavigate } from "react-router-dom"
import { login } from "@/api/auth"
import { Container, Paper, Box, TextField, Button, Typography, Alert } from "@mui/material"
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
setError("")
setLoading(true)
try {
const res = await api.post<{ token: string }>('/auth/login', { username, password });
setToken(res.token);
navigate('/dashboard');
const res = await login({ username, password })
console.log(res.token)
navigate("/dashboard")
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
setError(err instanceof Error ? err.message : "Login failed")
} finally {
setLoading(false);
setLoading(false)
}
}
return (
<div className="login-page">
<form className="login-form" onSubmit={handleSubmit}>
<h1>OTA Manager</h1>
<p className="login-subtitle">Sign in to manage updates</p>
<Box
sx={{
minHeight: "100vh",
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>
Username
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
<Box component="form" onSubmit={handleSubmit} sx={{ width: "100%" }}>
<TextField
margin="normal"
required
fullWidth
id="username"
label="Username"
name="username"
autoComplete="username"
autoFocus
required
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</label>
<label>
Password
<input
<TextField
margin="normal"
required
fullWidth
name="password"
label="Password"
type="password"
id="password"
autoComplete="current-password"
value={password}
onChange={e => setPassword(e.target.value)}
required
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button type="submit" disabled={loading} className="btn-primary">
{loading ? 'Signing in...' : 'Sign In'}
</button>
</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; }
+232 -119
View File
@@ -1,149 +1,262 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import './Users.css';
interface AdminUser {
id: number;
username: string;
role: string;
createdAt: string;
}
import { useEffect, useState, type MouseEvent } from "react"
import { getUsers, createUser, deleteUser, type User } from "@/api/users"
import {
Box,
Button,
Container,
Dialog,
DialogActions,
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() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
// Create form
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('admin');
const [creating, setCreating] = useState(false);
const [msg, setMsg] = useState('');
// Change password state
const [changingId, setChangingId] = useState<number | null>(null);
const [newPassword, setNewPassword] = useState('');
const [users, setUsers] = useState<User[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<{ id: string; name: string } | null>(null)
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [role, setRole] = useState<"admin" | "user">("admin")
const [creating, setCreating] = useState(false)
const [snackbar, setSnackbar] = useState<{
open: boolean
message: string
severity: "success" | "error"
}>({
open: false,
message: "",
severity: "success",
})
async function load() {
setLoading(true);
setLoading(true)
try {
const res = await api.get<{ data: AdminUser[] }>('/admin/users');
setUsers(res.data);
const res = await getUsers({ pageSize: 100 })
setUsers(res.data)
} catch (err) {
console.error(err);
console.error(err)
setSnackbar({ open: true, message: "Failed to load users", severity: "error" })
} finally {
setLoading(false);
setLoading(false)
}
}
useEffect(() => { load(); }, []);
useEffect(() => {
load()
.then(() => {})
.catch((error: Error) => {
console.error(error)
})
}, [])
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
setMsg('');
async function handleCreate(e: MouseEvent<HTMLButtonElement>) {
e.preventDefault()
setCreating(true)
try {
await api.post('/admin/users', { username, password, role });
setUsername('');
setPassword('');
setRole('admin');
setMsg('User created.');
load();
await createUser({ username, password, role })
setUsername("")
setPassword("")
setRole("admin")
setDialogOpen(false)
setSnackbar({ open: true, message: "User created successfully", severity: "success" })
load()
.then(() => {})
.catch(() => {})
} catch (err) {
setMsg('Failed: ' + (err instanceof Error ? err.message : 'unknown'));
setSnackbar({ open: true, message: "Failed to create user", severity: "error" })
} finally {
setCreating(false);
setCreating(false)
}
}
async function handleDelete(id: number, uname: string) {
if (!confirm(`Delete user "${uname}"?`)) return;
try {
await api.delete(`/admin/users/${id}`);
load();
} catch (err) {
alert('Failed to delete');
}
function handleDeleteClick(id: string, name: string) {
setDeleteTarget({ id, name })
setDeleteDialogOpen(true)
}
async function handleChangePassword(id: number) {
if (!newPassword) return;
async function handleDelete() {
if (!deleteTarget) return
try {
await api.put(`/admin/users/${id}/password`, { newPassword });
setChangingId(null);
setNewPassword('');
setMsg('Password changed.');
await deleteUser(deleteTarget.id)
setSnackbar({
open: true,
message: `User "${deleteTarget.name}" deleted`,
severity: "success",
})
setDeleteDialogOpen(false)
setDeleteTarget(null)
load()
.then(() => {})
.catch(() => {})
} catch (err) {
alert('Failed to change password');
setSnackbar({ open: true, message: "Failed to delete user", severity: "error" })
}
}
return (
<div>
<h1 className="page-title">Admin Users</h1>
<Container maxWidth="xl">
<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>}
<div className="card form-section">
<h3>Add User</h3>
<form className="user-form" onSubmit={handleCreate}>
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
<input placeholder="Password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<select value={role} onChange={e => setRole(e.target.value)}>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
</select>
<button type="submit" className="btn-primary btn-sm" disabled={creating}>
{creating ? 'Creating...' : 'Add User'}
</button>
</form>
</div>
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={4} className="td-empty">Loading...</td></tr>
) : users.length === 0 ? (
<tr><td colSpan={4} className="td-empty">No users.</td></tr>
) : users.map(u => (
<tr key={u.id}>
<td><strong>{u.username}</strong></td>
<td><span className="badge">{u.role}</span></td>
<td>{new Date(u.createdAt).toLocaleDateString()}</td>
<td className="actions-cell">
{changingId === u.id ? (
<span className="pw-inline">
<input
type="password"
placeholder="New password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
size={16}
<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
/>
<button className="btn-action" onClick={() => handleChangePassword(u.id)}>Save</button>
<button className="btn-action btn-cancel" onClick={() => setChangingId(null)}>Cancel</button>
</span>
<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>
<Paper elevation={1}>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell>Role</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading ? (
<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>
) : (
<>
<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>
</>
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>
))
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
</TableBody>
</Table>
</TableContainer>
</Paper>
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
<DialogTitle>Delete User</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete user "{deleteTarget?.name}"? 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>
)
}
+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
}
+6 -1
View File
@@ -22,7 +22,12 @@
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/api/*": ["./src/api/*"],
"@/shared/*": ["./src/shared/*"]
}
},
"include": ["src"]
+11
View File
@@ -1,4 +1,15 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/api/*": ["./src/api/*"],
"@/shared/*": ["./src/shared/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
+2 -1
View File
@@ -1,9 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),