Compare commits
3 Commits
5b0f33320e
...
163f5641e5
| Author | SHA1 | Date | |
|---|---|---|---|
|
163f5641e5
|
|||
|
3a6cd42361
|
|||
|
033082954a
|
+9
-9
@@ -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,4 +1,4 @@
|
|||||||
module onixbyte.com/message-converter-manifest
|
module onixbyte.com/pipely
|
||||||
|
|
||||||
go 1.26
|
go 1.26
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 --
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 --
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
Generated
+3047
-43
File diff suppressed because it is too large
Load Diff
-126
@@ -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; }
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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' }),
|
|
||||||
};
|
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}`)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import dayjs from "dayjs"
|
||||||
|
import duration from "dayjs/plugin/duration"
|
||||||
|
|
||||||
|
dayjs.extend(duration)
|
||||||
|
|
||||||
|
export default dayjs
|
||||||
+24
-2
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
<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}
|
value={version}
|
||||||
onChange={(e) => setVersion(e.target.value)}
|
onChange={(e) => setVersion(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<input
|
<TextField
|
||||||
|
margin="dense"
|
||||||
|
id="file"
|
||||||
|
label="File"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".jar,.patch,.zip"
|
fullWidth
|
||||||
onChange={(e) => setFile(e.target.files?.[0] || null)}
|
variant="outlined"
|
||||||
|
slotProps={{
|
||||||
|
inputLabel: { shrink: true },
|
||||||
|
htmlInput: { accept: ".jar,.patch,.zip" },
|
||||||
|
}}
|
||||||
|
onChange={(e) => setFile((e.target as HTMLInputElement).files?.[0] || null)}
|
||||||
required
|
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"}
|
{uploading ? "Uploading..." : "Upload"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</DialogActions>
|
||||||
{uploadMsg && (
|
</Dialog>
|
||||||
<p className={uploadMsg.includes("failed") ? "msg-error" : "msg-success"}>{uploadMsg}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
<Paper elevation={1}>
|
||||||
<table className="data-table">
|
<TableContainer>
|
||||||
<thead>
|
<Table>
|
||||||
<tr>
|
<TableHead>
|
||||||
<th>Version</th>
|
<TableRow>
|
||||||
<th>File</th>
|
<TableCell>Version</TableCell>
|
||||||
<th>Size</th>
|
<TableCell>Name</TableCell>
|
||||||
<th>Type</th>
|
<TableCell>Size</TableCell>
|
||||||
<th>SHA256</th>
|
<TableCell>Checksum</TableCell>
|
||||||
<th>Created</th>
|
<TableCell>Created</TableCell>
|
||||||
</tr>
|
<TableCell align="right">Actions</TableCell>
|
||||||
</thead>
|
</TableRow>
|
||||||
<tbody>
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<TableRow>
|
||||||
<td colSpan={6} className="td-empty">
|
<TableCell colSpan={6} align="center">
|
||||||
Loading...
|
Loading...
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
) : artefacts.length === 0 ? (
|
) : artefacts.length === 0 ? (
|
||||||
<tr>
|
<TableRow>
|
||||||
<td colSpan={6} className="td-empty">
|
<TableCell colSpan={6} align="center">
|
||||||
No versions yet. Upload the first one above.
|
No versions yet. Upload the first one above.
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
artefacts.map((a) => (
|
artefacts.map((a) => (
|
||||||
<tr key={a.id}>
|
<TableRow key={a.id}>
|
||||||
<td>
|
<TableCell sx={{ fontWeight: "medium" }}>{a.version}</TableCell>
|
||||||
<strong>{a.version}</strong>
|
<TableCell>
|
||||||
</td>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||||
<td>{a.fileName}</td>
|
<Archive fontSize="small" color="action" />
|
||||||
<td>{formatSize(a.fileSize)}</td>
|
{a.name}
|
||||||
<td>
|
</Box>
|
||||||
{a.isDelta ? (
|
</TableCell>
|
||||||
<span className="badge badge-delta">delta ({a.deltaRef})</span>
|
<TableCell>{formatSize(a.size)}</TableCell>
|
||||||
) : (
|
<TableCell>
|
||||||
<span className="badge badge-full">full</span>
|
<Box
|
||||||
)}
|
component="span"
|
||||||
</td>
|
sx={{
|
||||||
<td className="hash-cell" title={a.sha256Hash}>
|
fontFamily: "monospace",
|
||||||
{a.sha256Hash.slice(0, 12)}...
|
fontSize: 12,
|
||||||
</td>
|
cursor: "help",
|
||||||
<td>{new Date(a.createdAt).toLocaleDateString()}</td>
|
}}
|
||||||
</tr>
|
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>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
{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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
Deployments
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Manage software deployments to devices
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
startIcon={<Add />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowForm(!showForm)
|
loadArtefacts()
|
||||||
if (!showForm) loadArtefacts()
|
.then(() => {})
|
||||||
}}>
|
.catch(() => {})
|
||||||
{showForm ? "Cancel" : "New Deployment"}
|
setDialogOpen(true)
|
||||||
</button>
|
}}
|
||||||
</div>
|
>
|
||||||
|
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
|
||||||
|
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) => (
|
{artefacts.map((a) => (
|
||||||
<option key={a.id} value={a.id}>
|
<MenuItem key={a.id} value={a.id}>
|
||||||
{a.version}
|
{a.version}
|
||||||
</option>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</label>
|
</FormControl>
|
||||||
<label>
|
<TextField
|
||||||
Target
|
margin="dense"
|
||||||
<select value={targetType} onChange={(e) => setTargetType(e.target.value)}>
|
id="target"
|
||||||
<option value="all">All Devices</option>
|
label="Target Groups"
|
||||||
<option value="group">By Group</option>
|
fullWidth
|
||||||
<option value="device">Specific Devices</option>
|
variant="outlined"
|
||||||
</select>
|
placeholder="Comma-separated group names (e.g., default, production)"
|
||||||
</label>
|
value={targetGroups}
|
||||||
{targetType !== "all" && (
|
onChange={(e) => setTargetGroups(e.target.value)}
|
||||||
<label>
|
helperText="Leave empty to target all devices"
|
||||||
Target Spec (JSON)
|
|
||||||
<input
|
|
||||||
value={targetSpec}
|
|
||||||
onChange={(e) => setTargetSpec(e.target.value)}
|
|
||||||
placeholder={
|
|
||||||
targetType === "group"
|
|
||||||
? '{"groupName":"default"}'
|
|
||||||
: '{"deviceIds":["id1","id2"]}'
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</Box>
|
||||||
)}
|
</DialogContent>
|
||||||
<label>
|
<DialogActions>
|
||||||
Strategy
|
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||||
<select value={rolloutStrategy} onChange={(e) => setRolloutStrategy(e.target.value)}>
|
<Button onClick={handleCreate} disabled={creating}>
|
||||||
<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}>
|
|
||||||
{creating ? "Creating..." : "Create"}
|
{creating ? "Creating..." : "Create"}
|
||||||
</button>
|
</Button>
|
||||||
</form>
|
</DialogActions>
|
||||||
</div>
|
</Dialog>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="card">
|
<Paper elevation={1}>
|
||||||
<table className="data-table">
|
<TableContainer>
|
||||||
<thead>
|
<Table>
|
||||||
<tr>
|
<TableHead>
|
||||||
<th>Name</th>
|
<TableRow>
|
||||||
<th>Version</th>
|
<TableCell>Name</TableCell>
|
||||||
<th>Target</th>
|
<TableCell>Version</TableCell>
|
||||||
<th>Strategy</th>
|
<TableCell>Progress</TableCell>
|
||||||
<th>Status</th>
|
<TableCell>Status</TableCell>
|
||||||
<th>Created</th>
|
<TableCell>Created</TableCell>
|
||||||
<th>Actions</th>
|
<TableCell align="right">Actions</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
</thead>
|
</TableHead>
|
||||||
<tbody>
|
<TableBody>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<TableRow>
|
||||||
<td colSpan={7} className="td-empty">
|
<TableCell colSpan={6} align="center">
|
||||||
Loading...
|
Loading...
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
) : deployments.length === 0 ? (
|
) : deployments.length === 0 ? (
|
||||||
<tr>
|
<TableRow>
|
||||||
<td colSpan={7} className="td-empty">
|
<TableCell colSpan={6} align="center">
|
||||||
No deployments yet.
|
No deployments yet. Create the first one above.
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
deployments.map((d) => (
|
deployments.map((d) => (
|
||||||
<tr key={d.id}>
|
<TableRow key={d.id}>
|
||||||
<td>
|
<TableCell sx={{ fontWeight: 'medium' }}>{d.name}</TableCell>
|
||||||
<strong>{d.name}</strong>
|
<TableCell>{d.artefactVersion}</TableCell>
|
||||||
</td>
|
<TableCell>
|
||||||
<td>{d.artefactVersion}</td>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<td>{d.targetType}</td>
|
<Box sx={{ flex: 1 }}>
|
||||||
<td>{d.rolloutStrategy}</td>
|
<LinearProgress
|
||||||
<td>
|
variant="determinate"
|
||||||
<span className={`badge badge-${d.status}`}>{d.status}</span>
|
value={d.totalDevices > 0 ? (d.completedDevices / d.totalDevices) * 100 : 0}
|
||||||
</td>
|
/>
|
||||||
<td>{new Date(d.createdAt).toLocaleDateString()}</td>
|
</Box>
|
||||||
<td className="actions-cell">
|
<Typography variant="body2" color="text.secondary">
|
||||||
{d.status === "draft" && (
|
{d.completedDevices}/{d.totalDevices}
|
||||||
<button className="btn-action" onClick={() => activate(d.id)}>
|
</Typography>
|
||||||
Activate
|
</Box>
|
||||||
</button>
|
</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" && (
|
{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
|
Pause
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</td>
|
</TableCell>
|
||||||
</tr>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</TableBody>
|
||||||
</table>
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
{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" />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
autoFocus
|
autoFocus
|
||||||
required
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
<TextField
|
||||||
|
margin="normal"
|
||||||
<label>
|
required
|
||||||
Password
|
fullWidth
|
||||||
<input
|
name="password"
|
||||||
|
label="Password"
|
||||||
type="password"
|
type="password"
|
||||||
|
id="password"
|
||||||
|
autoComplete="current-password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</label>
|
<Button
|
||||||
|
type="submit"
|
||||||
<button type="submit" disabled={loading} className="btn-primary">
|
fullWidth
|
||||||
{loading ? 'Signing in...' : 'Sign In'}
|
variant="contained"
|
||||||
</button>
|
sx={{ mt: 3, mb: 2 }}
|
||||||
</form>
|
disabled={loading}>
|
||||||
</div>
|
{loading ? "Signing in..." : "Sign In"}
|
||||||
);
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
<div className="card form-section">
|
<DialogContent>
|
||||||
<h3>Add User</h3>
|
<DialogContentText sx={{ mb: 2 }}>
|
||||||
<form className="user-form" onSubmit={handleCreate}>
|
Add a new administrator to the system.
|
||||||
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
|
</DialogContentText>
|
||||||
<input placeholder="Password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
|
<Box component="form" sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<select value={role} onChange={e => setRole(e.target.value)}>
|
<TextField
|
||||||
<option value="admin">Admin</option>
|
autoFocus
|
||||||
<option value="operator">Operator</option>
|
margin="dense"
|
||||||
</select>
|
id="username"
|
||||||
<button type="submit" className="btn-primary btn-sm" disabled={creating}>
|
label="Username"
|
||||||
{creating ? 'Creating...' : 'Add User'}
|
fullWidth
|
||||||
</button>
|
variant="outlined"
|
||||||
</form>
|
value={username}
|
||||||
</div>
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
<button className="btn-action" onClick={() => handleChangePassword(u.id)}>Save</button>
|
<TextField
|
||||||
<button className="btn-action btn-cancel" onClick={() => setChangingId(null)}>Cancel</button>
|
margin="dense"
|
||||||
</span>
|
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>
|
||||||
) : (
|
) : (
|
||||||
<>
|
users.map((u) => (
|
||||||
<button className="btn-action" onClick={() => setChangingId(u.id)}>Change PW</button>
|
<TableRow key={u.id}>
|
||||||
<button className="btn-action btn-danger" onClick={() => handleDelete(u.id, u.username)}>Delete</button>
|
<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>
|
</TableBody>
|
||||||
</tr>
|
</Table>
|
||||||
))}
|
</TableContainer>
|
||||||
</tbody>
|
</Paper>
|
||||||
</table>
|
|
||||||
</div>
|
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||||
</div>
|
<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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
@@ -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 !== ""
|
||||||
@@ -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>()
|
||||||
@@ -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"
|
||||||
Vendored
+10
@@ -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
|
||||||
|
}
|
||||||
@@ -22,7 +22,12 @@
|
|||||||
"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"]
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
{
|
{
|
||||||
|
"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" },
|
||||||
|
|||||||
+2
-1
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user