Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
daf33a44b2 | ||
|
|
c3854bafda |
@@ -296,8 +296,24 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
**Nothing is destroyed unseen.** Every schedule answers *what would it delete?* with the
|
**Nothing is destroyed unseen.** Every schedule answers *what would it delete?* with the
|
||||||
list, sizes and dates, before Run now touches anything; only names that parse as a stamp
|
list, sizes and dates, before Run now touches anything; only names that parse as a stamp
|
||||||
and a TTL are candidates, so a snapshot taken by hand is invisible to it; and a snapshot
|
and a TTL are candidates, so a snapshot taken by hand is invisible to it; and a snapshot
|
||||||
deleted by hand needs its full name typed back. The scheduler is a 30 s tick in the daemon,
|
deleted by hand needs its full name typed back.
|
||||||
and `Run now` calls the same code, so the button and the timer cannot drift apart.
|
**A schedule is a real cron job** (2026-09-24, Mitchell's call after the first cut ran it
|
||||||
|
from a ticker inside the daemon). Ham Lab writes `/etc/cron.d/hamlab-zfs`, owns it, and
|
||||||
|
rewrites it whole on every change; each line calls `hamlab zfs-snapshot <id>` as the
|
||||||
|
account Ham Lab runs as, reaching zfs through sudo as the daemon does. Snapshots are
|
||||||
|
backup-shaped and must not stop because a console is down, upgrading, or uninstalled — and
|
||||||
|
a cron line is greppable by anyone looking at the machine the normal way. **A cron
|
||||||
|
expression is the single description of when a schedule fires**: the four common shapes
|
||||||
|
render to one, `custom` states one outright, and the next run is read back with the same
|
||||||
|
evaluator the Jobs tab uses on everyone else's lines, so the page, the file and the
|
||||||
|
prediction cannot disagree. A run's outcome is written to `zfs-runs/<id>.json` beside the
|
||||||
|
config, never to `state.json`, because the cron job is a separate process and the daemon
|
||||||
|
would clobber it. An existing external job can be **adopted** — its schedule, retention
|
||||||
|
and dataset read out of the cron line into a new Ham Lab schedule.
|
||||||
|
**zfSnap is retired on `grid`** as of that day: Ham Lab now takes the nightly `tank`
|
||||||
|
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
|
||||||
|
had taken left untouched and fully readable here — which is what adopting its naming
|
||||||
|
bought. See `hamlet/docs/hosts.md`.
|
||||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||||
|
|||||||
@@ -254,7 +254,6 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview))
|
m.HandleFunc("GET /api/zfs", s.auth(s.zfsOverview))
|
||||||
m.HandleFunc("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut))
|
m.HandleFunc("PUT /api/zfs/schedules", s.auth(s.zfsPolicyPut))
|
||||||
m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete))
|
m.HandleFunc("DELETE /api/zfs/schedules/{id}", s.auth(s.zfsPolicyDelete))
|
||||||
m.HandleFunc("GET /api/zfs/schedules/{id}/expired", s.auth(s.zfsPreview))
|
|
||||||
m.HandleFunc("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow))
|
m.HandleFunc("POST /api/zfs/schedules/{id}/run", s.auth(s.zfsRunNow))
|
||||||
m.HandleFunc("POST /api/zfs/snapshots/delete", s.auth(s.zfsSnapshotDelete))
|
m.HandleFunc("POST /api/zfs/snapshots/delete", s.auth(s.zfsSnapshotDelete))
|
||||||
m.HandleFunc("GET /api/space", s.auth(s.spaceOverview))
|
m.HandleFunc("GET /api/space", s.auth(s.spaceOverview))
|
||||||
|
|||||||
@@ -217,24 +217,6 @@ func (s *Server) zfsPolicy(id string) (storage.Policy, bool) {
|
|||||||
return storage.Policy{}, false
|
return storage.Policy{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// zfsPreview is what this schedule would destroy if it ran now. Nothing is deleted.
|
|
||||||
func (s *Server) zfsPreview(w http.ResponseWriter, r *http.Request) {
|
|
||||||
p, ok := s.zfsPolicy(r.PathValue("id"))
|
|
||||||
if !ok {
|
|
||||||
jsonErr(w, 404, "no such schedule")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
doomed, err := s.storage.Doomed(r.Context(), &p)
|
|
||||||
if err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if doomed == nil {
|
|
||||||
doomed = []storage.Snapshot{}
|
|
||||||
}
|
|
||||||
jsonOK(w, 200, map[string]any{"expired": doomed})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) zfsRunNow(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.PathValue("id")
|
id := r.PathValue("id")
|
||||||
p, ok := s.zfsPolicy(id)
|
p, ok := s.zfsPolicy(id)
|
||||||
|
|||||||
+54
-32
@@ -68,18 +68,43 @@ function PoolCard({ p, onChanged }: { p: ZFSInfo['pools'][number]; onChanged: ()
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A snapshot's name carries when it was taken and how long it lives — 2026-09-24_02.30.01--6w
|
||||||
|
// — so the explorer can say when each one goes without asking the server anything.
|
||||||
|
function snapMeta(full: string): { taken?: Date; expires?: Date } {
|
||||||
|
const snap = full.split('@')[1] ?? ''
|
||||||
|
const [body, ttl] = snap.split('--')
|
||||||
|
const m = /(\d{4})-(\d{2})-(\d{2})_(\d{2})\.(\d{2})\.(\d{2})$/.exec(body)
|
||||||
|
if (!m) return {}
|
||||||
|
const taken = new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6])
|
||||||
|
const a = /^(\d+)([mhdw])$/.exec(ttl ?? '')
|
||||||
|
if (!a) return { taken }
|
||||||
|
const ms = { m: 60e3, h: 3600e3, d: 86400e3, w: 7 * 86400e3 }[a[2]]!
|
||||||
|
return { taken, expires: new Date(taken.getTime() + +a[1] * ms) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// One dataset in the snapshot explorer: how many it has and what they cost, opening into
|
||||||
|
// the snapshots themselves, each of which can be taken now or deleted.
|
||||||
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
|
function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChanged: () => void }) {
|
||||||
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null)
|
const [snaps, setSnaps] = useState<ZFSSnapshot[] | null>(null)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) }
|
||||||
|
}, [d.name])
|
||||||
async function toggle() {
|
async function toggle() {
|
||||||
setOpen(!open)
|
setOpen(!open)
|
||||||
if (!open && !snaps) { try { setSnaps(await api.zfsSnapshots(d.name)) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err'); setSnaps([]) } }
|
if (!open && !snaps) await reload()
|
||||||
}
|
}
|
||||||
async function snapshot() {
|
async function snapshot() {
|
||||||
const def = 'hamlab-' + new Date().toISOString().slice(0, 16).replace(/[-:T]/g, '').replace(/(\d{8})(\d{4})/, '$1-$2')
|
const def = new Date().toISOString().slice(0, 19).replace('T', '_').replace(/:/g, '.')
|
||||||
const name = await askText(`Snapshot name for ${d.name}@`, def)
|
const name = await askText(`Snapshot name for ${d.name}@`, def)
|
||||||
if (!name) return
|
if (!name) return
|
||||||
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); setSnaps(null); onChanged() }
|
try { await api.zfsSnapshot(d.name, name.trim()); toast(`${d.name}@${name.trim()} created`); await reload(); onChanged() }
|
||||||
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
async function destroy(s: ZFSSnapshot) {
|
||||||
|
const short = s.name.split('@')[1]
|
||||||
|
if (!await askYes(`Delete ${s.name}? It is removed from this dataset and from every dataset beneath it that has a snapshot of the same name. This cannot be undone.`, 'Delete')) return
|
||||||
|
try { await api.deleteZfsSnapshot(s.name, true); toast(`${short} deleted`); await reload(); onChanged() }
|
||||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
}
|
}
|
||||||
const depth = d.name.split('/').length - 1
|
const depth = d.name.split('/').length - 1
|
||||||
@@ -87,18 +112,29 @@ function DatasetRow({ d, onChanged }: { d: ZFSInfo['datasets'][number]; onChange
|
|||||||
<>
|
<>
|
||||||
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
|
<div className="ds-row" style={{ paddingLeft: 12 + depth * 16 }}>
|
||||||
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
|
<span className="ds-name" onClick={toggle}><span className="chev">{open ? '▾' : '▸'}</span>{d.name.split('/').pop()}{d.type === 'volume' && <span className="badge">zvol</span>}</span>
|
||||||
<span className="num">{fmtBytes(d.used)}</span>
|
<span className="num">{d.snapshots}</span>
|
||||||
<span className="num muted">{fmtBytes(d.refer)}</span>
|
|
||||||
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
|
<span className="num muted">{d.used_by_snapshots ? fmtBytes(d.used_by_snapshots) : '—'}</span>
|
||||||
<span className="num muted">{d.snapshots}</span>
|
<span className="num muted">{fmtBytes(d.used)}</span>
|
||||||
<span className="num muted">{d.compressratio}</span>
|
<span className="num muted">{fmtBytes(d.refer)}</span>
|
||||||
<span className="ds-mount muted">{d.mount}</span>
|
<span className="ds-mount muted">{d.mount}</span>
|
||||||
</div>
|
</div>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="ds-snaps">
|
<div className="ds-snaps">
|
||||||
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted">avail {fmtBytes(d.avail)}</span></div>
|
<div className="svc-actions"><Btn className="btn" onClick={snapshot}>Snapshot now</Btn><span className="muted small">{d.snapshots} snapshot{d.snapshots === 1 ? '' : 's'} · {fmtBytes(d.avail)} free on this dataset</span></div>
|
||||||
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
|
{snaps === null ? <div className="muted"><Spinner /> listing…</div> : snaps.length === 0 ? <div className="muted">No snapshots.</div> : (
|
||||||
<div className="snap-list">{snaps.slice().reverse().map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleString()} · {fmtBytes(s.used)}</span></div>)}</div>
|
<div className="snap-list">{snaps.slice().reverse().map((s) => {
|
||||||
|
const { taken, expires } = snapMeta(s.name)
|
||||||
|
const gone = expires && expires.getTime() < Date.now()
|
||||||
|
return (
|
||||||
|
<div key={s.name} className="snap">
|
||||||
|
<span className="mono snap-name" title={s.name}>{s.name.split('@')[1]}</span>
|
||||||
|
<span className="muted snap-when">{taken ? taken.toLocaleString() : new Date(s.created).toLocaleString()}</span>
|
||||||
|
<span className={'muted snap-exp' + (gone ? ' warn' : '')}>{expires ? (gone ? 'expired' : `expires ${expires.toLocaleDateString()}`) : 'kept'}</span>
|
||||||
|
<span className="num muted">{fmtBytes(s.used)}</span>
|
||||||
|
<Btn className="btn danger snap-del" onClick={() => destroy(s)}>Delete</Btn>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -126,18 +162,11 @@ function ExternalNotice({ x, mine, onAdopt }: { x: ExternalJob; mine: number; on
|
|||||||
type ExternalJob = ZFSExternal
|
type ExternalJob = ZFSExternal
|
||||||
|
|
||||||
function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy; cron: string; next?: string; last: ZFSRun; onChanged: () => void; onEdit: () => void }) {
|
function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy; cron: string; next?: string; last: ZFSRun; onChanged: () => void; onEdit: () => void }) {
|
||||||
const [expired, setExpired] = useState<ZFSSnapshot[] | null>(null)
|
|
||||||
const [busy, setBusy] = useState('')
|
const [busy, setBusy] = useState('')
|
||||||
const preview = async () => {
|
|
||||||
setBusy('preview')
|
|
||||||
try { setExpired((await api.zfsExpired(p.id)).expired) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
|
|
||||||
}
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
const n = expired?.length
|
if (!await askYes(`Take a snapshot of ${p.dataset} now? Snapshots past their retention are removed at the same time, as they would be on the schedule.`, 'Run now')) return
|
||||||
const warn = n ? ` It will also destroy ${n} expired snapshot${n === 1 ? '' : 's'}.` : ''
|
|
||||||
if (!await askYes(`Take a snapshot of ${p.dataset} now?${warn}`, 'Run now')) return
|
|
||||||
setBusy('run')
|
setBusy('run')
|
||||||
try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); setExpired(null); onChanged() }
|
try { const r = await api.runZfsSchedule(p.id); toast(`${r.taken}${r.pruned.length ? ` · ${r.pruned.length} expired removed` : ''}`); onChanged() }
|
||||||
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
|
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } finally { setBusy('') }
|
||||||
}
|
}
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
@@ -165,20 +194,7 @@ function ScheduleCard({ p, cron, next, last, onChanged, onEdit }: { p: ZFSPolicy
|
|||||||
{p.keep_min > 0 && <div><span className="k">Floor</span><span>always keep the newest {p.keep_min}</span></div>}
|
{p.keep_min > 0 && <div><span className="k">Floor</span><span>always keep the newest {p.keep_min}</span></div>}
|
||||||
</div>
|
</div>
|
||||||
{last.error && <div className="error" style={{ marginTop: 8 }}>{last.error}</div>}
|
{last.error && <div className="error" style={{ marginTop: 8 }}>{last.error}</div>}
|
||||||
{expired && (
|
|
||||||
<div className="notice" style={{ marginTop: 8 }}>
|
|
||||||
{expired.length === 0 ? 'Nothing has expired — a run now would only take a snapshot.' : (
|
|
||||||
<>
|
|
||||||
<b>{expired.length}</b> snapshot{expired.length === 1 ? '' : 's'} would be destroyed by a run now:
|
|
||||||
<div className="snap-list" style={{ marginTop: 6, maxHeight: 180, overflow: 'auto' }}>
|
|
||||||
{expired.map((s) => <div key={s.name} className="snap"><span className="mono">{s.name.split('@')[1]}</span><span className="num muted">{new Date(s.created).toLocaleDateString()} · {fmtBytes(s.used)}</span></div>)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="svc-actions">
|
<div className="svc-actions">
|
||||||
<Btn className="btn" busy={busy === 'preview'} onClick={preview}>What would it delete?</Btn>
|
|
||||||
<Btn className="btn on" busy={busy === 'run'} onClick={run}>Run now</Btn>
|
<Btn className="btn on" busy={busy === 'run'} onClick={run}>Run now</Btn>
|
||||||
<button className="btn" onClick={onEdit}>Edit</button>
|
<button className="btn" onClick={onEdit}>Edit</button>
|
||||||
<Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn>
|
<Btn className="btn" onClick={toggle}>{p.enabled ? 'Pause' : 'Resume'}</Btn>
|
||||||
@@ -326,8 +342,14 @@ export default function ZFS() {
|
|||||||
<ScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last ?? {}} onChanged={load} onEdit={() => setEditing(policy)} />
|
<ScheduleCard key={policy.id} p={policy} cron={cron} next={next} last={last ?? {}} onChanged={load} onEdit={() => setEditing(policy)} />
|
||||||
))}</div>
|
))}</div>
|
||||||
|
|
||||||
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Datasets</h2><span className="spacer" /><span className="muted small">used · referenced · in snapshots · count · compression · mount</span></div>
|
<div className="dash-bar" style={{ marginTop: 12 }}><h2 className="section">Snapshots</h2><span className="spacer" />
|
||||||
|
<span className="muted small">{zfs.datasets.reduce((a, d) => a + d.snapshots, 0)} across {zfs.datasets.length} datasets · open one to browse, take or delete</span>
|
||||||
|
</div>
|
||||||
<section className="card ds-table">
|
<section className="card ds-table">
|
||||||
|
<div className="ds-row head">
|
||||||
|
<span>Dataset</span><span className="num">Snapshots</span><span className="num">They use</span>
|
||||||
|
<span className="num">Dataset uses</span><span className="num">Referenced</span><span>Mount</span>
|
||||||
|
</div>
|
||||||
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
|
{zfs.datasets.map((d) => <DatasetRow key={d.name} d={d} onChanged={load} />)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,6 @@ export const api = {
|
|||||||
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||||||
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
||||||
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
|
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
|
||||||
zfsExpired: (id: string) => req<{ expired: ZFSSnapshot[] }>('GET', `/api/zfs/schedules/${encodeURIComponent(id)}/expired`),
|
|
||||||
runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
|
runZfsSchedule: (id: string) => req<{ taken: string; pruned: string[] }>('POST', `/api/zfs/schedules/${encodeURIComponent(id)}/run`),
|
||||||
deleteZfsSnapshot: (name: string, recursive: boolean) => req<void>('POST', '/api/zfs/snapshots/delete', { name, confirm: name, recursive }),
|
deleteZfsSnapshot: (name: string, recursive: boolean) => req<void>('POST', '/api/zfs/snapshots/delete', { name, confirm: name, recursive }),
|
||||||
zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`),
|
zfsSnapshots: (dataset: string) => req<ZFSSnapshot[]>('GET', `/api/storage/zfs/snapshots?dataset=${encodeURIComponent(dataset)}`),
|
||||||
|
|||||||
+9
-2
@@ -285,14 +285,21 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
the fallback above stays a fallback. */
|
the fallback above stays a fallback. */
|
||||||
.disk-cards { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); }
|
.disk-cards { grid-template-columns: repeat(auto-fill, minmax(min(420px, 100%), 1fr)); }
|
||||||
.datasets { padding: 0; overflow-x: auto; }
|
.datasets { padding: 0; overflow-x: auto; }
|
||||||
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 80px 80px 50px 50px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; } /* the columns and gaps add up to 692: at 640 the row ended before its own cells did */
|
.ds-row { display: grid; grid-template-columns: minmax(160px, 1fr) 80px 90px 90px 90px minmax(120px, 1fr); gap: 8px; align-items: center; padding: 6px 12px; border-bottom: 1px solid var(--border); font-size: 13px; min-width: 700px; }
|
||||||
.ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
.ds-row.head { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
.ds-row .num { text-align: right; }
|
.ds-row .num { text-align: right; }
|
||||||
.ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.ds-name { cursor: pointer; display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
|
.ds-mount { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: ui-monospace, monospace; font-size: 12px; }
|
||||||
.ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; }
|
.ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; }
|
||||||
.snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; }
|
.snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; }
|
||||||
.snap { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }
|
.snap { display: grid; grid-template-columns: minmax(150px, 1fr) 150px 120px 70px auto; gap: 10px; align-items: center; font-size: 12px; padding: 2px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.snap .snap-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.snap .snap-when, .snap .snap-exp { white-space: nowrap; }
|
||||||
|
.snap .snap-exp.warn { color: var(--s2); }
|
||||||
|
.snap .num { text-align: right; }
|
||||||
|
.snap .snap-del { padding: 0 6px; font-size: 11px; opacity: .55; }
|
||||||
|
.snap:hover .snap-del { opacity: 1; }
|
||||||
|
@media (max-width: 800px) { .snap { grid-template-columns: minmax(120px, 1fr) auto auto; } .snap .snap-when, .snap .snap-exp { display: none; } }
|
||||||
|
|
||||||
/* files */
|
/* files */
|
||||||
.files { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); }
|
.files { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); }
|
||||||
|
|||||||
Reference in New Issue
Block a user