bones
This commit is contained in:
parent
3d302ee541
commit
ff2cc25cb0
|
|
@ -0,0 +1,34 @@
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Editors / IDEs
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# Go build artifacts
|
||||||
|
bin/
|
||||||
|
dist/
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.out
|
||||||
|
|
||||||
|
# Go module workspace (optional local override files)
|
||||||
|
go.work
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# Application runtime data
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
@ -29,7 +29,9 @@ import (
|
||||||
|
|
||||||
const (
|
const (
|
||||||
sessionCookieName = "satoru_session"
|
sessionCookieName = "satoru_session"
|
||||||
sessionTTL = 24 * time.Hour * 14
|
sessionTTL = 24 * time.Hour * 7
|
||||||
|
scanInterval = 24 * time.Hour
|
||||||
|
scanLoopTick = time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
|
|
@ -48,6 +50,11 @@ func main() {
|
||||||
defer st.Close()
|
defer st.Close()
|
||||||
|
|
||||||
a := &app{store: st}
|
a := &app{store: st}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
go a.startSiteScanLoop(ctx)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
fileServer := http.FileServer(http.Dir("web/static"))
|
fileServer := http.FileServer(http.Dir("web/static"))
|
||||||
|
|
@ -72,7 +79,7 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := a.currentUser(r.Context(), r)
|
user, err := a.currentUserWithRollingSession(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
templ.Handler(webui.Home(time.Now(), store.User{})).ServeHTTP(w, r)
|
templ.Handler(webui.Home(time.Now(), store.User{})).ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
|
|
@ -96,7 +103,7 @@ func (a *app) handleHome(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) handleSignupPage(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleSignupPage(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, err := a.currentUser(r.Context(), r); err == nil {
|
if _, err := a.currentUserWithRollingSession(w, r); err == nil {
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -150,7 +157,7 @@ func (a *app) handleSignupSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) handleSigninPage(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleSigninPage(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, err := a.currentUser(r.Context(), r); err == nil {
|
if _, err := a.currentUserWithRollingSession(w, r); err == nil {
|
||||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +210,7 @@ func (a *app) handleSignoutSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) handlePasswordPage(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handlePasswordPage(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := a.currentUser(r.Context(), r)
|
user, err := a.currentUserWithRollingSession(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
|
|
@ -212,7 +219,7 @@ func (a *app) handlePasswordPage(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) handlePasswordSubmit(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handlePasswordSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
user, err := a.currentUser(r.Context(), r)
|
user, err := a.currentUserWithRollingSession(w, r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
|
|
@ -257,7 +264,7 @@ func (a *app) handlePasswordSubmit(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) handleSiteCreate(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleSiteCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, err := a.currentUser(r.Context(), r); err != nil {
|
if _, err := a.currentUserWithRollingSession(w, r); err != nil {
|
||||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -268,21 +275,36 @@ func (a *app) handleSiteCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
sshUser := strings.TrimSpace(r.FormValue("ssh_user"))
|
sshUser := strings.TrimSpace(r.FormValue("ssh_user"))
|
||||||
host := strings.TrimSpace(r.FormValue("host"))
|
host := strings.TrimSpace(r.FormValue("host"))
|
||||||
remotePath := strings.TrimSpace(r.FormValue("remote_path"))
|
port, err := parsePort(r.FormValue("port"))
|
||||||
if sshUser == "" || host == "" || remotePath == "" {
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/?msg=site-invalid-port", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
directoryPaths := parsePathList(r.FormValue("directory_paths"))
|
||||||
|
sqlitePaths := parsePathList(r.FormValue("sqlite_paths"))
|
||||||
|
targets := buildTargets(directoryPaths, sqlitePaths)
|
||||||
|
if sshUser == "" || host == "" || len(targets) == 0 {
|
||||||
http.Redirect(w, r, "/?msg=site-invalid", http.StatusSeeOther)
|
http.Redirect(w, r, "/?msg=site-invalid", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !targetsAreValid(targets) {
|
||||||
|
http.Redirect(w, r, "/?msg=site-invalid-path", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if _, err := a.store.CreateSite(r.Context(), sshUser, host, remotePath); err != nil {
|
site, err := a.store.CreateSite(r.Context(), sshUser, host, port, targets)
|
||||||
|
if err != nil {
|
||||||
http.Error(w, "failed to add site", http.StatusInternalServerError)
|
http.Error(w, "failed to add site", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
scanCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||||
|
a.scanSiteNow(scanCtx, site.ID)
|
||||||
|
cancel()
|
||||||
http.Redirect(w, r, "/?msg=site-added", http.StatusSeeOther)
|
http.Redirect(w, r, "/?msg=site-added", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleSiteRun(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, err := a.currentUser(r.Context(), r); err != nil {
|
if _, err := a.currentUserWithRollingSession(w, r); err != nil {
|
||||||
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
http.Redirect(w, r, "/signin", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -322,6 +344,11 @@ func (a *app) issueSession(w http.ResponseWriter, r *http.Request, userID int64)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: sessionCookieName,
|
Name: sessionCookieName,
|
||||||
Value: token,
|
Value: token,
|
||||||
|
|
@ -331,7 +358,6 @@ func (a *app) issueSession(w http.ResponseWriter, r *http.Request, userID int64)
|
||||||
Secure: r.TLS != nil,
|
Secure: r.TLS != nil,
|
||||||
Expires: expiresAt,
|
Expires: expiresAt,
|
||||||
})
|
})
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearSessionCookie(w http.ResponseWriter) {
|
func clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
|
@ -346,12 +372,31 @@ func clearSessionCookie(w http.ResponseWriter) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) currentUser(ctx context.Context, r *http.Request) (store.User, error) {
|
func (a *app) currentUser(ctx context.Context, r *http.Request) (store.User, string, error) {
|
||||||
c, err := r.Cookie(sessionCookieName)
|
c, err := r.Cookie(sessionCookieName)
|
||||||
if err != nil || c.Value == "" {
|
if err != nil || c.Value == "" {
|
||||||
return store.User{}, http.ErrNoCookie
|
return store.User{}, "", http.ErrNoCookie
|
||||||
}
|
}
|
||||||
return a.store.UserBySessionTokenHash(ctx, hashToken(c.Value))
|
user, err := a.store.UserBySessionTokenHash(ctx, hashToken(c.Value))
|
||||||
|
if err != nil {
|
||||||
|
return store.User{}, "", err
|
||||||
|
}
|
||||||
|
return user, c.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) currentUserWithRollingSession(w http.ResponseWriter, r *http.Request) (store.User, error) {
|
||||||
|
user, token, err := a.currentUser(r.Context(), r)
|
||||||
|
if err != nil {
|
||||||
|
return store.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
expiresAt := time.Now().Add(sessionTTL)
|
||||||
|
if err := a.store.TouchSessionByTokenHash(r.Context(), hashToken(token), expiresAt); err != nil {
|
||||||
|
clearSessionCookie(w)
|
||||||
|
return store.User{}, err
|
||||||
|
}
|
||||||
|
setSessionCookie(w, r, token, expiresAt)
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateToken() (string, error) {
|
func generateToken() (string, error) {
|
||||||
|
|
@ -396,7 +441,7 @@ func runSSHHello(ctx context.Context, site store.Site) (string, string) {
|
||||||
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(cmdCtx, "ssh", target, "echo hello from satoru")
|
cmd := exec.CommandContext(cmdCtx, "ssh", "-p", strconv.Itoa(site.Port), target, "echo hello from satoru")
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
output := strings.TrimSpace(string(out))
|
output := strings.TrimSpace(string(out))
|
||||||
if output == "" {
|
if output == "" {
|
||||||
|
|
@ -416,3 +461,178 @@ func defaultWorkflowStages() []webui.WorkflowStage {
|
||||||
{Title: "Audit and Recover", Description: "Each site records run output/status for operational visibility before full job history is added."},
|
{Title: "Audit and Recover", Description: "Each site records run output/status for operational visibility before full job history is added."},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parsePathList(raw string) []string {
|
||||||
|
split := strings.FieldsFunc(raw, func(r rune) bool {
|
||||||
|
return r == '\n' || r == ',' || r == ';'
|
||||||
|
})
|
||||||
|
out := make([]string, 0, len(split))
|
||||||
|
for _, item := range split {
|
||||||
|
item = strings.TrimSpace(item)
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTargets(directoryPaths, sqlitePaths []string) []store.SiteTarget {
|
||||||
|
out := make([]store.SiteTarget, 0, len(directoryPaths)+len(sqlitePaths))
|
||||||
|
for _, p := range directoryPaths {
|
||||||
|
out = append(out, store.SiteTarget{Path: p, Mode: "directory"})
|
||||||
|
}
|
||||||
|
for _, p := range sqlitePaths {
|
||||||
|
out = append(out, store.SiteTarget{Path: p, Mode: "sqlite_dump"})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func targetsAreValid(targets []store.SiteTarget) bool {
|
||||||
|
for _, t := range targets {
|
||||||
|
if t.Path == "" || !strings.HasPrefix(t.Path, "/") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePort(raw string) (int, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return 22, nil
|
||||||
|
}
|
||||||
|
port, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || port < 1 || port > 65535 {
|
||||||
|
return 0, errors.New("invalid port")
|
||||||
|
}
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) startSiteScanLoop(ctx context.Context) {
|
||||||
|
a.scanAllSites(ctx)
|
||||||
|
ticker := time.NewTicker(scanLoopTick)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
a.scanDueSites(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) scanAllSites(ctx context.Context) {
|
||||||
|
sites, err := a.store.ListSites(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan loop: failed to list sites: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, site := range sites {
|
||||||
|
a.scanSiteNow(ctx, site.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) scanDueSites(ctx context.Context) {
|
||||||
|
sites, err := a.store.ListSites(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan loop: failed to list sites: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, site := range sites {
|
||||||
|
if site.LastScanAt.Valid && site.LastScanAt.Time.Add(scanInterval).After(now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a.scanSiteNow(ctx, site.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *app) scanSiteNow(ctx context.Context, siteID int64) {
|
||||||
|
site, err := a.store.SiteByID(ctx, siteID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("scan site %d: load failed: %v", siteID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scannedAt := time.Now()
|
||||||
|
success := 0
|
||||||
|
failures := 0
|
||||||
|
updated := make([]store.SiteTarget, 0, len(site.Targets))
|
||||||
|
for _, target := range site.Targets {
|
||||||
|
size, outErr := queryTargetSize(ctx, site, target)
|
||||||
|
target.LastScanAt = sql.NullTime{Time: scannedAt, Valid: true}
|
||||||
|
if outErr != nil {
|
||||||
|
failures++
|
||||||
|
target.LastSizeByte = sql.NullInt64{}
|
||||||
|
target.LastError = sql.NullString{String: outErr.Error(), Valid: true}
|
||||||
|
} else {
|
||||||
|
success++
|
||||||
|
target.LastSizeByte = sql.NullInt64{Int64: size, Valid: true}
|
||||||
|
target.LastError = sql.NullString{}
|
||||||
|
}
|
||||||
|
updated = append(updated, target)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := "ok"
|
||||||
|
switch {
|
||||||
|
case len(site.Targets) == 0:
|
||||||
|
state = "failed"
|
||||||
|
case failures == len(site.Targets):
|
||||||
|
state = "failed"
|
||||||
|
case failures > 0:
|
||||||
|
state = "partial"
|
||||||
|
}
|
||||||
|
notes := fmt.Sprintf("%d/%d targets scanned", success, len(site.Targets))
|
||||||
|
if err := a.store.UpdateSiteScanResult(ctx, site.ID, state, notes, scannedAt, updated); err != nil {
|
||||||
|
log.Printf("scan site %d: update failed: %v", siteID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryTargetSize(ctx context.Context, site store.Site, target store.SiteTarget) (int64, error) {
|
||||||
|
targetAddr := fmt.Sprintf("%s@%s", site.SSHUser, site.Host)
|
||||||
|
cmdCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
remote := remoteSizeCommand(target)
|
||||||
|
cmd := exec.CommandContext(cmdCtx, "ssh", "-p", strconv.Itoa(site.Port), targetAddr, remote)
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
output := strings.TrimSpace(string(out))
|
||||||
|
if err != nil {
|
||||||
|
if output == "" {
|
||||||
|
output = err.Error()
|
||||||
|
}
|
||||||
|
return 0, errors.New(output)
|
||||||
|
}
|
||||||
|
size, ok := extractLastInteger(output)
|
||||||
|
if !ok {
|
||||||
|
return 0, errors.New("empty size output")
|
||||||
|
}
|
||||||
|
return size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteSizeCommand(target store.SiteTarget) string {
|
||||||
|
path := shellQuote(target.Path)
|
||||||
|
if target.Mode == "sqlite_dump" {
|
||||||
|
return fmt.Sprintf("stat -c%%s -- %s", path)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("du -sb -- %s | awk '{print $1}'", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shellQuote(s string) string {
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractLastInteger(output string) (int64, bool) {
|
||||||
|
fields := strings.Fields(output)
|
||||||
|
for i := len(fields) - 1; i >= 0; i-- {
|
||||||
|
v, err := strconv.ParseInt(fields[i], 10, 64)
|
||||||
|
if err == nil {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,11 +27,23 @@ type Site struct {
|
||||||
ID int64
|
ID int64
|
||||||
SSHUser string
|
SSHUser string
|
||||||
Host string
|
Host string
|
||||||
RemotePath string
|
Port int
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LastRunStatus sql.NullString
|
LastRunStatus sql.NullString
|
||||||
LastRunOutput sql.NullString
|
LastRunOutput sql.NullString
|
||||||
LastRunAt sql.NullTime
|
LastRunAt sql.NullTime
|
||||||
|
LastScanAt sql.NullTime
|
||||||
|
LastScanState sql.NullString
|
||||||
|
LastScanNotes sql.NullString
|
||||||
|
Targets []SiteTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteTarget struct {
|
||||||
|
Path string
|
||||||
|
Mode string
|
||||||
|
LastSizeByte sql.NullInt64
|
||||||
|
LastScanAt sql.NullTime
|
||||||
|
LastError sql.NullString
|
||||||
}
|
}
|
||||||
|
|
||||||
func Open(path string) (*Store, error) {
|
func Open(path string) (*Store, error) {
|
||||||
|
|
@ -80,48 +92,37 @@ CREATE TABLE IF NOT EXISTS sites (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
ssh_user TEXT NOT NULL,
|
ssh_user TEXT NOT NULL,
|
||||||
host TEXT NOT NULL,
|
host TEXT NOT NULL,
|
||||||
remote_path TEXT NOT NULL,
|
port INTEGER NOT NULL DEFAULT 22,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
last_run_status TEXT,
|
last_run_status TEXT,
|
||||||
last_run_output TEXT,
|
last_run_output TEXT,
|
||||||
last_run_at DATETIME
|
last_run_at DATETIME,
|
||||||
|
last_scan_at DATETIME,
|
||||||
|
last_scan_state TEXT,
|
||||||
|
last_scan_notes TEXT
|
||||||
|
);`
|
||||||
|
|
||||||
|
const siteTargetsSQL = `
|
||||||
|
CREATE TABLE IF NOT EXISTS site_targets (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
site_id INTEGER NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
mode TEXT NOT NULL CHECK(mode IN ('directory', 'sqlite_dump')),
|
||||||
|
last_size_bytes INTEGER,
|
||||||
|
last_scan_at DATETIME,
|
||||||
|
last_error TEXT
|
||||||
);`
|
);`
|
||||||
|
|
||||||
if _, err := s.db.ExecContext(ctx, usersSQL); err != nil {
|
if _, err := s.db.ExecContext(ctx, usersSQL); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := s.migrateUsersLegacyEmail(ctx); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := s.db.ExecContext(ctx, sessionsSQL); err != nil {
|
if _, err := s.db.ExecContext(ctx, sessionsSQL); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := s.db.ExecContext(ctx, sitesSQL); err != nil {
|
if _, err := s.db.ExecContext(ctx, sitesSQL); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
if _, err := s.db.ExecContext(ctx, siteTargetsSQL); err != nil {
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) migrateUsersLegacyEmail(ctx context.Context) error {
|
|
||||||
cols, err := tableColumns(ctx, s.db, "users")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if cols["username"] {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.db.ExecContext(ctx, `ALTER TABLE users ADD COLUMN username TEXT`); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if cols["email"] {
|
|
||||||
if _, err := s.db.ExecContext(ctx, `UPDATE users SET username = lower(trim(email)) WHERE username IS NULL`); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_users_username ON users(username)`); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -203,19 +204,39 @@ func (s *Store) DeleteSessionByTokenHash(ctx context.Context, tokenHash string)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) TouchSessionByTokenHash(ctx context.Context, tokenHash string, expiresAt time.Time) error {
|
||||||
|
res, err := s.db.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE sessions SET expires_at = ? WHERE token_hash = ? AND expires_at > CURRENT_TIMESTAMP`,
|
||||||
|
expiresAt.UTC().Format(time.RFC3339),
|
||||||
|
tokenHash,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rows == 0 {
|
||||||
|
return sql.ErrNoRows
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateUserPasswordHash(ctx context.Context, userID int64, passwordHash string) error {
|
func (s *Store) UpdateUserPasswordHash(ctx context.Context, userID int64, passwordHash string) error {
|
||||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
|
_, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, passwordHash, userID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) CreateSite(ctx context.Context, sshUser, host, remotePath string) (Site, error) {
|
func (s *Store) CreateSite(ctx context.Context, sshUser, host string, port int, targets []SiteTarget) (Site, error) {
|
||||||
res, err := s.db.ExecContext(
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
ctx,
|
if err != nil {
|
||||||
`INSERT INTO sites (ssh_user, host, remote_path) VALUES (?, ?, ?)`,
|
return Site{}, err
|
||||||
sshUser,
|
}
|
||||||
host,
|
defer tx.Rollback()
|
||||||
remotePath,
|
|
||||||
)
|
res, err := tx.ExecContext(ctx, `INSERT INTO sites (ssh_user, host, port) VALUES (?, ?, ?)`, sshUser, host, port)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Site{}, err
|
return Site{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -223,12 +244,28 @@ func (s *Store) CreateSite(ctx context.Context, sshUser, host, remotePath string
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Site{}, err
|
return Site{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, t := range targets {
|
||||||
|
if _, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`INSERT INTO site_targets (site_id, path, mode) VALUES (?, ?, ?)`,
|
||||||
|
id,
|
||||||
|
t.Path,
|
||||||
|
t.Mode,
|
||||||
|
); err != nil {
|
||||||
|
return Site{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return Site{}, err
|
||||||
|
}
|
||||||
return s.SiteByID(ctx, id)
|
return s.SiteByID(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ListSites(ctx context.Context) ([]Site, error) {
|
func (s *Store) ListSites(ctx context.Context) ([]Site, error) {
|
||||||
const q = `
|
const q = `
|
||||||
SELECT id, ssh_user, host, remote_path, created_at, last_run_status, last_run_output, last_run_at
|
SELECT id, ssh_user, host, port, created_at, last_run_status, last_run_output, last_run_at, last_scan_at, last_scan_state, last_scan_notes
|
||||||
FROM sites
|
FROM sites
|
||||||
ORDER BY id DESC`
|
ORDER BY id DESC`
|
||||||
rows, err := s.db.QueryContext(ctx, q)
|
rows, err := s.db.QueryContext(ctx, q)
|
||||||
|
|
@ -248,15 +285,27 @@ ORDER BY id DESC`
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if err := s.populateTargets(ctx, out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) SiteByID(ctx context.Context, id int64) (Site, error) {
|
func (s *Store) SiteByID(ctx context.Context, id int64) (Site, error) {
|
||||||
const q = `
|
const q = `
|
||||||
SELECT id, ssh_user, host, remote_path, created_at, last_run_status, last_run_output, last_run_at
|
SELECT id, ssh_user, host, port, created_at, last_run_status, last_run_output, last_run_at, last_scan_at, last_scan_state, last_scan_notes
|
||||||
FROM sites
|
FROM sites
|
||||||
WHERE id = ?`
|
WHERE id = ?`
|
||||||
return scanSite(s.db.QueryRowContext(ctx, q, id))
|
site, err := scanSite(s.db.QueryRowContext(ctx, q, id))
|
||||||
|
if err != nil {
|
||||||
|
return Site{}, err
|
||||||
|
}
|
||||||
|
targets, err := s.targetsBySiteID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return Site{}, err
|
||||||
|
}
|
||||||
|
site.Targets = targets
|
||||||
|
return site, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateSiteRunResult(ctx context.Context, id int64, status, output string, at time.Time) error {
|
func (s *Store) UpdateSiteRunResult(ctx context.Context, id int64, status, output string, at time.Time) error {
|
||||||
|
|
@ -271,6 +320,42 @@ func (s *Store) UpdateSiteRunResult(ctx context.Context, id int64, status, outpu
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) UpdateSiteScanResult(ctx context.Context, siteID int64, state, notes string, scannedAt time.Time, targets []SiteTarget) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, t := range targets {
|
||||||
|
if _, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE site_targets SET last_size_bytes = ?, last_scan_at = ?, last_error = ? WHERE site_id = ? AND path = ? AND mode = ?`,
|
||||||
|
nullInt64Arg(t.LastSizeByte),
|
||||||
|
timeOrNil(t.LastScanAt),
|
||||||
|
nullStringArg(t.LastError),
|
||||||
|
siteID,
|
||||||
|
t.Path,
|
||||||
|
t.Mode,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(
|
||||||
|
ctx,
|
||||||
|
`UPDATE sites SET last_scan_at = ?, last_scan_state = ?, last_scan_notes = ? WHERE id = ?`,
|
||||||
|
scannedAt.UTC().Format(time.RFC3339),
|
||||||
|
state,
|
||||||
|
notes,
|
||||||
|
siteID,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
func userByIDTx(ctx context.Context, tx *sql.Tx, id int64) (User, error) {
|
func userByIDTx(ctx context.Context, tx *sql.Tx, id int64) (User, error) {
|
||||||
const q = `
|
const q = `
|
||||||
SELECT id, username, password_hash, is_admin, created_at
|
SELECT id, username, password_hash, is_admin, created_at
|
||||||
|
|
@ -299,17 +384,79 @@ func scanSite(row scanner) (Site, error) {
|
||||||
&site.ID,
|
&site.ID,
|
||||||
&site.SSHUser,
|
&site.SSHUser,
|
||||||
&site.Host,
|
&site.Host,
|
||||||
&site.RemotePath,
|
&site.Port,
|
||||||
&site.CreatedAt,
|
&site.CreatedAt,
|
||||||
&site.LastRunStatus,
|
&site.LastRunStatus,
|
||||||
&site.LastRunOutput,
|
&site.LastRunOutput,
|
||||||
&site.LastRunAt,
|
&site.LastRunAt,
|
||||||
|
&site.LastScanAt,
|
||||||
|
&site.LastScanState,
|
||||||
|
&site.LastScanNotes,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return Site{}, err
|
return Site{}, err
|
||||||
}
|
}
|
||||||
return site, nil
|
return site, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) populateTargets(ctx context.Context, sites []Site) error {
|
||||||
|
if len(sites) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
targetsBySite, err := s.allTargetsBySiteID(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range sites {
|
||||||
|
sites[i].Targets = targetsBySite[sites[i].ID]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) allTargetsBySiteID(ctx context.Context) (map[int64][]SiteTarget, error) {
|
||||||
|
const q = `SELECT site_id, path, mode, last_size_bytes, last_scan_at, last_error FROM site_targets ORDER BY id ASC`
|
||||||
|
rows, err := s.db.QueryContext(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
out := map[int64][]SiteTarget{}
|
||||||
|
for rows.Next() {
|
||||||
|
var siteID int64
|
||||||
|
var target SiteTarget
|
||||||
|
if err := rows.Scan(&siteID, &target.Path, &target.Mode, &target.LastSizeByte, &target.LastScanAt, &target.LastError); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[siteID] = append(out[siteID], target)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) targetsBySiteID(ctx context.Context, siteID int64) ([]SiteTarget, error) {
|
||||||
|
const q = `SELECT path, mode, last_size_bytes, last_scan_at, last_error FROM site_targets WHERE site_id = ? ORDER BY id ASC`
|
||||||
|
rows, err := s.db.QueryContext(ctx, q, siteID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []SiteTarget
|
||||||
|
for rows.Next() {
|
||||||
|
var target SiteTarget
|
||||||
|
if err := rows.Scan(&target.Path, &target.Mode, &target.LastSizeByte, &target.LastScanAt, &target.LastError); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, target)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func boolToInt(v bool) int {
|
func boolToInt(v bool) int {
|
||||||
if v {
|
if v {
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -323,28 +470,23 @@ func isUniqueUsernameErr(err error) bool {
|
||||||
return strings.Contains(err.Error(), "UNIQUE constraint failed: users.username")
|
return strings.Contains(err.Error(), "UNIQUE constraint failed: users.username")
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableColumns(ctx context.Context, db *sql.DB, table string) (map[string]bool, error) {
|
func nullInt64Arg(v sql.NullInt64) any {
|
||||||
rows, err := db.QueryContext(ctx, fmt.Sprintf("PRAGMA table_info(%s)", table))
|
if v.Valid {
|
||||||
if err != nil {
|
return v.Int64
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
return nil
|
||||||
|
}
|
||||||
cols := map[string]bool{}
|
|
||||||
for rows.Next() {
|
func nullStringArg(v sql.NullString) any {
|
||||||
var cid int
|
if v.Valid {
|
||||||
var name string
|
return v.String
|
||||||
var typ string
|
}
|
||||||
var notNull int
|
return nil
|
||||||
var dflt sql.NullString
|
}
|
||||||
var pk int
|
|
||||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
func timeOrNil(v sql.NullTime) any {
|
||||||
return nil, err
|
if v.Valid {
|
||||||
}
|
return v.Time.UTC().Format(time.RFC3339)
|
||||||
cols[name] = true
|
}
|
||||||
}
|
return nil
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return cols, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package webui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -76,24 +77,63 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
if site.LastRunOutput.Valid && strings.TrimSpace(site.LastRunOutput.String) != "" {
|
if site.LastRunOutput.Valid && strings.TrimSpace(site.LastRunOutput.String) != "" {
|
||||||
runOutput = site.LastRunOutput.String
|
runOutput = site.LastRunOutput.String
|
||||||
}
|
}
|
||||||
|
scanState := "pending"
|
||||||
|
if site.LastScanState.Valid && site.LastScanState.String != "" {
|
||||||
|
scanState = site.LastScanState.String
|
||||||
|
}
|
||||||
|
lastScan := "Never"
|
||||||
|
if site.LastScanAt.Valid {
|
||||||
|
lastScan = site.LastScanAt.Time.Local().Format(time.RFC1123)
|
||||||
|
}
|
||||||
|
scanNotes := "No scan yet."
|
||||||
|
if site.LastScanNotes.Valid && site.LastScanNotes.String != "" {
|
||||||
|
scanNotes = site.LastScanNotes.String
|
||||||
|
}
|
||||||
|
|
||||||
|
var targets strings.Builder
|
||||||
|
if len(site.Targets) == 0 {
|
||||||
|
targets.WriteString(`<li><span class="pill pending">none</span> <code>(no targets)</code></li>`)
|
||||||
|
}
|
||||||
|
for _, t := range site.Targets {
|
||||||
|
sizeOrErr := "size pending"
|
||||||
|
if t.LastError.Valid && t.LastError.String != "" {
|
||||||
|
sizeOrErr = "error: " + t.LastError.String
|
||||||
|
} else if t.LastSizeByte.Valid {
|
||||||
|
sizeOrErr = formatBytes(t.LastSizeByte.Int64)
|
||||||
|
}
|
||||||
|
targets.WriteString(fmt.Sprintf(`<li><span class="pill %s">%s</span> <code>%s</code><span class="muted inline">%s</span></li>`,
|
||||||
|
targetModeClass(t.Mode),
|
||||||
|
html.EscapeString(targetModeLabel(t.Mode)),
|
||||||
|
html.EscapeString(t.Path),
|
||||||
|
html.EscapeString(sizeOrErr)))
|
||||||
|
}
|
||||||
|
|
||||||
sites.WriteString(fmt.Sprintf(`
|
sites.WriteString(fmt.Sprintf(`
|
||||||
<article class="site-card">
|
<article class="site-card">
|
||||||
<div class="site-head">
|
<div class="site-head">
|
||||||
<h3>%s@%s</h3>
|
<h3>%s@%s:%d</h3>
|
||||||
<form method="post" action="/sites/%d/run">
|
<form method="post" action="/sites/%d/run">
|
||||||
<button class="button" type="submit">Run</button>
|
<button class="button" type="submit">Run</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">Path: <code>%s</code></p>
|
<p class="muted">Backup targets:</p>
|
||||||
|
<ul class="target-list">%s</ul>
|
||||||
|
<p class="muted">Scan: <span class="pill %s">%s</span> · Last: %s · Next: %s</p>
|
||||||
|
<p class="muted">%s</p>
|
||||||
<p class="muted">Last run: %s</p>
|
<p class="muted">Last run: %s</p>
|
||||||
<p class="muted">Status: <span class="pill %s">%s</span></p>
|
<p class="muted">Status: <span class="pill %s">%s</span></p>
|
||||||
<pre class="output">%s</pre>
|
<pre class="output">%s</pre>
|
||||||
</article>`,
|
</article>`,
|
||||||
html.EscapeString(site.SSHUser),
|
html.EscapeString(site.SSHUser),
|
||||||
html.EscapeString(site.Host),
|
html.EscapeString(site.Host),
|
||||||
|
site.Port,
|
||||||
site.ID,
|
site.ID,
|
||||||
html.EscapeString(site.RemotePath),
|
targets.String(),
|
||||||
|
html.EscapeString(scanState),
|
||||||
|
html.EscapeString(scanState),
|
||||||
|
html.EscapeString(lastScan),
|
||||||
|
html.EscapeString(timeUntilNextScan(data.Now, site.LastScanAt)),
|
||||||
|
html.EscapeString(scanNotes),
|
||||||
html.EscapeString(last),
|
html.EscapeString(last),
|
||||||
html.EscapeString(runStatus),
|
html.EscapeString(runStatus),
|
||||||
html.EscapeString(runStatus),
|
html.EscapeString(runStatus),
|
||||||
|
|
@ -138,10 +178,12 @@ func Dashboard(data DashboardData) templ.Component {
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h2>Add Site</h2>
|
<h2>Add Site</h2>
|
||||||
<form class="grid-3" method="post" action="/sites">
|
<form class="grid-2" method="post" action="/sites">
|
||||||
<label class="stack"><span>SSH User</span><input name="ssh_user" required /></label>
|
<label class="stack"><span>SSH User</span><input name="ssh_user" required /></label>
|
||||||
<label class="stack"><span>Server</span><input name="host" placeholder="host or ip" required /></label>
|
<label class="stack"><span>Server</span><input name="host" placeholder="host or ip" required /></label>
|
||||||
<label class="stack"><span>Path</span><input name="remote_path" placeholder="/var/www" required /></label>
|
<label class="stack"><span>Port</span><input type="number" name="port" min="1" max="65535" value="22" required /></label>
|
||||||
|
<label class="stack"><span>Directory paths (one per line)</span><textarea name="directory_paths" placeholder="/var/www /etc/nginx" rows="4"></textarea></label>
|
||||||
|
<label class="stack"><span>SQLite DB paths (will dump, one per line)</span><textarea name="sqlite_paths" placeholder="/srv/app/db/app.sqlite3" rows="4"></textarea></label>
|
||||||
<button class="button" type="submit">Add site</button>
|
<button class="button" type="submit">Add site</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
@ -170,10 +212,60 @@ func formatFlash(code string) string {
|
||||||
case "site-ran":
|
case "site-ran":
|
||||||
return "Run completed."
|
return "Run completed."
|
||||||
case "site-invalid":
|
case "site-invalid":
|
||||||
return "All site fields are required."
|
return "SSH user, host, and at least one target path are required."
|
||||||
|
case "site-invalid-path":
|
||||||
|
return "Target paths must be absolute Linux paths starting with '/'."
|
||||||
|
case "site-invalid-port":
|
||||||
|
return "Port must be an integer between 1 and 65535."
|
||||||
case "password-updated":
|
case "password-updated":
|
||||||
return "Password updated."
|
return "Password updated."
|
||||||
default:
|
default:
|
||||||
return code
|
return code
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func targetModeLabel(mode string) string {
|
||||||
|
if mode == "sqlite_dump" {
|
||||||
|
return "sqlite dump"
|
||||||
|
}
|
||||||
|
return "directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
func targetModeClass(mode string) string {
|
||||||
|
if mode == "sqlite_dump" {
|
||||||
|
return "sqlite"
|
||||||
|
}
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatBytes(v int64) string {
|
||||||
|
if v < 1024 {
|
||||||
|
return fmt.Sprintf("%d B", v)
|
||||||
|
}
|
||||||
|
units := []string{"KB", "MB", "GB", "TB"}
|
||||||
|
value := float64(v)
|
||||||
|
for _, u := range units {
|
||||||
|
value /= 1024
|
||||||
|
if value < 1024 {
|
||||||
|
return fmt.Sprintf("%.1f %s", value, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f PB", value/1024)
|
||||||
|
}
|
||||||
|
|
||||||
|
func timeUntilNextScan(now time.Time, lastScan sql.NullTime) string {
|
||||||
|
if !lastScan.Valid {
|
||||||
|
return "due now"
|
||||||
|
}
|
||||||
|
next := lastScan.Time.Add(24 * time.Hour)
|
||||||
|
if !next.After(now) {
|
||||||
|
return "due now"
|
||||||
|
}
|
||||||
|
d := next.Sub(now).Round(time.Minute)
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(d.Minutes()) % 60
|
||||||
|
if h == 0 {
|
||||||
|
return fmt.Sprintf("%dm", m)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%dh %dm", h, m)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -172,6 +172,13 @@ h2 {
|
||||||
align-items: end;
|
align-items: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.grid-2 {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
.site-card {
|
.site-card {
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
@ -210,6 +217,28 @@ h2 {
|
||||||
color: #fecaca;
|
color: #fecaca;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pill.partial {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.sqlite {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
color: #fde68a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.pending {
|
||||||
|
border-color: var(--border);
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.target-list {
|
||||||
|
margin: 0.25rem 0 0.75rem;
|
||||||
|
padding-left: 1.1rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
.output {
|
.output {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
|
|
@ -220,11 +249,25 @@ h2 {
|
||||||
background: color-mix(in srgb, var(--card) 80%, black);
|
background: color-mix(in srgb, var(--card) 80%, black);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--card) 75%, black);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
margin: 0.75rem 0 0;
|
margin: 0.75rem 0 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.muted.inline {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #7dd3fc;
|
color: #7dd3fc;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue