Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions scripts/bin/sp-update
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,11 @@ if [ -f "$INSTALL_DIR/scripts/lib/storage-helpers" ]; then
install_storage_cleanup_sudoers
fi

if [ -f "$INSTALL_DIR/scripts/lib/journald-helpers" ]; then
source "$INSTALL_DIR/scripts/lib/journald-helpers"
install_journald_config
fi

# Re-block WAN before starting service
if [ "$WAN_WAS_BLOCKED" = true ]; then
reload_iptables_helpers
Expand Down
18 changes: 6 additions & 12 deletions scripts/install
Original file line number Diff line number Diff line change
Expand Up @@ -279,9 +279,9 @@ reclaim_rootfs_caches() {
rm -rf /var/lib/dnf
fi

# Journal vacuum. The journald config below caps SystemMaxUse=50M, but
# only enforces it on rotation. A one-shot vacuum brings the on-disk
# size in line immediately; the cap keeps growth bounded after that.
# Journal vacuum. The journald config below keeps new logs in RAM, so
# the on-disk journal stops growing; a one-shot vacuum trims what the
# firmware's persistent journal already holds.
if command -v journalctl &>/dev/null; then
echo " Vacuuming systemd journal to 20M..."
journalctl --vacuum-size=20M >/dev/null 2>&1 || true
Expand Down Expand Up @@ -1494,15 +1494,9 @@ RuntimeDirectory=dac
WantedBy=multi-user.target
EOF

# Cap journal size to prevent unbounded log growth on limited disk
mkdir -p /etc/systemd/journald.conf.d
cat > /etc/systemd/journald.conf.d/sleepypod.conf << 'JOURNALD'
[Journal]
SystemMaxUse=50M
SystemKeepFree=200M
MaxFileSec=1week
JOURNALD
systemctl restart systemd-journald 2>/dev/null || true
# Keep the journal in RAM so firmware log chatter doesn't wear the eMMC
source "$INSTALL_DIR/scripts/lib/journald-helpers"
install_journald_config

# Install CLI tools from scripts/bin/ BEFORE the service starts. The systemd
# unit's ExecStartPre references /usr/local/bin/sp-maintenance — if these
Expand Down
45 changes: 45 additions & 0 deletions scripts/lib/journald-helpers
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# shellcheck shell=bash
# Keep the systemd journal in RAM (/run/log/journal). Eight's firmware sets
# Storage=persistent and links /var/log/journal to /persistent/journal, so
# frank's steady log stream otherwise lands on the eMMC around the clock. A
# drop-in overrides journald.conf; logs from earlier boots are the cost.
# Shared by install and sp-update so existing pods pick it up on update.
install_journald_config() {
local file="/etc/systemd/journald.conf.d/sleepypod.conf" tmp backup=""
mkdir -p /etc/systemd/journald.conf.d
tmp=$(mktemp)
cat > "$tmp" <<'JOURNALD'
[Journal]
Storage=volatile
RuntimeMaxUse=48M
RuntimeKeepFree=100M
MaxFileSec=1week
JOURNALD
if cmp -s "$tmp" "$file"; then
rm -f "$tmp"
return 0
fi
if [ -f "$file" ]; then
backup=$(mktemp)
cp -fp "$file" "$backup"
fi
install -m 0644 "$tmp" "$file"
rm -f "$tmp"
# journald keeps service stdout streams in its fd store across a restart.
# If it won't restart, put the previous config back and bring journald up
# on it, so the next install or update retries. Non-fatal either way.
if ! systemctl restart systemd-journald 2>/dev/null; then
if [ -n "$backup" ]; then
install -m 0644 "$backup" "$file"
else
rm -f "$file"
fi
systemctl restart systemd-journald 2>/dev/null || true
if systemctl is-active --quiet systemd-journald; then
echo "Warning: systemd-journald rejected the RAM-only journal config — kept the previous one, will retry on next update" >&2
else
echo "Warning: systemd-journald is not running after restoring its previous config — check 'systemctl status systemd-journald'" >&2
fi
fi
rm -f "$backup"
}
63 changes: 61 additions & 2 deletions src/components/System/StorageTab.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
'use client'

import { useState } from 'react'
import { HardDrive, Trash2 } from 'lucide-react'
import { Gauge, HardDrive, Trash2 } from 'lucide-react'
import type { inferRouterOutputs } from '@trpc/server'
import type { AppRouter } from '@/src/server/routers/app'
import { trpc } from '@/src/utils/trpc'
import { Button, Card, CardHeader, InlineError, Skeleton, Toggle } from '@/src/components/ds'
import { Button, Card, CardHeader, InlineError, KeyValue, Skeleton, StatusDot, Toggle } from '@/src/components/ds'
import { ConfirmDialog } from '@/src/components/Schedule/ConfirmDialog'
import { formatBytes } from '@/src/components/status/SystemInfoCard'

type Storage = inferRouterOutputs<AppRouter>['system']['getStorage']
type Flash = NonNullable<inferRouterOutputs<AppRouter>['system']['getFlashHealth']>
type Wear = Flash['lifeTime'][number]
type SegmentKey = Storage['segments'][number]['key']

const SEGMENTS: Record<SegmentKey, { label: string, color: string }> = {
Expand All @@ -26,6 +28,60 @@ function shortPath(p: string): string {
return p.replace(/^\/persistent\//, '')
}

const PRE_EOL = {
normal: { label: 'Normal', tone: 'ok' },
warning: { label: 'Warning', tone: 'warn' },
urgent: { label: 'Urgent', tone: 'danger' },
} as const

/** JEDEC reports wear in 10% steps; "exceeded" has no upper bound. */
function wearLabel(w: NonNullable<Wear>): string {
return w.maxPercent == null ? 'Past rated life' : `${w.minPercent}–${w.maxPercent}%`
}

/** Card tone from the chip's own estimates: 80%+ worn or reserve blocks low is a warning. */
function flashTone(f: Flash): 'warn' | 'danger' | undefined {
const worst = Math.max(...f.lifeTime.map(w => w?.minPercent ?? 0))
if (f.preEol === 'urgent' || worst >= 100) return 'danger'
if (f.preEol === 'warning' || worst >= 80) return 'warn'
return undefined
}

/**
* eMMC endurance: how worn the chip says it is, and how fast the pod is
* writing to it. Hidden off-pod, where there's no eMMC to read.
*/
function FlashCard({ flash }: { flash: Flash }) {
const [slc, main] = flash.lifeTime
const eol = flash.preEol && PRE_EOL[flash.preEol]
return (
<Card tone={flashTone(flash)} data-testid="flash-card">
<CardHeader
icon={Gauge}
title="Flash endurance"
subtitle={[flash.name, flash.capacityBytes && `${formatBytes(flash.capacityBytes)} eMMC`].filter(Boolean).join(' · ') || flash.device}
right={eol && <StatusDot tone={eol.tone} label={eol.label} />}
/>
<div className="grid grid-cols-2 gap-x-6 gap-y-3 @min-[640px]:grid-cols-3">
<KeyValue
label="Writes per day"
value={flash.perDay ? formatBytes(flash.perDay.bytes) : 'Measuring…'}
/>
{main && <KeyValue label="Wear (main area)" value={wearLabel(main)} />}
{slc && <KeyValue label="Wear (SLC area)" value={wearLabel(slc)} />}
<KeyValue label="Reserve blocks" value={eol?.label ?? 'Not reported'} />
<KeyValue label="Written since boot" value={formatBytes(flash.sinceBoot.bytes)} />
</div>
<p className="text-xs text-fg-2 text-pretty">
{flash.perDay
? `Averaged over the last ${flash.perDay.coveredHours < 48 ? `${flash.perDay.coveredHours} h` : `${Math.round(flash.perDay.coveredHours / 24)} days`}. `
: 'Writes per day appear after an hour of samples. '}
{'Covers everything on the chip — Eight\'s firmware, sleepypod and the OS. Wear is the chip\'s own estimate, in 10% steps.'}
</p>
</Card>
)
}

/**
* System → Storage: what fills /persistent, how much raw history that leaves
* once the pruner trims to its target, and a one-button cleanup of
Expand All @@ -34,6 +90,7 @@ function shortPath(p: string): string {
export function StorageTab() {
const utils = trpc.useUtils()
const storage = trpc.system.getStorage.useQuery({}, { refetchInterval: 60_000 })
const flash = trpc.system.getFlashHealth.useQuery({}, { refetchInterval: 60_000 })
const [includeBackups, setIncludeBackups] = useState(false)
const [confirming, setConfirming] = useState(false)
const free = trpc.system.freeStorage.useMutation({
Expand Down Expand Up @@ -127,6 +184,8 @@ export function StorageTab() {
</p>
</Card>

{flash.data && <FlashCard flash={flash.data} />}

<Card>
<CardHeader
icon={Trash2}
Expand Down
46 changes: 46 additions & 0 deletions src/components/System/tests/StorageTab.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ const mocks = vi.hoisted(() => ({
mutate: vi.fn(),
result: undefined as unknown,
pending: false,
flash: null as unknown,
}))

vi.mock('@/src/utils/trpc', () => ({
trpc: {
useUtils: () => ({ system: { getStorage: { invalidate: vi.fn() } } }),
system: {
getStorage: { useQuery: () => ({ data: mocks.pending ? undefined : mocks.data, isPending: mocks.pending, isLoading: false, error: null }) },
getFlashHealth: { useQuery: () => ({ data: mocks.flash }) },
freeStorage: { useMutation: () => ({ mutate: mocks.mutate, isPending: false, error: null, data: mocks.result }) },
},
},
Expand All @@ -26,6 +28,7 @@ beforeEach(() => {
mocks.mutate.mockReset()
mocks.result = undefined
mocks.pending = false
mocks.flash = null
mocks.data = {
persistent: { totalBytes: 15 * GB, usedBytes: 12 * GB, availableBytes: 3 * GB, usedPercent: 80 },
prunerTargetPercent: 80,
Expand Down Expand Up @@ -96,4 +99,47 @@ describe('StorageTab', () => {
expect(screen.getByText(/Cleanup tool not installed/)).toBeTruthy()
expect(screen.queryByRole('button', { name: /Free up/ })).toBeNull()
})

describe('flash endurance', () => {
const flash = (over: Record<string, unknown> = {}) => ({
device: 'mmcblk0',
name: 'C9A551',
capacityBytes: 29 * GB,
lifeTime: [{ minPercent: 30, maxPercent: 40 }, null],
preEol: 'normal',
sinceBoot: { bytes: 1.5 * GB, uptimeSeconds: 2600 },
perDay: { bytes: 3.1 * GB, coveredHours: 168 },
...over,
})

it('is hidden off-pod', () => {
render(<StorageTab />)
expect(screen.queryByTestId('flash-card')).toBeNull()
})

it('shows wear, reserve blocks and writes per day', () => {
mocks.flash = flash()
render(<StorageTab />)
expect(screen.getByText('C9A551 · 29.0 GB eMMC')).toBeTruthy()
expect(screen.getByText('30–40%')).toBeTruthy()
expect(screen.queryByText('Wear (main area)')).toBeNull()
expect(screen.getByText('3.1 GB')).toBeTruthy()
expect(screen.getByText(/Averaged over the last 7 days/)).toBeTruthy()
expect(screen.getAllByText('Normal').length).toBeGreaterThan(0)
})

it('says it is still measuring before an hour of samples', () => {
mocks.flash = flash({ perDay: null })
render(<StorageTab />)
expect(screen.getByText('Measuring…')).toBeTruthy()
expect(screen.getByText(/appear after an hour of samples/)).toBeTruthy()
})

it('flags a chip past rated life', () => {
mocks.flash = flash({ lifeTime: [null, { minPercent: 100, maxPercent: null }], preEol: 'urgent' })
render(<StorageTab />)
expect(screen.getByText('Past rated life')).toBeTruthy()
expect(screen.getByTestId('flash-card').className).toContain('border-danger-line')
})
})
})
8 changes: 8 additions & 0 deletions src/db/biometrics-migrations/0031_wide_black_knight.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
CREATE TABLE `flash_write_samples` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`timestamp` integer NOT NULL,
`boot_id` text NOT NULL,
`sectors_written` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `idx_flash_write_samples_timestamp` ON `flash_write_samples` (`timestamp`);
Loading
Loading