feat: initial open-source release of Pipely

OTA update server designed for high-concurrency, low-bandwidth deployments.
GORM-backed PostgreSQL, JWT auth, device management, artefact versioning,
deployment rollout with gated rate-limited downloads, and a React admin panel.
This commit is contained in:
2026-07-07 10:29:28 +08:00
commit e070a3fb5f
59 changed files with 6515 additions and 0 deletions
+50
View File
@@ -0,0 +1,50 @@
.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;
}
+172
View File
@@ -0,0 +1,172 @@
import { useEffect, useState } from "react"
import { api } from "../api/client"
import "./Artefacts.css"
interface Artefact {
id: number
version: string
fileName: string
fileSize: number
sha256Hash: string
isDelta: boolean
deltaRef: string
createdAt: string
}
const PAGE_SIZE = 20
export default function Artefacts() {
const [artefacts, setArtefacts] = useState<Artefact[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [uploading, setUploading] = useState(false)
const [version, setVersion] = useState("")
const [file, setFile] = useState<File | null>(null)
const [uploadMsg, setUploadMsg] = useState("")
async function load() {
setLoading(true)
try {
const res = await api.get<{ data: Artefact[]; total: number }>(
`/artefacts?page=${page}&pageSize=${PAGE_SIZE}`
)
setArtefacts(res.data)
setTotal(res.total)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [page])
async function handleUpload(e: React.FormEvent) {
e.preventDefault()
if (!file || !version) return
const form = new FormData()
form.append("version", version)
form.append("file", file)
setUploading(true)
setUploadMsg("")
try {
await api.post("/artefacts", form)
setVersion("")
setFile(null)
setUploadMsg("Version uploaded successfully.")
load()
} catch (err) {
setUploadMsg("Upload failed: " + (err instanceof Error ? err.message : "unknown error"))
} finally {
setUploading(false)
}
}
const totalPages = Math.ceil(total / PAGE_SIZE)
return (
<div>
<h1 className="page-title">Versions</h1>
<div className="card upload-section">
<h3>Create New Version</h3>
<form className="upload-form" onSubmit={handleUpload}>
<input
type="text"
placeholder="Version (e.g. 1.2.0)"
value={version}
onChange={(e) => setVersion(e.target.value)}
required
/>
<input
type="file"
accept=".jar,.patch,.zip"
onChange={(e) => setFile(e.target.files?.[0] || null)}
required
/>
<button type="submit" className="btn-primary btn-sm" disabled={uploading}>
{uploading ? "Uploading..." : "Upload"}
</button>
</form>
{uploadMsg && (
<p className={uploadMsg.includes("failed") ? "msg-error" : "msg-success"}>{uploadMsg}</p>
)}
</div>
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Version</th>
<th>File</th>
<th>Size</th>
<th>Type</th>
<th>SHA256</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} className="td-empty">
Loading...
</td>
</tr>
) : artefacts.length === 0 ? (
<tr>
<td colSpan={6} className="td-empty">
No versions yet. Upload the first one above.
</td>
</tr>
) : (
artefacts.map((a) => (
<tr key={a.id}>
<td>
<strong>{a.version}</strong>
</td>
<td>{a.fileName}</td>
<td>{formatSize(a.fileSize)}</td>
<td>
{a.isDelta ? (
<span className="badge badge-delta">delta ({a.deltaRef})</span>
) : (
<span className="badge badge-full">full</span>
)}
</td>
<td className="hash-cell" title={a.sha256Hash}>
{a.sha256Hash.slice(0, 12)}...
</td>
<td>{new Date(a.createdAt).toLocaleDateString()}</td>
</tr>
))
)}
</tbody>
</table>
{totalPages > 1 && (
<div className="pagination">
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Prev
</button>
<span>
Page {page} of {totalPages}
</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Next
</button>
</div>
)}
</div>
</div>
)
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + " B"
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB"
return (bytes / (1024 * 1024)).toFixed(1) + " MB"
}
+37
View File
@@ -0,0 +1,37 @@
.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;
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from "react"
import { api } from "../api/client"
import "./Dashboard.css"
interface Stats {
totalDevices: number
onlineDevices: number
activeDeployments: number
totalArtefacts: number
}
export default function Dashboard() {
const [stats, setStats] = useState<Stats>({
totalDevices: 0,
onlineDevices: 0,
activeDeployments: 0,
totalArtefacts: 0,
})
const [loading, setLoading] = useState(true)
useEffect(() => {
async function load() {
try {
const [devices, artefacts, deployments] = await Promise.all([
api.get<{ total: number; data: { status: string }[] }>("/devices?pageSize=1"),
api.get<{ total: number }>("/artefacts?pageSize=1"),
api.get<{ total: number; data: { status: string }[] }>("/deployments?pageSize=100"),
])
const online = devices.data
? devices.data.filter((d: { status: string }) => d.status === "online").length
: 0
const active = deployments.data
? deployments.data.filter((d: { status: string }) => d.status === "active").length
: 0
setStats({
totalDevices: devices.total,
onlineDevices: online,
activeDeployments: active,
totalArtefacts: artefacts.total,
})
} catch (err) {
console.error("Failed to load stats:", err)
} finally {
setLoading(false)
}
}
load()
}, [])
if (loading) return <div className="page-loading">Loading...</div>
const cards = [
{ label: "Total Devices", value: stats.totalDevices, colour: "#4a90d9" },
{ label: "Online Devices", value: stats.onlineDevices, colour: "#27ae60" },
{ label: "Active Deployments", value: stats.activeDeployments, colour: "#e67e22" },
{ label: "Artefact Versions", value: stats.totalArtefacts, colour: "#8e44ad" },
]
return (
<div>
<h1 className="page-title">Dashboard</h1>
<div className="stats-grid">
{cards.map((c) => (
<div key={c.label} className="stat-card" style={{ borderTopColor: c.colour }}>
<div className="stat-value">{c.value}</div>
<div className="stat-label">{c.label}</div>
</div>
))}
</div>
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
.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; }
+266
View File
@@ -0,0 +1,266 @@
import { type FormEvent, useEffect, useState } from "react"
import { api } from "../api/client"
import "./Deployments.css"
interface Deployment {
id: number
artefactId: number
artefactVersion: string
name: string
targetType: string
rolloutStrategy: string
maxConcurrent: number
status: string
createdAt: string
}
interface Artefact {
id: number
version: string
}
export default function Deployments() {
const [deployments, setDeployments] = useState<Deployment[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
// Create form
const [showForm, setShowForm] = useState(false)
const [artefacts, setArtefacts] = useState<Artefact[]>([])
const [name, setName] = useState("")
const [artefactId, setArtefactId] = useState("")
const [targetType, setTargetType] = useState("all")
const [targetSpec, setTargetSpec] = useState("")
const [rolloutStrategy, setRolloutStrategy] = useState("immediate")
const [maxConcurrent, setMaxConcurrent] = useState(50)
const [creating, setCreating] = useState(false)
const [msg, setMsg] = useState("")
async function load() {
setLoading(true)
try {
const res = await api.get<{ data: Deployment[]; total: number }>(
`/deployments?page=${page}&pageSize=20`
)
setDeployments(res.data)
setTotal(res.total)
} catch (err) {
console.error(err)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [load, page])
async function loadArtefacts() {
try {
const res = await api.get<{ data: Artefact[] }>("/artefacts?pageSize=200")
setArtefacts(res.data)
} catch (err) {
console.error(err)
}
}
async function handleCreate(e: FormEvent) {
e.preventDefault()
setCreating(true)
setMsg("")
try {
await api.post("/deployments", {
artefactId: parseInt(artefactId),
name,
targetType,
targetSpec: targetSpec || "{}",
rolloutStrategy,
maxConcurrent,
})
setShowForm(false)
setName("")
setArtefactId("")
setTargetSpec("")
setMsg("Deployment created. Activate it to begin rollout.")
load()
} catch (err) {
setMsg("Failed: " + (err instanceof Error ? err.message : "unknown"))
} finally {
setCreating(false)
}
}
async function activate(id: number) {
try {
await api.post(`/deployments/${id}/activate`)
load()
} catch (err) {
alert("Failed to activate")
}
}
async function pause(id: number) {
try {
await api.post(`/deployments/${id}/pause`)
load()
} catch (err) {
alert("Failed to pause")
}
}
const totalPages = Math.ceil(total / 20)
return (
<div>
<div className="page-header">
<h1 className="page-title">Deployments</h1>
<button
className="btn-primary btn-sm"
onClick={() => {
setShowForm(!showForm)
if (!showForm) loadArtefacts()
}}>
{showForm ? "Cancel" : "New Deployment"}
</button>
</div>
{msg && <p className={msg.startsWith("Failed") ? "msg-error" : "msg-success"}>{msg}</p>}
{showForm && (
<div className="card form-section">
<h3>Create Deployment</h3>
<form className="dep-form" onSubmit={handleCreate}>
<label>
Name <input value={name} onChange={(e) => setName(e.target.value)} required />
</label>
<label>
Version
<select value={artefactId} onChange={(e) => setArtefactId(e.target.value)} required>
<option value="">-- Select --</option>
{artefacts.map((a) => (
<option key={a.id} value={a.id}>
{a.version}
</option>
))}
</select>
</label>
<label>
Target
<select value={targetType} onChange={(e) => setTargetType(e.target.value)}>
<option value="all">All Devices</option>
<option value="group">By Group</option>
<option value="device">Specific Devices</option>
</select>
</label>
{targetType !== "all" && (
<label>
Target Spec (JSON)
<input
value={targetSpec}
onChange={(e) => setTargetSpec(e.target.value)}
placeholder={
targetType === "group"
? '{"groupName":"default"}'
: '{"deviceIds":["id1","id2"]}'
}
/>
</label>
)}
<label>
Strategy
<select value={rolloutStrategy} onChange={(e) => setRolloutStrategy(e.target.value)}>
<option value="immediate">Immediate</option>
<option value="staged">Staged</option>
</select>
</label>
<label>
Max Concurrent{" "}
<input
type="number"
value={maxConcurrent}
onChange={(e) => setMaxConcurrent(Number(e.target.value))}
min={1}
max={500}
/>
</label>
<button type="submit" className="btn-primary btn-sm" disabled={creating}>
{creating ? "Creating..." : "Create"}
</button>
</form>
</div>
)}
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Name</th>
<th>Version</th>
<th>Target</th>
<th>Strategy</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="td-empty">
Loading...
</td>
</tr>
) : deployments.length === 0 ? (
<tr>
<td colSpan={7} className="td-empty">
No deployments yet.
</td>
</tr>
) : (
deployments.map((d) => (
<tr key={d.id}>
<td>
<strong>{d.name}</strong>
</td>
<td>{d.artefactVersion}</td>
<td>{d.targetType}</td>
<td>{d.rolloutStrategy}</td>
<td>
<span className={`badge badge-${d.status}`}>{d.status}</span>
</td>
<td>{new Date(d.createdAt).toLocaleDateString()}</td>
<td className="actions-cell">
{d.status === "draft" && (
<button className="btn-action" onClick={() => activate(d.id)}>
Activate
</button>
)}
{d.status === "active" && (
<button className="btn-action btn-pause" onClick={() => pause(d.id)}>
Pause
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
{totalPages > 1 && (
<div className="pagination">
<button disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Prev
</button>
<span>
Page {page} of {totalPages}
</span>
<button disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
Next
</button>
</div>
)}
</div>
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
.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;
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api, setToken } from '../api/client';
import './Login.css';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await api.post<{ token: string }>('/auth/login', { username, password });
setToken(res.token);
navigate('/dashboard');
} catch (err) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
}
return (
<div className="login-page">
<form className="login-form" onSubmit={handleSubmit}>
<h1>OTA Manager</h1>
<p className="login-subtitle">Sign in to manage updates</p>
{error && <div className="login-error">{error}</div>}
<label>
Username
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
autoFocus
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
/>
</label>
<button type="submit" disabled={loading} className="btn-primary">
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
.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; }
+149
View File
@@ -0,0 +1,149 @@
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import './Users.css';
interface AdminUser {
id: number;
username: string;
role: string;
createdAt: string;
}
export default function Users() {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
// Create form
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [role, setRole] = useState('admin');
const [creating, setCreating] = useState(false);
const [msg, setMsg] = useState('');
// Change password state
const [changingId, setChangingId] = useState<number | null>(null);
const [newPassword, setNewPassword] = useState('');
async function load() {
setLoading(true);
try {
const res = await api.get<{ data: AdminUser[] }>('/admin/users');
setUsers(res.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
setCreating(true);
setMsg('');
try {
await api.post('/admin/users', { username, password, role });
setUsername('');
setPassword('');
setRole('admin');
setMsg('User created.');
load();
} catch (err) {
setMsg('Failed: ' + (err instanceof Error ? err.message : 'unknown'));
} finally {
setCreating(false);
}
}
async function handleDelete(id: number, uname: string) {
if (!confirm(`Delete user "${uname}"?`)) return;
try {
await api.delete(`/admin/users/${id}`);
load();
} catch (err) {
alert('Failed to delete');
}
}
async function handleChangePassword(id: number) {
if (!newPassword) return;
try {
await api.put(`/admin/users/${id}/password`, { newPassword });
setChangingId(null);
setNewPassword('');
setMsg('Password changed.');
} catch (err) {
alert('Failed to change password');
}
}
return (
<div>
<h1 className="page-title">Admin Users</h1>
{msg && <p className={msg.startsWith('Failed') ? 'msg-error' : 'msg-success'}>{msg}</p>}
<div className="card form-section">
<h3>Add User</h3>
<form className="user-form" onSubmit={handleCreate}>
<input placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
<input placeholder="Password" type="password" value={password} onChange={e => setPassword(e.target.value)} required />
<select value={role} onChange={e => setRole(e.target.value)}>
<option value="admin">Admin</option>
<option value="operator">Operator</option>
</select>
<button type="submit" className="btn-primary btn-sm" disabled={creating}>
{creating ? 'Creating...' : 'Add User'}
</button>
</form>
</div>
<div className="card">
<table className="data-table">
<thead>
<tr>
<th>Username</th>
<th>Role</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={4} className="td-empty">Loading...</td></tr>
) : users.length === 0 ? (
<tr><td colSpan={4} className="td-empty">No users.</td></tr>
) : users.map(u => (
<tr key={u.id}>
<td><strong>{u.username}</strong></td>
<td><span className="badge">{u.role}</span></td>
<td>{new Date(u.createdAt).toLocaleDateString()}</td>
<td className="actions-cell">
{changingId === u.id ? (
<span className="pw-inline">
<input
type="password"
placeholder="New password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
size={16}
/>
<button className="btn-action" onClick={() => handleChangePassword(u.id)}>Save</button>
<button className="btn-action btn-cancel" onClick={() => setChangingId(null)}>Cancel</button>
</span>
) : (
<>
<button className="btn-action" onClick={() => setChangingId(u.id)}>Change PW</button>
<button className="btn-action btn-danger" onClick={() => handleDelete(u.id, u.username)}>Delete</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}