ZMB Webui: Complete Project – Rebrand & Initial Clean Commit
ARCHITECTURE ============ Backend: FastAPI + uvicorn (port 8000) - JWT authentication with PAM system users - ZFS CLI wrapper with caching (30-60s TTL) - WebSocket pool status broadcaster (30s interval) - Services: auth, zfs_runner, file_manager, shares, identities, system_info - Routers: pools, datasets, snapshots, shares, identities, navigator, system Frontend: Next.js 15 + TypeScript (static export) - Incremental Static Regeneration (ISR) for weak hardware - Type-safe API client (lib/api.ts) - Dark mode + custom Tailwind theme - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc. DEPLOYMENT ========== Test Target: 192.168.1.179:8090 (Debian LXC) Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64) Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh) FEATURES COMPLETED ================== Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage) - Real-time stats with color-coded progress bars - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns) - ISR-optimized for fast loads on weak hardware REBRANDING ========== Renamed throughout: - Project: 'ZFS Manager' → 'ZMB Webui' - Services: 'zfs-manager' → 'zmb-webui' - Systemd units: zfs-manager-backend → zmb-webui-backend - Configuration files and documentation Co-Authored-By: Patrick <patrick@perlbach24.de>
This commit is contained in:
@@ -0,0 +1,480 @@
|
||||
import axios, { AxiosInstance } from "axios"
|
||||
|
||||
export interface Pool {
|
||||
name: string
|
||||
size: number
|
||||
alloc: number
|
||||
free: number
|
||||
fragmentation: string
|
||||
capacity: string
|
||||
health: "ONLINE" | "DEGRADED" | "FAULTED" | "OFFLINE" | "UNAVAIL"
|
||||
}
|
||||
|
||||
export interface Vdev {
|
||||
name: string
|
||||
state: string
|
||||
read: number
|
||||
write: number
|
||||
cksum: number
|
||||
children?: Vdev[]
|
||||
}
|
||||
|
||||
export interface PoolStatus extends Pool {
|
||||
state?: string
|
||||
scan?: string
|
||||
errors?: string
|
||||
vdevs: Vdev[]
|
||||
}
|
||||
|
||||
export interface Dataset {
|
||||
name: string
|
||||
type: "filesystem" | "volume" | "snapshot"
|
||||
used: number
|
||||
avail: number
|
||||
refer: number
|
||||
mountpoint?: string
|
||||
compression?: string
|
||||
quota?: number
|
||||
reservation?: number
|
||||
}
|
||||
|
||||
export interface DatasetProperties {
|
||||
compression?: string
|
||||
quota?: number
|
||||
reservation?: number
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
name: string
|
||||
dataset?: string
|
||||
creation: number
|
||||
used: number
|
||||
referenced: number
|
||||
}
|
||||
|
||||
export interface SambaShare {
|
||||
name: string
|
||||
path: string
|
||||
comment?: string
|
||||
read_only?: boolean
|
||||
guest_ok?: boolean
|
||||
valid_users?: string
|
||||
}
|
||||
|
||||
export interface NfsShare {
|
||||
path: string
|
||||
clients: string
|
||||
options?: string
|
||||
}
|
||||
|
||||
export interface SystemInfo {
|
||||
hostname: string
|
||||
uptime: number
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
memory_available: number
|
||||
cpu_count: number
|
||||
cpu_model: string
|
||||
os: string
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
status: string
|
||||
zfs_available: boolean
|
||||
version: string
|
||||
}
|
||||
|
||||
export interface SystemUser {
|
||||
username: string
|
||||
uid: number
|
||||
gid: number
|
||||
home: string
|
||||
shell: string
|
||||
gecos?: string
|
||||
locked?: boolean
|
||||
groups: string[]
|
||||
}
|
||||
|
||||
export interface SystemGroup {
|
||||
groupname: string
|
||||
gid: number
|
||||
members: string[]
|
||||
}
|
||||
|
||||
export interface LoginEntry {
|
||||
user: string
|
||||
terminal: string
|
||||
host: string
|
||||
login_time: string
|
||||
logout_time?: string
|
||||
duration?: string
|
||||
}
|
||||
|
||||
export class ZFSManagerAPI {
|
||||
private client: AxiosInstance
|
||||
private token: string | null = null
|
||||
|
||||
constructor(baseURL: string = process.env.NEXT_PUBLIC_API_URL || "") {
|
||||
this.client = axios.create({
|
||||
baseURL,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
this.token = localStorage.getItem("access_token")
|
||||
if (this.token) {
|
||||
this.setAuthHeader()
|
||||
}
|
||||
}
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Don't redirect on login endpoint itself
|
||||
const isLoginEndpoint = error.config?.url?.includes("/api/auth/login")
|
||||
|
||||
if (error.response?.status === 401 && !isLoginEndpoint) {
|
||||
localStorage.removeItem("access_token")
|
||||
this.token = null
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.href = "/login"
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private setAuthHeader() {
|
||||
if (this.token) {
|
||||
this.client.defaults.headers.common["Authorization"] = `Bearer ${this.token}`
|
||||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
async login(username: string, password: string): Promise<{ access_token: string; token_type: string }> {
|
||||
// Clear any existing token before login
|
||||
localStorage.removeItem("access_token")
|
||||
this.token = null
|
||||
this.client.defaults.headers.common["Authorization"] = ""
|
||||
|
||||
try {
|
||||
const response = await this.client.post("/api/auth/login", { username, password })
|
||||
this.token = response.data.access_token
|
||||
this.setAuthHeader()
|
||||
if (this.token) {
|
||||
localStorage.setItem("access_token", this.token)
|
||||
}
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
// Clear any invalid token on login failure
|
||||
localStorage.removeItem("access_token")
|
||||
this.token = null
|
||||
this.client.defaults.headers.common["Authorization"] = ""
|
||||
|
||||
// Provide better error message
|
||||
const message = error?.response?.data?.detail || error?.message || "Login failed"
|
||||
const err = new Error(message)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async logout() {
|
||||
this.token = null
|
||||
this.client.defaults.headers.common["Authorization"] = ""
|
||||
localStorage.removeItem("access_token")
|
||||
}
|
||||
|
||||
async verifyToken(): Promise<{ valid: boolean; username: string }> {
|
||||
try {
|
||||
const response = await this.client.post("/api/auth/verify")
|
||||
return response.data
|
||||
} catch {
|
||||
return { valid: false, username: "" }
|
||||
}
|
||||
}
|
||||
|
||||
// System Status (no auth required)
|
||||
async getSystemStatus(): Promise<SystemStatus> {
|
||||
try {
|
||||
const response = await this.client.get("/api/status")
|
||||
return response.data
|
||||
} catch {
|
||||
return { status: "unknown", zfs_available: false, version: "" }
|
||||
}
|
||||
}
|
||||
|
||||
// Pools
|
||||
async getPools(): Promise<Pool[]> {
|
||||
const response = await this.client.get("/api/pools/")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getPoolStatus(name: string): Promise<PoolStatus> {
|
||||
const response = await this.client.get(`/api/pools/${name}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async startScrub(poolName: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post(`/api/pools/${poolName}/scrub`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Datasets
|
||||
async getDatasets(pool: string = "tank"): Promise<Dataset[]> {
|
||||
const response = await this.client.get("/api/datasets/", { params: { pool } })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async createDataset(name: string, properties?: Record<string, string>): Promise<{ status: string }> {
|
||||
const response = await this.client.post("/api/datasets/", { name, properties })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async updateDatasetProperties(name: string, props: DatasetProperties): Promise<{ status: string }> {
|
||||
const response = await this.client.patch(`/api/datasets/${name}`, props)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteDataset(name: string, recursive = false): Promise<{ status: string }> {
|
||||
const response = await this.client.delete(`/api/datasets/${name}`, { params: { recursive } })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Snapshots
|
||||
async getSnapshots(dataset?: string, limit = 50): Promise<Snapshot[]> {
|
||||
const response = await this.client.get("/api/snapshots/", {
|
||||
params: { ...(dataset ? { dataset } : {}), limit },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
async createSnapshot(dataset: string, name?: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post("/api/snapshots/", { dataset, name })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteSnapshot(name: string): Promise<{ status: string }> {
|
||||
const response = await this.client.delete(`/api/snapshots/${name}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async rollbackSnapshot(snapshot: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post("/api/snapshots/rollback", { snapshot })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Shares — Samba
|
||||
async getSambaShares(): Promise<SambaShare[]> {
|
||||
const response = await this.client.get("/api/shares/samba")
|
||||
return response.data.shares ?? response.data
|
||||
}
|
||||
|
||||
async createSambaShare(share: SambaShare): Promise<{ status: string }> {
|
||||
const response = await this.client.post("/api/shares/samba", share)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteSambaShare(name: string): Promise<{ status: string }> {
|
||||
const response = await this.client.delete(`/api/shares/samba/${name}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Shares — NFS
|
||||
async getNfsShares(): Promise<NfsShare[]> {
|
||||
const response = await this.client.get("/api/shares/nfs")
|
||||
return response.data.shares ?? response.data
|
||||
}
|
||||
|
||||
async createNfsShare(share: NfsShare): Promise<{ status: string }> {
|
||||
const response = await this.client.post("/api/shares/nfs", share)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteNfsShare(path: string): Promise<{ status: string }> {
|
||||
const response = await this.client.delete("/api/shares/nfs", { params: { path } })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Shares Configuration
|
||||
async getSambaGlobalConfig(): Promise<{ [key: string]: any }> {
|
||||
const response = await this.client.get("/api/shares/samba/config")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async setSambaGlobalConfig(config: string): Promise<{ status: string }> {
|
||||
const response = await this.client.put("/api/shares/samba/config", { config })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getNfsGlobalConfig(): Promise<{ exports: string; path?: string }> {
|
||||
const response = await this.client.get("/api/shares/nfs/config")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async setNfsGlobalConfig(config: string): Promise<{ status: string }> {
|
||||
const response = await this.client.put("/api/shares/nfs/config", { config })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// System
|
||||
async getSystemInfo(): Promise<SystemInfo> {
|
||||
const response = await this.client.get("/api/system/info")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getMemory(): Promise<{ total: number; used: number; available: number; swap_total: number; swap_used: number }> {
|
||||
const response = await this.client.get("/api/system/memory")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getCpuInfo(): Promise<{ count: number; percent?: number; load_average: number[] }> {
|
||||
const response = await this.client.get("/api/system/cpu")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getUptime(): Promise<{ uptime_seconds: number; uptime_string: string }> {
|
||||
const response = await this.client.get("/api/system/uptime")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getNetwork(): Promise<{ interfaces: any[] }> {
|
||||
const response = await this.client.get("/api/system/network")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getNetworkTraffic(): Promise<{ interfaces: any[] }> {
|
||||
const response = await this.client.get("/api/system/network/traffic")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getDiskIO(): Promise<{ disks: any[] }> {
|
||||
const response = await this.client.get("/api/system/diskio")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getServices(): Promise<{ services: any[] }> {
|
||||
const response = await this.client.get("/api/system/services")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getUnits(): Promise<{
|
||||
services: any[]
|
||||
targets: any[]
|
||||
sockets: any[]
|
||||
timers: any[]
|
||||
paths: any[]
|
||||
}> {
|
||||
const response = await this.client.get("/api/system/units")
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getSystemLogs(limit: number = 20): Promise<{ logs: string[] }> {
|
||||
const response = await this.client.get(`/api/system/logs?limit=${limit}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async getHealth(): Promise<{ status: string; version: string }> {
|
||||
const response = await this.client.get("/health")
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Identities - Users
|
||||
async getUsers(): Promise<SystemUser[]> {
|
||||
const response = await this.client.get("/api/identities/users")
|
||||
return response.data.users ?? []
|
||||
}
|
||||
|
||||
async createUser(username: string, home_dir?: string, shell?: string, gecos?: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post("/api/identities/users", { username, home_dir, shell, gecos })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteUser(username: string, remove_home: boolean = true): Promise<{ status: string }> {
|
||||
const response = await this.client.delete(`/api/identities/users/${username}`, { params: { remove_home } })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async changePassword(username: string, password: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post(`/api/identities/users/${username}/password`, { password })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async changeShell(username: string, shell: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post(`/api/identities/users/${username}/shell`, { shell })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async lockUser(username: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post(`/api/identities/users/${username}/lock`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async unlockUser(username: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post(`/api/identities/users/${username}/unlock`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async setSambaPassword(username: string, password: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post(`/api/identities/users/${username}/samba-password`, { password })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Identities - Groups
|
||||
async getGroups(): Promise<SystemGroup[]> {
|
||||
const response = await this.client.get("/api/identities/groups")
|
||||
return response.data.groups ?? []
|
||||
}
|
||||
|
||||
async createGroup(groupname: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post("/api/identities/groups", { groupname })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async deleteGroup(groupname: string): Promise<{ status: string }> {
|
||||
const response = await this.client.delete(`/api/identities/groups/${groupname}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async addUserToGroup(username: string, groupname: string): Promise<{ status: string }> {
|
||||
const response = await this.client.post(`/api/identities/users/${username}/groups`, { groupname })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async removeUserFromGroup(username: string, groupname: string): Promise<{ status: string }> {
|
||||
const response = await this.client.delete(`/api/identities/users/${username}/groups/${groupname}`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Identities - Samba Users
|
||||
async getSambaUsers(): Promise<SystemUser[]> {
|
||||
const response = await this.client.get("/api/identities/samba-users")
|
||||
return response.data.users ?? []
|
||||
}
|
||||
|
||||
// Identities - Login History
|
||||
async getLoginHistory(limit: number = 50): Promise<LoginEntry[]> {
|
||||
const response = await this.client.get("/api/identities/login-history", { params: { limit } })
|
||||
return response.data.logins ?? []
|
||||
}
|
||||
|
||||
// Navigator - Copy, Move, Search
|
||||
async copyFile(src: string, dst: string, overwrite: boolean = false): Promise<{ status: string }> {
|
||||
const response = await this.client.post("/api/navigator/copy", { src, dst, overwrite })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async moveFile(src: string, dst: string, overwrite: boolean = false): Promise<{ status: string }> {
|
||||
const response = await this.client.post("/api/navigator/move", { src, dst, overwrite })
|
||||
return response.data
|
||||
}
|
||||
|
||||
async searchFiles(q: string, path: string = "", limit: number = 50): Promise<any[]> {
|
||||
const response = await this.client.get("/api/navigator/search", { params: { q, path, limit } })
|
||||
return response.data.results ?? []
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ZFSManagerAPI()
|
||||
Reference in New Issue
Block a user