feat(storage): rename snapraid_content to snapraid_conf; clarify conf vs content file; add auto-detect and conf viewer

This commit is contained in:
2026-07-07 01:17:55 -04:00
parent 047f0f5c09
commit 599af5a828
11 changed files with 143 additions and 42 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
BINARY=nasctl BINARY=nasctl
VERSION?=0.7.6 VERSION?=0.8.0
GO?=go GO?=go
LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
BUILD_FLAGS=CGO_ENABLED=0 BUILD_FLAGS=CGO_ENABLED=0
@@ -0,0 +1,4 @@
-- Rename snapraid_content to snapraid_conf to reflect that this field
-- holds the path to the snapraid.conf TEXT configuration file,
-- not the binary snapraid.content database file.
ALTER TABLE storage_config RENAME COLUMN snapraid_content TO snapraid_conf;
+1 -1
View File
@@ -97,7 +97,7 @@ type StorageConfig struct {
MoverInplace bool `json:"mover_inplace"` MoverInplace bool `json:"mover_inplace"`
MoverRsyncOptions string `json:"mover_rsync_options"` MoverRsyncOptions string `json:"mover_rsync_options"`
MoverWarningThreshold int `json:"mover_warning_threshold"` MoverWarningThreshold int `json:"mover_warning_threshold"`
SnapraidContent string `json:"snapraid_content"` SnapraidConf string `json:"snapraid_conf"`
SnapraidDataDirs string `json:"snapraid_data_dirs"` SnapraidDataDirs string `json:"snapraid_data_dirs"`
SnapraidParityDir string `json:"snapraid_parity_dir"` SnapraidParityDir string `json:"snapraid_parity_dir"`
SnapraidScrubPlan int `json:"snapraid_scrub_plan"` SnapraidScrubPlan int `json:"snapraid_scrub_plan"`
+4 -4
View File
@@ -19,7 +19,7 @@ func scanStorageConfig(row interface {
&removeSource, &removeSource,
&inplace, &inplace,
&c.MoverRsyncOptions, &c.MoverRsyncOptions,
&c.SnapraidContent, &c.SnapraidConf,
&c.SnapraidDataDirs, &c.SnapraidDataDirs,
&c.SnapraidParityDir, &c.SnapraidParityDir,
&c.SnapraidScrubPlan, &c.SnapraidScrubPlan,
@@ -36,7 +36,7 @@ func scanStorageConfig(row interface {
} }
func (d *DB) GetStorageConfig() (StorageConfig, error) { func (d *DB) GetStorageConfig() (StorageConfig, error) {
row := d.conn.QueryRow(`SELECT id, mover_source, mover_dest, mover_clean_macos, mover_remove_source, mover_inplace, mover_rsync_options, snapraid_content, snapraid_data_dirs, snapraid_parity_dir, snapraid_scrub_plan, mover_warning_threshold, updated_at FROM storage_config WHERE id = 1`) row := d.conn.QueryRow(`SELECT id, mover_source, mover_dest, mover_clean_macos, mover_remove_source, mover_inplace, mover_rsync_options, snapraid_conf, snapraid_data_dirs, snapraid_parity_dir, snapraid_scrub_plan, mover_warning_threshold, updated_at FROM storage_config WHERE id = 1`)
c, err := scanStorageConfig(row) c, err := scanStorageConfig(row)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return StorageConfig{ID: 1, MoverCleanMacOS: true, MoverRemoveSource: true, MoverInplace: true, SnapraidScrubPlan: 8}, nil return StorageConfig{ID: 1, MoverCleanMacOS: true, MoverRemoveSource: true, MoverInplace: true, SnapraidScrubPlan: 8}, nil
@@ -51,11 +51,11 @@ func (d *DB) UpdateStorageConfig(c StorageConfig) error {
_, err := d.conn.Exec(` _, err := d.conn.Exec(`
UPDATE storage_config UPDATE storage_config
SET mover_source=?, mover_dest=?, mover_clean_macos=?, mover_remove_source=?, mover_inplace=?, SET mover_source=?, mover_dest=?, mover_clean_macos=?, mover_remove_source=?, mover_inplace=?,
mover_rsync_options=?, snapraid_content=?, snapraid_data_dirs=?, snapraid_parity_dir=?, mover_rsync_options=?, snapraid_conf=?, snapraid_data_dirs=?, snapraid_parity_dir=?,
snapraid_scrub_plan=?, mover_warning_threshold=?, updated_at=datetime('now') snapraid_scrub_plan=?, mover_warning_threshold=?, updated_at=datetime('now')
WHERE id=1`, WHERE id=1`,
c.MoverSource, c.MoverDest, toInt(c.MoverCleanMacOS), toInt(c.MoverRemoveSource), toInt(c.MoverInplace), c.MoverSource, c.MoverDest, toInt(c.MoverCleanMacOS), toInt(c.MoverRemoveSource), toInt(c.MoverInplace),
c.MoverRsyncOptions, c.SnapraidContent, c.SnapraidDataDirs, c.SnapraidParityDir, c.MoverRsyncOptions, c.SnapraidConf, c.SnapraidDataDirs, c.SnapraidParityDir,
c.SnapraidScrubPlan, c.MoverWarningThreshold, c.ID, c.SnapraidScrubPlan, c.MoverWarningThreshold, c.ID,
) )
if err != nil { if err != nil {
+3 -3
View File
@@ -170,13 +170,13 @@ func (jm *JobManager) runJob(job db.StorageJob) {
return return
} }
sc := SnapraidConfig{ sc := SnapraidConfig{
Content: envcfg.SnapraidContent, Conf: envcfg.SnapraidConf,
DataDirs: ParseSnapraidDataDirs(envcfg.SnapraidDataDirs), DataDirs: ParseSnapraidDataDirs(envcfg.SnapraidDataDirs),
ParityDir: envcfg.SnapraidParityDir, ParityDir: envcfg.SnapraidParityDir,
ScrubPlan: envcfg.SnapraidScrubPlan, ScrubPlan: envcfg.SnapraidScrubPlan,
} }
if err := ValidateSnapraidContent(sc.Content); err != nil { if err := ValidateSnapraidConf(sc.Conf); err != nil {
jm.failJob(job.ID, -1, fmt.Sprintf("content validation: %v", err)) jm.failJob(job.ID, -1, fmt.Sprintf("conf validation: %v", err))
return return
} }
if job.Kind == "snapraid_scrub" { if job.Kind == "snapraid_scrub" {
+11 -11
View File
@@ -8,17 +8,17 @@ import (
) )
type SnapraidConfig struct { type SnapraidConfig struct {
Content string Conf string
DataDirs []string DataDirs []string
ParityDir string ParityDir string
ScrubPlan int ScrubPlan int
} }
func BuildSnapraidArgs(kind string, cfg SnapraidConfig) ([]string, error) { func BuildSnapraidArgs(kind string, cfg SnapraidConfig) ([]string, error) {
if cfg.Content == "" { if cfg.Conf == "" {
return nil, fmt.Errorf("snapraid content file is required") return nil, fmt.Errorf("snapraid conf file is required")
} }
args := []string{"snapraid", "-c", cfg.Content} args := []string{"snapraid", "-c", cfg.Conf}
switch kind { switch kind {
case "snapraid_diff": case "snapraid_diff":
args = append(args, "diff") args = append(args, "diff")
@@ -34,16 +34,16 @@ func BuildSnapraidArgs(kind string, cfg SnapraidConfig) ([]string, error) {
return args, nil return args, nil
} }
func ValidateSnapraidContent(content string) error { func ValidateSnapraidConf(conf string) error {
if content == "" { if conf == "" {
return fmt.Errorf("snapraid content file is required") return fmt.Errorf("snapraid conf file is required")
} }
info, err := os.Stat(content) info, err := os.Stat(conf)
if err != nil { if err != nil {
return fmt.Errorf("snapraid content %s: %w", content, err) return fmt.Errorf("snapraid conf %s: %w", conf, err)
} }
if info.IsDir() { if info.IsDir() {
return fmt.Errorf("snapraid content %s: is a directory, not a file", content) return fmt.Errorf("snapraid conf %s: is a directory, not a file", conf)
} }
return nil return nil
} }
+11 -11
View File
@@ -10,23 +10,23 @@ func TestBuildSnapraidArgs(t *testing.T) {
}{ }{
{ {
"snapraid_diff", "snapraid_diff",
SnapraidConfig{Content: "/pool/snapraid.content"}, SnapraidConfig{Conf: "/etc/snapraid.conf"},
[]string{"snapraid", "-c", "/pool/snapraid.content", "diff"}, []string{"snapraid", "-c", "/etc/snapraid.conf", "diff"},
}, },
{ {
"snapraid_sync", "snapraid_sync",
SnapraidConfig{Content: "/pool/snapraid.content"}, SnapraidConfig{Conf: "/etc/snapraid.conf"},
[]string{"snapraid", "-c", "/pool/snapraid.content", "sync"}, []string{"snapraid", "-c", "/etc/snapraid.conf", "sync"},
}, },
{ {
"snapraid_check", "snapraid_check",
SnapraidConfig{Content: "/pool/snapraid.content"}, SnapraidConfig{Conf: "/etc/snapraid.conf"},
[]string{"snapraid", "-c", "/pool/snapraid.content", "check"}, []string{"snapraid", "-c", "/etc/snapraid.conf", "check"},
}, },
{ {
"snapraid_scrub", "snapraid_scrub",
SnapraidConfig{Content: "/pool/snapraid.content", ScrubPlan: 8}, SnapraidConfig{Conf: "/etc/snapraid.conf", ScrubPlan: 8},
[]string{"snapraid", "-c", "/pool/snapraid.content", "scrub", "-p", "8"}, []string{"snapraid", "-c", "/etc/snapraid.conf", "scrub", "-p", "8"},
}, },
} }
for _, tt := range tests { for _, tt := range tests {
@@ -48,10 +48,10 @@ func TestBuildSnapraidArgs(t *testing.T) {
} }
} }
func TestBuildSnapraidArgsNoContent(t *testing.T) { func TestBuildSnapraidArgsNoConf(t *testing.T) {
_, err := BuildSnapraidArgs("snapraid_sync", SnapraidConfig{Content: ""}) _, err := BuildSnapraidArgs("snapraid_sync", SnapraidConfig{Conf: ""})
if err == nil { if err == nil {
t.Error("expected error for empty content") t.Error("expected error for empty conf")
} }
} }
+31 -3
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -31,9 +32,36 @@ func (s *Server) handleStorageGetConfig(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusInternalServerError, err.Error()) writeError(w, http.StatusInternalServerError, err.Error())
return return
} }
if cfg.SnapraidConf == "" {
for _, path := range []string{"/etc/snapraid.conf", "/usr/local/etc/snapraid.conf"} {
if info, err := os.Stat(path); err == nil && !info.IsDir() {
cfg.SnapraidConf = path
break
}
}
}
writeJSON(w, http.StatusOK, cfg) writeJSON(w, http.StatusOK, cfg)
} }
func (s *Server) handleStorageGetConfFile(w http.ResponseWriter, r *http.Request) {
cfg, err := s.DB.GetStorageConfig()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
if cfg.SnapraidConf == "" {
writeError(w, http.StatusBadRequest, "snapraid conf path not configured")
return
}
data, err := os.ReadFile(cfg.SnapraidConf)
if err != nil {
writeError(w, http.StatusInternalServerError, fmt.Sprintf("read %s: %v", cfg.SnapraidConf, err))
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write(data)
}
func (s *Server) handleStorageUpdateConfig(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStorageUpdateConfig(w http.ResponseWriter, r *http.Request) {
var cfg db.StorageConfig var cfg db.StorageConfig
defer r.Body.Close() defer r.Body.Close()
@@ -67,9 +95,9 @@ func (s *Server) handleStorageUpdateConfig(w http.ResponseWriter, r *http.Reques
return return
} }
} }
if cfg.SnapraidContent != "" { if cfg.SnapraidConf != "" {
if err := validateAbsPath(cfg.SnapraidContent); err != nil { if err := validateAbsPath(cfg.SnapraidConf); err != nil {
writeError(w, http.StatusBadRequest, fmt.Sprintf("snapraid_content: %v", err)) writeError(w, http.StatusBadRequest, fmt.Sprintf("snapraid_conf: %v", err))
return return
} }
} }
+1
View File
@@ -92,6 +92,7 @@ func NewRouter(s *Server) chi.Router {
storageRouter.Get("/disk-usage", s.handleStorageDiskUsage) storageRouter.Get("/disk-usage", s.handleStorageDiskUsage)
storageRouter.Get("/config", s.handleStorageGetConfig) storageRouter.Get("/config", s.handleStorageGetConfig)
storageRouter.Put("/config", s.handleStorageUpdateConfig) storageRouter.Put("/config", s.handleStorageUpdateConfig)
storageRouter.Get("/conf-file", s.handleStorageGetConfFile)
storageRouter.Route("/jobs", func(j chi.Router) { storageRouter.Route("/jobs", func(j chi.Router) {
j.Get("/", s.handleStorageListJobs) j.Get("/", s.handleStorageListJobs)
j.Post("/", s.handleStorageStartJob) j.Post("/", s.handleStorageStartJob)
+5 -1
View File
@@ -170,7 +170,7 @@ export interface StorageConfig {
mover_inplace: boolean; mover_inplace: boolean;
mover_rsync_options: string; mover_rsync_options: string;
mover_warning_threshold: number; mover_warning_threshold: number;
snapraid_content: string; snapraid_conf: string;
snapraid_data_dirs: string; snapraid_data_dirs: string;
snapraid_parity_dir: string; snapraid_parity_dir: string;
snapraid_scrub_plan: number; snapraid_scrub_plan: number;
@@ -322,6 +322,10 @@ export const api = {
request<StorageJob>("POST", "/storage/jobs", { kind, args }), request<StorageJob>("POST", "/storage/jobs", { kind, args }),
cancelStorageJob: (id: number) => request<{ ok: boolean }>("POST", `/storage/jobs/${id}/cancel`), cancelStorageJob: (id: number) => request<{ ok: boolean }>("POST", `/storage/jobs/${id}/cancel`),
storageJobStreamUrl: (id: number) => `/api/storage/jobs/${id}/stream`, storageJobStreamUrl: (id: number) => `/api/storage/jobs/${id}/stream`,
getStorageConfFile: () => fetch(`/api/storage/conf-file`).then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}: ${r.statusText}`);
return r.text();
}),
}; };
export function formatBytes(bytes: number): string { export function formatBytes(bytes: number): string {
+71 -7
View File
@@ -41,7 +41,10 @@ export default function Storage() {
const [scrubPlanInput, setScrubPlanInput] = useState("8"); const [scrubPlanInput, setScrubPlanInput] = useState("8");
const [dirtyConfig, setDirtyConfig] = useState(false); const [dirtyConfig, setDirtyConfig] = useState(false);
const [pendingConfig, setPendingConfig] = useState<Partial<StorageConfig>>({}); const [pendingConfig, setPendingConfig] = useState<Partial<StorageConfig>>({});
const outputRef = useRef<HTMLPreElement>(null); const [confModalOpen, setConfModalOpen] = useState(false);
const [confModalContent, setConfModalContent] = useState("");
const [confModalError, setConfModalError] = useState("");
const outputRef = useRef<HTMLPreElement | null>(null);
const esRef = useRef<EventSource | null>(null); const esRef = useRef<EventSource | null>(null);
useEffect(() => { useEffect(() => {
@@ -116,6 +119,18 @@ export default function Storage() {
} }
} }
async function openConfModal() {
setConfModalOpen(true);
setConfModalContent("");
setConfModalError("");
try {
const content = await api.getStorageConfFile();
setConfModalContent(content);
} catch (e: unknown) {
setConfModalError(e instanceof Error ? e.message : String(e));
}
}
async function startJob(kind: JobKind) { async function startJob(kind: JobKind) {
try { try {
const job = await api.startStorageJob(kind); const job = await api.startStorageJob(kind);
@@ -311,12 +326,35 @@ export default function Storage() {
<fieldset className="space-y-3"> <fieldset className="space-y-3">
<legend className="text-sm font-medium text-slate-300">SnapRAID</legend> <legend className="text-sm font-medium text-slate-300">SnapRAID</legend>
<Field <div>
label="Content file" <Field
value={mergedConfig.snapraid_content ?? ""} label="Archivo de configuración"
onChange={(v) => handleConfigChange("snapraid_content", v)} value={mergedConfig.snapraid_conf ?? ""}
placeholder="/mnt/pool/snapraid.content" onChange={(v) => handleConfigChange("snapraid_conf", v)}
/> placeholder="/etc/snapraid.conf"
/>
<div className="mt-1 flex items-center gap-2">
<button
type="button"
className="text-xs text-slate-500 underline hover:text-slate-300 disabled:opacity-40"
onClick={openConfModal}
disabled={!mergedConfig.snapraid_conf}
>
Ver contenido
</button>
{!mergedConfig.snapraid_conf && (
<span className="text-xs text-amber-400">
No configurado. Busca en{" "}
<code className="text-amber-300">/etc/snapraid.conf</code> o{" "}
<code className="text-amber-300">/usr/local/etc/snapraid.conf</code>
</span>
)}
</div>
<p className="mt-1 text-xs text-slate-500">
Ruta al archivo <code className="text-slate-400">snapraid.conf</code>. Este es el archivo de configuración de texto,{" "}
<span className="text-amber-400">no el archivo binario .content</span>.
</p>
</div>
<Field <Field
label="Directorios de datos (CSV)" label="Directorios de datos (CSV)"
value={mergedConfig.snapraid_data_dirs ?? ""} value={mergedConfig.snapraid_data_dirs ?? ""}
@@ -503,6 +541,32 @@ export default function Storage() {
</div> </div>
)} )}
</div> </div>
{/* Conf file viewer modal */}
{confModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60">
<div className="w-full max-w-2xl rounded-lg border border-slate-600 bg-slate-800 p-4 shadow-xl">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium text-slate-200">
{mergedConfig.snapraid_conf ?? "snapraid.conf"}
</h3>
<button
className="text-slate-400 hover:text-white"
onClick={() => setConfModalOpen(false)}
>
</button>
</div>
{confModalError ? (
<p className="text-sm text-red-400">{confModalError}</p>
) : (
<pre className="max-h-96 overflow-auto rounded bg-slate-900 p-3 text-xs text-slate-300 font-mono">
{confModalContent || "Cargando..."}
</pre>
)}
</div>
</div>
)}
</div> </div>
); );
} }