mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-17 08:05:13 +00:00
7295a28ec9
- OIDC: RP-Initiated Logout is now opt-in (default off) via a new
'single_logout' setting, with the exact post-logout redirect URL shown
in Settings for the admin to register at their provider. Fixes the
regression from last time: enabling it unconditionally broke sign-out
for anyone whose IdP hadn't been told to trust the redirect yet
(Keycloak's invalid_redirect_uri, browser stuck on a stale page).
- formatBytes shows up to 2 decimals (was an adaptive 0-or-1 rule); every
bytes/rate chart now computes a rounded 'nice' axis scale (0/5/10/15/20
GB, the standard Heckbert algorithm) and locks every tick + the tooltip
to one consistent unit derived from the axis's own max.
- Storage page: the capacity donut is now sized by used bytes + a free
remainder instead of by total capacity share, which was always 100%
the moment there was only one pool — completely disconnected from the
"224 GB used" text next to it.
- Topology: Export SVG (fits the full diagram regardless of current pan/
zoom) and a Snap-to-grid toggle.
- New Disks tab on the node detail page: every physical disk with model/
serial/size/type, PASSED/FAILED health, SSD/NVMe wearout %, and a
per-drive temperature read from SMART, plus a full SMART attribute
table per disk. Backed by new /nodes/{node}/disks and
/nodes/{node}/disks/smart endpoints.
- CPU/GPU temperature is not exposed by Proxmox's own API (no built-in
lm-sensors/nvidia-smi integration) and isn't something this can add
without a node-side agent Proxmox doesn't ship — disk temperature via
SMART is the thermal data actually available.
377 lines
11 KiB
Go
377 lines
11 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"ferrum/internal/pve"
|
|
)
|
|
|
|
func (s *Server) nodeStatus(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
status, err := client.NodeStatus(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, status)
|
|
}
|
|
|
|
func (s *Server) nodeDisks(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
disks, err := client.NodeDisks(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, disks)
|
|
}
|
|
|
|
func (s *Server) diskSMART(w http.ResponseWriter, r *http.Request) {
|
|
devpath := r.URL.Query().Get("disk")
|
|
if devpath == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "disk query parameter is required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
smart, err := client.DiskSMART(r.Context(), chi.URLParam(r, "node"), devpath)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, smart)
|
|
}
|
|
|
|
func (s *Server) rebootNode(w http.ResponseWriter, r *http.Request) {
|
|
node := chi.URLParam(r, "node")
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
upid, err := client.RebootNode(r.Context(), node)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "node.reboot", "node", node)
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|
|
|
|
func (s *Server) shutdownNode(w http.ResponseWriter, r *http.Request) {
|
|
node := chi.URLParam(r, "node")
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
upid, err := client.ShutdownNode(r.Context(), node)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "node.shutdown", "node", node)
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|
|
|
|
func (s *Server) nodeStorageList(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
storages, err := client.NodeStorage(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, storages)
|
|
}
|
|
|
|
func (s *Server) storageContent(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
items, err := client.StorageContent(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "storage"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, items)
|
|
}
|
|
|
|
func (s *Server) deleteStorageContent(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
node, storage, volid := chi.URLParam(r, "node"), chi.URLParam(r, "storage"), chi.URLParam(r, "volid")
|
|
if err := client.DeleteStorageContent(r.Context(), node, storage, volid); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "storage.delete-content", "storage", node+"/"+storage+"/"+volid)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) nodeNetwork(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
ifaces, err := client.NodeNetwork(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, ifaces)
|
|
}
|
|
|
|
func (s *Server) aptUpdates(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
updates, err := client.AptUpdates(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, updates)
|
|
}
|
|
|
|
func (s *Server) aptRefresh(w http.ResponseWriter, r *http.Request) {
|
|
node := chi.URLParam(r, "node")
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
upid, err := client.RefreshAptIndex(r.Context(), node)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|
|
|
|
func (s *Server) aptUpgrade(w http.ResponseWriter, r *http.Request) {
|
|
node := chi.URLParam(r, "node")
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
upid, err := client.UpgradeNode(r.Context(), node)
|
|
if err != nil {
|
|
if errors.Is(err, pve.ErrUpgradeNotSupported) {
|
|
s.writeError(w, http.StatusNotImplemented, err)
|
|
return
|
|
}
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "node.upgrade", "node", node)
|
|
writeJSON(w, http.StatusOK, map[string]string{"upid": upid})
|
|
}
|
|
|
|
func (s *Server) nodeSyslog(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
entries, err := client.NodeSyslog(r.Context(), chi.URLParam(r, "node"), 200)
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, entries)
|
|
}
|
|
|
|
func (s *Server) nodeFirewallRules(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
rules, err := client.NodeFirewallRules(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rules)
|
|
}
|
|
|
|
func (s *Server) addNodeFirewallRule(w http.ResponseWriter, r *http.Request) {
|
|
var req newFirewallRuleRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
if req.Type == "" || req.Action == "" {
|
|
writeErrorMsg(w, http.StatusBadRequest, "type and action are required")
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
rule := pve.NewFirewallRule{
|
|
Type: req.Type, Action: req.Action, Source: req.Source, Dest: req.Dest,
|
|
Proto: req.Proto, Dport: req.Dport, Sport: req.Sport, Macro: req.Macro,
|
|
Comment: req.Comment, Enable: req.Enable,
|
|
}
|
|
if err := client.AddNodeFirewallRule(r.Context(), chi.URLParam(r, "node"), rule); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "firewall.rule.create", "firewall", "node:"+chi.URLParam(r, "node"))
|
|
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) deleteNodeFirewallRule(w http.ResponseWriter, r *http.Request) {
|
|
pos, err := strconv.Atoi(chi.URLParam(r, "pos"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadRequest, err)
|
|
return
|
|
}
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
if err := client.DeleteNodeFirewallRule(r.Context(), chi.URLParam(r, "node"), pos); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
s.audit(r, "firewall.rule.delete", "firewall", "node:"+chi.URLParam(r, "node"))
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) nodeReplicationStatus(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
status, err := client.NodeReplicationStatus(r.Context(), chi.URLParam(r, "node"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, status)
|
|
}
|
|
|
|
// cephNotConfigured ends a Ceph request where the target server has no
|
|
// Ceph (not installed, or the endpoint isn't implemented on its PVE
|
|
// version). 204 reads as "no data" to the frontend — which shows "Not
|
|
// configured" — instead of an error state and a 502 in the logs.
|
|
func cephNotConfigured(w http.ResponseWriter, err error) bool {
|
|
if !pve.IsNotAvailable(err) {
|
|
return false
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return true
|
|
}
|
|
|
|
func (s *Server) cephStatus(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
status, err := client.CephClusterStatus(r.Context(), chi.URLParam(r, "node"))
|
|
if cephNotConfigured(w, err) {
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, status)
|
|
}
|
|
|
|
func (s *Server) cephPools(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
pools, err := client.CephPools(r.Context(), chi.URLParam(r, "node"))
|
|
if cephNotConfigured(w, err) {
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, pools)
|
|
}
|
|
|
|
func (s *Server) cephOSDs(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
osds, err := client.CephOSDs(r.Context(), chi.URLParam(r, "node"))
|
|
if cephNotConfigured(w, err) {
|
|
return
|
|
}
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, osds)
|
|
}
|
|
|
|
func (s *Server) taskLog(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
lines, err := client.TaskLog(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "upid"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, lines)
|
|
}
|
|
|
|
func (s *Server) cancelTask(w http.ResponseWriter, r *http.Request) {
|
|
client, err := s.clientFor(r.Context(), chi.URLParam(r, "id"))
|
|
if err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
if err := client.CancelTask(r.Context(), chi.URLParam(r, "node"), chi.URLParam(r, "upid")); err != nil {
|
|
s.writeError(w, http.StatusBadGateway, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|