mirror of
https://github.com/anand34577/ferrum.git
synced 2026-09-12 05:48:58 +00:00
d63c284fbf
Frontend build:
- combobox.tsx: DropdownMenuContent doesn't expose onOpenAutoFocus (Radix
Popper primitive, unlike Dialog/Popover) — tsc failed. Focus the search
input from an effect on `open` instead.
Backend data bugs (JSON parsing / validation):
- pve.JournalEntry: "n" is sometimes sent quoted by PVE ("n":"1"), which
failed the whole unmarshal and blanked the node Journal tab. Decoded
loosely, same as the existing cpuinfo.mhz string/number quirk.
- pve.ClusterConfigNode: pve_addr was typed int but PVE always sends a
string (an IP) — populated it 502'd Cluster & SDN > Members. Fixed to
string.
- ClusterPage: PVE also errors /cluster/config/nodes outright on a
standalone (non-clustered) node — that hit the same 502 error card
instead of the existing "not part of a cluster" message.
- ai_providers.go: updateAIProvider rejected the built-in Needle 2
provider's "needle://local" baseUrl on every save (missing the
needle.IsBuiltin bypass createAIProvider already had), so it could only
ever be disabled, never edited. Deleting it only lasted until the next
restart (the seeder always re-created it) — now tracked via a
"dismissed" flag in the settings table so a delete sticks.
UI polish:
- Added amber/rose/teal accent color presets (theme.tsx, AppearanceCard,
index.css, backend whitelist).
- StatusDot's glow was clipped on one side wherever it sat inside a
`truncate` (overflow-hidden) flex row — moved truncation to just the
text sibling (ConnectionsPage, AlertActivityWidget).
- ResourceAreaChart: a wide Y-axis tick ("47.68 MB/s") wraps onto two
lines in recharts, and the chart only had 8px of top margin — the first
line rendered off the top edge. Widened the axis gutter and margin.
- AlertActivityWidget required *both* of two independent queries to fail
before showing an error, so one broken endpoint alone rendered stale
counts instead of the error state.
- DonutChart: the hover tooltip followed the cursor by default, which on
a compact ring collided with the centered value/label text. Pinned it
below the ring instead.
- NodeDetailPage storage rows: a variable number of badges before the
usage bar made every row's bar start at a different x. Switched to a
fixed-width grid, same pattern already used for two other bar lists.
- RunningTasksWidget: a failed task's PVE status is a full sentence, not
a short word — stuffing it into a badge blew the row's layout up.
Collapsed to "Failed" with the full text as a hover title.
- Alert value/threshold formatting assumed every metric is a 0-100
percent; three built-in alerts (cert_expiry/connection_stale in days,
storage_orphan_disk in bytes) printed nonsense like "67108864.0%".
Added a shared formatAlertValue() and used it everywhere alert
instances are rendered (AlertsPage, OverviewPage, NotificationBell).
README: added a Features section summarizing the fleet management,
automation, integration, and access-control capabilities.
136 lines
4.3 KiB
Go
136 lines
4.3 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// defaultPreferencesRow is the single (id=1) default_preferences row — the
|
|
// org-wide theme/accent/look/landing-page a brand-new user starts with,
|
|
// before they've ever saved a preference of their own. Admin-editable from
|
|
// Settings; existing users with a saved preference are unaffected.
|
|
type defaultPreferencesRow struct {
|
|
theme string
|
|
accent string
|
|
look string
|
|
landingPage string
|
|
density string
|
|
}
|
|
|
|
func defaultDefaultPreferencesRow() defaultPreferencesRow {
|
|
return defaultPreferencesRow{theme: "system", accent: "oxide", look: "enterprise", landingPage: "/", density: "comfortable"}
|
|
}
|
|
|
|
func (s *Server) loadDefaultPreferencesRow(ctx context.Context) (defaultPreferencesRow, error) {
|
|
row := defaultDefaultPreferencesRow()
|
|
err := s.db.QueryRowContext(ctx, `SELECT theme, accent, look, landing_page, density FROM default_preferences WHERE id = 1`).
|
|
Scan(&row.theme, &row.accent, &row.look, &row.landingPage, &row.density)
|
|
if err == sql.ErrNoRows {
|
|
return defaultDefaultPreferencesRow(), nil
|
|
}
|
|
if err != nil {
|
|
return defaultPreferencesRow{}, err
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
func (s *Server) saveDefaultPreferencesRow(ctx context.Context, row defaultPreferencesRow) error {
|
|
_, err := s.db.ExecContext(ctx, `
|
|
INSERT INTO default_preferences (id, theme, accent, look, landing_page, density, updated_at)
|
|
VALUES (1, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (id) DO UPDATE SET
|
|
theme = excluded.theme, accent = excluded.accent, look = excluded.look, landing_page = excluded.landing_page,
|
|
density = excluded.density, updated_at = excluded.updated_at`,
|
|
row.theme, row.accent, row.look, row.landingPage, row.density, time.Now().UTC().Format(time.RFC3339))
|
|
return err
|
|
}
|
|
|
|
type defaultPreferencesResponse struct {
|
|
Theme string `json:"theme"`
|
|
Accent string `json:"accent"`
|
|
Look string `json:"look"`
|
|
LandingPage string `json:"landingPage"`
|
|
Density string `json:"density"`
|
|
}
|
|
|
|
func toDefaultPreferencesResponse(row defaultPreferencesRow) defaultPreferencesResponse {
|
|
return defaultPreferencesResponse{Theme: row.theme, Accent: row.accent, Look: row.look, LandingPage: row.landingPage, Density: row.density}
|
|
}
|
|
|
|
func (s *Server) getDefaultPreferences(w http.ResponseWriter, r *http.Request) {
|
|
row, err := s.loadDefaultPreferencesRow(r.Context())
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, toDefaultPreferencesResponse(row))
|
|
}
|
|
|
|
type defaultPreferencesPatch struct {
|
|
Theme *string `json:"theme"`
|
|
Accent *string `json:"accent"`
|
|
Look *string `json:"look"`
|
|
LandingPage *string `json:"landingPage"`
|
|
Density *string `json:"density"`
|
|
}
|
|
|
|
func (s *Server) putDefaultPreferences(w http.ResponseWriter, r *http.Request) {
|
|
var patch defaultPreferencesPatch
|
|
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
|
writeErrorMsg(w, http.StatusBadRequest, "expected a JSON object")
|
|
return
|
|
}
|
|
|
|
row, err := s.loadDefaultPreferencesRow(r.Context())
|
|
if err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
|
|
if patch.Theme != nil {
|
|
if !validThemes[*patch.Theme] {
|
|
writeErrorMsg(w, http.StatusBadRequest, "theme must be one of: light, dark, system")
|
|
return
|
|
}
|
|
row.theme = *patch.Theme
|
|
}
|
|
if patch.Accent != nil {
|
|
if !validAccents[*patch.Accent] {
|
|
writeErrorMsg(w, http.StatusBadRequest, "accent must be one of: oxide, azure, verdant, violet, slate, amber, rose, teal")
|
|
return
|
|
}
|
|
row.accent = *patch.Accent
|
|
}
|
|
if patch.Look != nil {
|
|
if !validLooks[*patch.Look] {
|
|
writeErrorMsg(w, http.StatusBadRequest, "unrecognized look")
|
|
return
|
|
}
|
|
row.look = *patch.Look
|
|
}
|
|
if patch.LandingPage != nil {
|
|
if !validLandingPages[*patch.LandingPage] {
|
|
writeErrorMsg(w, http.StatusBadRequest, "unrecognized landing page")
|
|
return
|
|
}
|
|
row.landingPage = *patch.LandingPage
|
|
}
|
|
if patch.Density != nil {
|
|
if !validDensities[*patch.Density] {
|
|
writeErrorMsg(w, http.StatusBadRequest, "density must be one of: comfortable, compact")
|
|
return
|
|
}
|
|
row.density = *patch.Density
|
|
}
|
|
|
|
if err := s.saveDefaultPreferencesRow(r.Context(), row); err != nil {
|
|
s.writeError(w, http.StatusInternalServerError, err)
|
|
return
|
|
}
|
|
s.audit(r, "settings.defaults", "settings", "updated")
|
|
writeJSON(w, http.StatusOK, toDefaultPreferencesResponse(row))
|
|
}
|