v0.4.5: Add dedicated Backup page (Biztonsági mentés)

New /backups page with full backup system visibility:
- Status overview cards (local/remote backup, DB count, repo size)
- Schedule section with next-run times and retention policy
- Database table with type, size, validation (table count), status
- Snapshot history table with per-snapshot stats
- Repository info card with paths, integrity status, remote placeholder
- "Mentés most" button with auto-refresh polling
- Empty state when backup not configured

Backend: SnapshotRecord history (ring buffer), DumpValidation,
ListDumpFiles, ListSnapshots, GetFullStatus, restic check tracking.
Server accepts scheduler for next-run time calculation.

Sidebar nav updated with 3rd item, dashboard backup card title clickable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 07:43:24 +01:00
parent 0985339e6c
commit 37ff296a0d
12 changed files with 1064 additions and 16 deletions
+143
View File
@@ -3,7 +3,10 @@ package web
import (
"fmt"
"html/template"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
@@ -157,5 +160,145 @@ func (s *Server) templateFuncMap() template.FuncMap {
return "available"
}
},
"timeAgo": func(t time.Time) string {
if t.IsZero() {
return ""
}
loc, _ := time.LoadLocation("Europe/Budapest")
if loc == nil {
loc = time.UTC
}
now := time.Now().In(loc)
d := now.Sub(t.In(loc))
switch {
case d < time.Minute:
return "most"
case d < time.Hour:
return fmt.Sprintf("%d perce", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%d órája", int(d.Hours()))
case d < 48*time.Hour:
return "tegnap"
default:
return fmt.Sprintf("%d napja", int(d.Hours()/24))
}
},
"fmtTime": func(t time.Time) string {
if t.IsZero() {
return ""
}
loc, _ := time.LoadLocation("Europe/Budapest")
if loc == nil {
loc = time.UTC
}
return t.In(loc).Format("2006-01-02 15:04")
},
"fmtTimeShort": func(t time.Time) string {
if t.IsZero() {
return ""
}
loc, _ := time.LoadLocation("Europe/Budapest")
if loc == nil {
loc = time.UTC
}
lt := t.In(loc)
now := time.Now().In(loc)
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() {
return lt.Format("15:04")
}
return lt.Format("01-02 15:04")
},
"dbTypeLabel": func(t backup.DBType) string {
switch t {
case backup.DBTypePostgres:
return "PostgreSQL"
case backup.DBTypeMariaDB:
return "MariaDB"
default:
return string(t)
}
},
"nextRunLabel": func(t time.Time) string {
if t.IsZero() {
return ""
}
loc, _ := time.LoadLocation("Europe/Budapest")
if loc == nil {
loc = time.UTC
}
lt := t.In(loc)
now := time.Now().In(loc)
timeStr := lt.Format("15:04")
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay() {
return "ma " + timeStr
}
if lt.Year() == now.Year() && lt.YearDay() == now.YearDay()+1 {
return "holnap " + timeStr
}
return lt.Format("2006-01-02") + " " + timeStr
},
"pruneLabel": func(s string) string {
switch strings.ToLower(s) {
case "weekly":
return "vasárnap"
case "daily":
return "naponta"
case "sunday":
return "vasárnap"
default:
return s
}
},
"nextPruneLabel": func(schedule string) string {
loc, _ := time.LoadLocation("Europe/Budapest")
if loc == nil {
loc = time.UTC
}
now := time.Now().In(loc)
var next time.Time
switch strings.ToLower(schedule) {
case "daily":
next = now.Add(24 * time.Hour)
default: // weekly/sunday
daysUntilSunday := (7 - int(now.Weekday())) % 7
if daysUntilSunday == 0 && now.Hour() >= 4 {
daysUntilSunday = 7
}
next = now.AddDate(0, 0, daysUntilSunday)
}
return next.Format("2006-01-02")
},
"fmtDuration": func(d time.Duration) string {
if d < time.Second {
return "< 1s"
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
},
"fmtBytes": func(b int64) string {
const (
kb = 1024
mb = 1024 * kb
gb = 1024 * mb
)
switch {
case b >= int64(gb):
return fmt.Sprintf("%.1f GB", float64(b)/float64(gb))
case b >= int64(mb):
return fmt.Sprintf("%.1f MB", float64(b)/float64(mb))
case b >= int64(kb):
return fmt.Sprintf("%.1f KB", float64(b)/float64(kb))
default:
return fmt.Sprintf("%d B", b)
}
},
"shortID": func(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
},
}
}
+16
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
@@ -181,3 +182,18 @@ func (s *Server) appDetailHandler(w http.ResponseWriter, _ *http.Request, slug s
s.render(w, "app_info", data)
}
func (s *Server) backupsHandler(w http.ResponseWriter, _ *http.Request) {
data := s.baseData("backups", "Biztonsági mentés")
if s.backupMgr != nil {
nextDBDump := scheduler.NextDailyRun(s.cfg.Backup.DBDumpSchedule)
nextBackup := scheduler.NextDailyRun(s.cfg.Backup.ResticSchedule)
fullStatus := s.backupMgr.GetFullStatus(nextDBDump, nextBackup)
data["Backup"] = fullStatus
} else {
data["Backup"] = nil
}
s.render(w, "backups", data)
}
+6 -1
View File
@@ -12,6 +12,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/scheduler"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
)
@@ -21,6 +22,7 @@ type Server struct {
stackMgr *stacks.Manager
cpuCollector *system.CPUCollector
backupMgr *backup.Manager
scheduler *scheduler.Scheduler
logger *log.Logger
version string
tmpl *template.Template
@@ -29,12 +31,13 @@ type Server struct {
sessionsMu sync.RWMutex
}
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, logger *log.Logger, version string) *Server {
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *system.CPUCollector, backupMgr *backup.Manager, sched *scheduler.Scheduler, logger *log.Logger, version string) *Server {
s := &Server{
cfg: cfg,
stackMgr: stackMgr,
cpuCollector: cpuCollector,
backupMgr: backupMgr,
scheduler: sched,
logger: logger,
version: version,
sessions: make(map[string]*session),
@@ -59,6 +62,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.dashboardHandler(w, r)
case path == "/stacks":
s.stacksHandler(w, r)
case path == "/backups":
s.backupsHandler(w, r)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/logs")
@@ -0,0 +1,311 @@
{{define "backups"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>Biztonsági mentés</h2>
<span class="domain-badge">{{.Domain}}</span>
</div>
{{if not .Backup}}
<div class="backup-empty-state">
<div class="backup-empty-icon">&#128737;</div>
<h3>Biztonsági mentés nincs beállítva</h3>
<p>A biztonsági mentés funkció nem aktív.<br>
Kérjük, vegye fel a kapcsolatot a Felhom csapattal a beállításhoz.</p>
</div>
{{else}}
<!-- Section 1: Status overview cards -->
<div class="stats-grid backup-page-cards">
{{if .Backup.LastBackup}}
{{if .Backup.LastBackup.Success}}
<div class="stat-card stat-running">
<div class="stat-value">&#10003;</div>
<div class="stat-label">Helyi mentés aktív</div>
</div>
{{else}}
<div class="stat-card stat-stopped">
<div class="stat-value">&#10007;</div>
<div class="stat-label">Helyi mentés sikertelen</div>
</div>
{{end}}
{{else}}
<div class="stat-card">
<div class="stat-value"></div>
<div class="stat-label">Helyi mentés</div>
</div>
{{end}}
<div class="stat-card" style="border-left-color: var(--gray);">
<div class="stat-value" style="background:var(--gray);-webkit-background-clip:text;background-clip:text;"></div>
<div class="stat-label">Távoli mentés<br><span class="relative-time">nincs beállítva</span></div>
</div>
<div class="stat-card stat-total">
<div class="stat-value">
{{if .Backup.LastDBDump}}{{len .Backup.LastDBDump.Results}}{{else}}{{len .Backup.DumpFiles}}{{end}}
</div>
<div class="stat-label">Adatbázis mentve</div>
</div>
<div class="stat-card stat-total">
<div class="stat-value">
{{if .Backup.RepoStats}}{{.Backup.RepoStats.TotalSize}}{{else}}{{end}}
</div>
<div class="stat-label">Tároló méret
{{if .Backup.RepoStats}}<br><span class="relative-time">{{.Backup.RepoStats.SnapshotCount}} pillanatkép</span>{{end}}
</div>
</div>
</div>
<!-- Section 2: Schedule -->
<div class="schedule-card">
<h3>Ütemezés</h3>
<div class="schedule-rows">
<div class="schedule-row">
<span class="schedule-task">Adatbázis mentés</span>
<span class="schedule-time">{{.Backup.DBDumpSchedule}}</span>
<span class="schedule-next">Következő: {{nextRunLabel .Backup.NextDBDump}}</span>
</div>
<div class="schedule-row">
<span class="schedule-task">Restic pillanatkép</span>
<span class="schedule-time">{{.Backup.ResticSchedule}}</span>
<span class="schedule-next">Következő: {{nextRunLabel .Backup.NextBackup}}</span>
</div>
<div class="schedule-row">
<span class="schedule-task">Karbantartás</span>
<span class="schedule-time">{{pruneLabel .Backup.PruneSchedule}}</span>
<span class="schedule-next">Következő: {{nextPruneLabel .Backup.PruneSchedule}}</span>
</div>
</div>
<div class="schedule-summary">
{{if .Backup.LastBackup}}
<div class="schedule-summary-row">
<span>Utolsó sikeres mentés:</span>
<span class="schedule-summary-value">{{fmtTime .Backup.LastBackup.LastRun}} ({{timeAgo .Backup.LastBackup.LastRun}})</span>
</div>
<div class="schedule-summary-row">
<span>Mentés időtartam:</span>
<span class="schedule-summary-value">{{fmtDuration .Backup.LastBackup.Duration}}</span>
</div>
{{else}}
<div class="schedule-summary-row">
<span>Utolsó sikeres mentés:</span>
<span class="schedule-summary-value relative-time">Még nem futott</span>
</div>
{{end}}
<div class="schedule-summary-row">
<span>Megőrzés:</span>
<span class="schedule-summary-value">{{.Backup.Retention.KeepDaily}} napi · {{.Backup.Retention.KeepWeekly}} heti · {{.Backup.Retention.KeepMonthly}} havi</span>
</div>
</div>
<div class="schedule-actions">
<button class="btn btn-sm btn-primary" onclick="triggerBackupFromPage()" id="backup-page-btn"
{{if .Backup.Running}}disabled{{end}}>
{{if .Backup.Running}}Mentés folyamatban...{{else}}Mentés most{{end}}
</button>
</div>
</div>
<!-- Section 3: Databases -->
<div class="backup-section-card">
<h3>Adatbázisok</h3>
{{if or .Backup.DumpFiles .Backup.DiscoveredDBs}}
<div class="backup-table-wrap">
<table class="db-table">
<thead>
<tr>
<th>Alkalmazás</th>
<th>Típus</th>
<th>Méret</th>
<th>Utolsó</th>
<th>Érvényesítés</th>
<th>Állapot</th>
</tr>
</thead>
<tbody>
{{if .Backup.LastDBDump}}
{{range .Backup.LastDBDump.Results}}
<tr>
<td>{{.DB.StackName}}</td>
<td><span class="db-type-badge db-type-{{.DB.DBType}}">{{dbTypeLabel .DB.DBType}}</span></td>
<td class="mono">{{if .Error}}{{else}}{{fmtBytes .Size}}{{end}}</td>
<td class="mono">{{if .Error}}{{else}}{{fmtTimeShort $.Backup.LastDBDump.LastRun}}{{end}}</td>
<td>
{{if .Error}}
<span class="validation-badge validation-na"></span>
{{else if .Validation.Valid}}
<span class="validation-badge validation-ok">{{.Validation.TableCount}} tábla</span>
{{else}}
<span class="validation-badge validation-fail" title="{{.Validation.Error}}">Hiba</span>
{{end}}
</td>
<td>
{{if .Error}}
<span class="validation-badge validation-fail" title="{{.Error}}">Hiba</span>
{{else}}
<span class="validation-badge validation-ok">OK</span>
{{end}}
</td>
</tr>
{{end}}
{{else}}
{{range .Backup.DumpFiles}}
<tr>
<td>{{.StackName}}</td>
<td><span class="db-type-badge db-type-{{.DBType}}">{{dbTypeLabel .DBType}}</span></td>
<td class="mono">{{fmtBytes .Size}}</td>
<td class="mono">{{fmtTimeShort .ModTime}}</td>
<td>
{{if .Validation.Valid}}
<span class="validation-badge validation-ok">{{.Validation.TableCount}} tábla</span>
{{else if .Validation.Error}}
<span class="validation-badge validation-fail" title="{{.Validation.Error}}">Hiba</span>
{{else}}
<span class="validation-badge validation-na"></span>
{{end}}
</td>
<td><span class="validation-badge validation-ok">OK</span></td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="backup-table-empty">Nem található adatbázis mentés.</div>
{{end}}
</div>
<!-- Section 4: Snapshots -->
<div class="backup-section-card">
<h3>Pillanatképek</h3>
{{if .Backup.SnapshotHistory}}
<div class="backup-table-wrap">
<table class="snapshot-table">
<thead>
<tr>
<th>Azonosító</th>
<th>Időpont</th>
<th>Méret</th>
<th>Új fájl</th>
<th>Változott</th>
</tr>
</thead>
<tbody>
{{range .Backup.SnapshotHistory}}
<tr>
<td class="mono">{{shortID .SnapshotID}}</td>
<td class="mono">{{fmtTime .Time}}</td>
<td class="mono">{{if .HasStats}}+{{.DataAdded}}{{else}}{{end}}</td>
<td class="mono">{{if .HasStats}}{{.FilesNew}}{{else}}{{end}}</td>
<td class="mono">{{if .HasStats}}{{.FilesChanged}}{{else}}{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<div class="snapshot-footer">
Összesen: {{len .Backup.SnapshotHistory}} pillanatkép
{{if .Backup.RepoStats}} · {{.Backup.RepoStats.TotalSize}}{{end}}
</div>
{{else}}
<div class="backup-table-empty">Még nincs pillanatkép.</div>
{{end}}
</div>
<!-- Section 5: Repository -->
<div class="repo-card">
<h3>Tároló</h3>
<div class="repo-info-rows">
<div class="repo-info-row">
<span class="repo-label">Helyszín:</span>
<span class="repo-value mono">{{.Backup.RepoPath}} (helyi)</span>
</div>
{{if .Backup.RepoStats}}
<div class="repo-info-row">
<span class="repo-label">Méret:</span>
<span class="repo-value">{{.Backup.RepoStats.TotalSize}}</span>
</div>
<div class="repo-info-row">
<span class="repo-label">Pillanatképek:</span>
<span class="repo-value">{{.Backup.RepoStats.SnapshotCount}}</span>
</div>
{{end}}
<div class="repo-info-row">
<span class="repo-label">Integritás:</span>
<span class="repo-value">
{{if .Backup.LastCheckTime.IsZero}}
<span class="relative-time">Még nem ellenőrzött</span>
{{else if .Backup.LastCheckOK}}
<span class="backup-status-ok">Rendben</span> <span class="relative-time">({{fmtTime .Backup.LastCheckTime}})</span>
{{else}}
<span class="backup-status-fail">Hiba</span> <span class="relative-time">({{fmtTime .Backup.LastCheckTime}})</span>
{{end}}
</span>
</div>
</div>
<div class="repo-paths">
<span class="repo-label">Mentett útvonalak:</span>
<ul class="repo-path-list">
{{range .Backup.BackupPaths}}
<li class="mono">{{.}}</li>
{{end}}
</ul>
</div>
<div class="repo-remote">
<span class="repo-label">Távoli másolat:</span>
<div class="repo-remote-status">
<span class="relative-time">Nincs beállítva</span>
<span class="relative-time">(B2/S3/SFTP támogatás hamarosan)</span>
</div>
</div>
</div>
{{end}}
<script>
function triggerBackupFromPage() {
const btn = document.getElementById('backup-page-btn');
btn.disabled = true;
btn.textContent = 'Mentés indítása...';
fetch('/api/backup/run', { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ok) {
btn.textContent = 'Mentés folyamatban...';
btn.classList.add('loading');
startBackupPolling();
} else {
btn.textContent = data.error || 'Hiba';
setTimeout(() => { btn.textContent = 'Mentés most'; btn.disabled = false; }, 3000);
}
})
.catch(() => {
btn.textContent = 'Hiba';
setTimeout(() => { btn.textContent = 'Mentés most'; btn.disabled = false; }, 3000);
});
}
function startBackupPolling() {
const poll = setInterval(() => {
fetch('/api/backup/status')
.then(r => r.json())
.then(data => {
if (data.ok && data.data && !data.data.running) {
clearInterval(poll);
window.location.reload();
}
})
.catch(() => {});
}, 3000);
}
// Auto-poll if backup is already running on page load
{{if .Backup}}{{if .Backup.Running}}
startBackupPolling();
{{end}}{{end}}
</script>
{{template "layout_end" .}}
{{end}}
@@ -82,7 +82,7 @@
{{if .BackupEnabled}}
<div class="backup-status-card">
<h3>Biztonsági mentés</h3>
<h3><a href="/backups" class="backup-card-link">Biztonsági mentés</a></h3>
{{if .BackupStatus}}
<div class="backup-info-row">
<span class="backup-label">Utolsó mentés:</span>
@@ -16,6 +16,7 @@
<ul class="nav-links">
<li><a href="/" class="{{if eq .Page "dashboard"}}active{{end}}">Vezérlőpult</a></li>
<li><a href="/stacks" class="{{if eq .Page "stacks"}}active{{end}}">Alkalmazások</a></li>
<li><a href="/backups" class="{{if eq .Page "backups"}}active{{end}}">Biztonsági mentés</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">v{{.Version}}</span>
+251
View File
@@ -1226,6 +1226,257 @@ a.stat-card:hover {
.backup-status-fail { color: var(--red); }
.backup-status-none { color: var(--text-muted); }
/* Dashboard backup card link */
.backup-card-link {
color: var(--text-primary);
text-decoration: none;
transition: color 0.2s;
}
.backup-card-link:hover {
color: var(--accent-light);
}
/* --- Backup page --- */
.backup-page-cards {
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
}
.backup-empty-state {
text-align: center;
padding: 4rem 2rem;
background: var(--bg-card);
border-radius: var(--radius);
border: 1px solid var(--border-color);
}
.backup-empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.backup-empty-state h3 {
margin-bottom: .5rem;
}
.backup-empty-state p {
color: var(--text-secondary);
font-size: .9rem;
line-height: 1.6;
}
.schedule-card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 1.25rem;
border: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.schedule-card h3 {
margin-bottom: .75rem;
}
.schedule-rows {
margin-bottom: 1rem;
}
.schedule-row {
display: flex;
align-items: center;
gap: 1rem;
padding: .4rem 0;
font-size: .85rem;
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
}
.schedule-row:last-child { border-bottom: none; }
.schedule-task {
color: var(--text-primary);
font-weight: 500;
min-width: 160px;
}
.schedule-time {
color: var(--accent-light);
font-family: 'JetBrains Mono', monospace;
font-size: .8rem;
min-width: 80px;
}
.schedule-next {
color: var(--text-secondary);
font-size: .8rem;
}
.schedule-summary {
border-top: 1px solid var(--border-color);
padding-top: .75rem;
margin-bottom: .75rem;
}
.schedule-summary-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: .25rem 0;
font-size: .85rem;
color: var(--text-secondary);
}
.schedule-summary-value {
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: .8rem;
}
.schedule-actions {
padding-top: .5rem;
}
.backup-section-card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 1.25rem;
border: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.backup-section-card h3 {
margin-bottom: .75rem;
}
.backup-table-wrap {
overflow-x: auto;
}
.db-table, .snapshot-table {
width: 100%;
border-collapse: collapse;
font-size: .85rem;
}
.db-table th, .snapshot-table th {
text-align: left;
padding: .5rem .75rem;
color: var(--text-muted);
font-size: .75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .5px;
border-bottom: 1px solid var(--border-color);
}
.db-table td, .snapshot-table td {
padding: .5rem .75rem;
color: var(--text-primary);
border-bottom: 1px solid rgba(48, 54, 61, 0.3);
}
.db-table tr:nth-child(even), .snapshot-table tr:nth-child(even) {
background: var(--bg-secondary);
}
.db-table tr:nth-child(odd), .snapshot-table tr:nth-child(odd) {
background: var(--bg-card);
}
.db-table td.mono, .snapshot-table td.mono {
font-family: 'JetBrains Mono', monospace;
font-size: .8rem;
}
.db-type-badge {
display: inline-block;
padding: .1rem .5rem;
border-radius: 999px;
font-size: .75rem;
font-weight: 500;
}
.db-type-postgres {
background: rgba(0, 136, 204, 0.15);
color: var(--accent-light);
}
.db-type-mariadb {
background: rgba(210, 153, 34, 0.15);
color: var(--yellow);
}
.validation-badge {
display: inline-block;
padding: .1rem .5rem;
border-radius: 999px;
font-size: .75rem;
font-weight: 500;
}
.validation-ok {
background: var(--green-bg);
color: var(--green);
}
.validation-fail {
background: var(--red-bg);
color: var(--red);
cursor: help;
}
.validation-na {
color: var(--text-muted);
}
.snapshot-footer {
padding: .75rem .75rem 0;
font-size: .8rem;
color: var(--text-secondary);
}
.backup-table-empty {
padding: 1.5rem;
text-align: center;
color: var(--text-muted);
font-size: .85rem;
}
.repo-card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 1.25rem;
border: 1px solid var(--border-color);
margin-bottom: 1.5rem;
}
.repo-card h3 {
margin-bottom: .75rem;
}
.repo-info-rows {
margin-bottom: 1rem;
}
.repo-info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: .3rem 0;
font-size: .85rem;
}
.repo-label {
color: var(--text-secondary);
}
.repo-value {
color: var(--text-primary);
}
.repo-value.mono {
font-family: 'JetBrains Mono', monospace;
font-size: .8rem;
}
.repo-paths {
border-top: 1px solid var(--border-color);
padding-top: .75rem;
margin-bottom: .75rem;
}
.repo-path-list {
list-style: disc;
padding-left: 1.5rem;
margin-top: .5rem;
}
.repo-path-list li {
font-family: 'JetBrains Mono', monospace;
font-size: .8rem;
color: var(--text-secondary);
padding: .15rem 0;
}
.repo-remote {
border-top: 1px solid var(--border-color);
padding-top: .75rem;
}
.repo-remote-status {
margin-top: .25rem;
display: flex;
flex-direction: column;
gap: .15rem;
}
.relative-time {
color: var(--text-muted);
font-size: .8rem;
}
/* Responsive */
@media(max-width: 768px) {
.sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }