Creating Controller service which will control WROLPi services, admin, SMART, disks, and status. nginx will fallback to Controller UI when React UI is unavailable (such as during upgrades).

This commit is contained in:
Roland 2025-12-11 18:32:55 -07:00 • committed by lrnselfreliance
parent 344fe9035f
commit b9158b60fe
66 changed files with 9920 additions and 327 deletions

32
.github/workflows/controller-tests.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: Controller Tests
on:
push:
branches: [master, feature/*]
pull_request:
branches: [master]
jobs:
controller-tests:
runs-on: ubuntu-latest
defaults:
run:
working-directory: controller
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: 'pip'
cache-dependency-path: controller/requirements.txt
- name: Install dependencies
run: pip install -r requirements.txt
- name: Run pytest
run: python -m pytest test/ -v --tb=short --confcutdir=$(pwd)

3
.gitignore vendored
View file

@ -137,3 +137,6 @@ pg_data
.DS_Store
app/cypress/screenshots
app/cypress/videos
.claude
*.md

View file

@ -119,9 +119,9 @@ function handleEvents(events) {
if (event === 'upgrade_started') {
eventToast('Upgrade Started', message, 'info', 10000);
// Redirect to maintenance page after a short delay
// Redirect to Controller UI for upgrade status after a short delay
setTimeout(() => {
window.location.href = '/maintenance.html';
window.location.href = '/controller/?upgrade=true';
}, 1000);
}

186
app/src/api/controller.js Normal file
View file

@ -0,0 +1,186 @@
/**
* Controller API client for WROLPi.
*
* These functions call the Controller service endpoints.
* Controller handles system-level operations that need to work
* even when the main API is down.
*/
// Controller endpoints are served under /controller/api
const CONTROLLER_BASE = '/controller/api';
/**
* Fetch with error handling for Controller endpoints.
*/
async function controllerFetch(endpoint, options = {}) {
const url = `${CONTROLLER_BASE}${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({detail: response.statusText}));
throw new Error(error.detail || 'Controller request failed');
}
return response.json();
}
// --- Status Endpoints ---
/**
* Get all system stats from the Controller.
* Returns CPU, memory, load, drives, network, power, iostat, processes,
* disk_bandwidth along with system info (dockerized, is_rpi, hotspot, throttle).
*/
export async function getControllerStats() {
return controllerFetch('/stats');
}
export async function getCpuStatus() {
return controllerFetch('/status/cpu');
}
export async function getMemoryStatus() {
return controllerFetch('/status/memory');
}
export async function getLoadStatus() {
return controllerFetch('/status/load');
}
export async function getDrivesStatus() {
return controllerFetch('/status/drives');
}
export async function getPrimaryDriveStatus() {
return controllerFetch('/status/primary-drive');
}
export async function getNetworkStatus() {
return controllerFetch('/status/network');
}
export async function getPowerStatus() {
return controllerFetch('/status/power');
}
// --- Admin Endpoints ---
export async function getHotspotStatus() {
return controllerFetch('/hotspot/status');
}
export async function enableHotspot() {
return controllerFetch('/hotspot/enable', {method: 'POST'});
}
export async function disableHotspot() {
return controllerFetch('/hotspot/disable', {method: 'POST'});
}
export async function getThrottleStatus() {
return controllerFetch('/throttle/status');
}
export async function enableThrottle() {
return controllerFetch('/throttle/enable', {method: 'POST'});
}
export async function disableThrottle() {
return controllerFetch('/throttle/disable', {method: 'POST'});
}
export async function shutdownSystem() {
return controllerFetch('/shutdown', {method: 'POST'});
}
export async function rebootSystem() {
return controllerFetch('/reboot', {method: 'POST'});
}
export async function restartServices() {
return controllerFetch('/restart', {method: 'POST'});
}
// --- Service Endpoints ---
export async function getServices() {
return controllerFetch('/services');
}
export async function getServiceStatus(serviceName) {
return controllerFetch(`/services/${serviceName}`);
}
export async function startService(serviceName) {
return controllerFetch(`/services/${serviceName}/start`, {method: 'POST'});
}
export async function stopService(serviceName) {
return controllerFetch(`/services/${serviceName}/stop`, {method: 'POST'});
}
export async function restartService(serviceName) {
return controllerFetch(`/services/${serviceName}/restart`, {method: 'POST'});
}
export async function enableService(serviceName) {
return controllerFetch(`/services/${serviceName}/enable`, {method: 'POST'});
}
export async function disableService(serviceName) {
return controllerFetch(`/services/${serviceName}/disable`, {method: 'POST'});
}
export async function getServiceLogs(serviceName, lines = 100) {
return controllerFetch(`/services/${serviceName}/logs?lines=${lines}`);
}
// --- Disk Endpoints ---
export async function getDisks() {
return controllerFetch('/disks');
}
export async function getMounts() {
return controllerFetch('/disks/mounts');
}
export async function mountDisk(device, mountPoint) {
return controllerFetch('/disks/mount', {
method: 'POST',
body: JSON.stringify({device, mount_point: mountPoint}),
});
}
export async function unmountDisk(mountPoint, lazy = false) {
return controllerFetch('/disks/unmount', {
method: 'POST',
body: JSON.stringify({mount_point: mountPoint, lazy}),
});
}
export async function detectWrolpiDrives() {
return controllerFetch('/disks/detect-wrolpi');
}
export async function getSmartStatus() {
return controllerFetch('/disks/smart');
}
// --- Health Check ---
export async function healthCheck() {
return controllerFetch('/health');
}
// --- Controller Info ---
export async function getControllerInfo() {
return controllerFetch('/info');
}

View file

@ -27,6 +27,12 @@ import {
StatisticGroup as SStatisticGroup,
Tab as STab,
Table as STable,
TableBody as STableBody,
TableCell as STableCell,
TableFooter as STableFooter,
TableHeader as STableHeader,
TableHeaderCell as STableHeaderCell,
TableRow as STableRow,
TabPane as STabPane,
TextArea as STextArea,
AccordionProps,
@ -55,6 +61,12 @@ import {
StatisticGroupProps,
TabProps,
TableProps,
TableBodyProps,
TableCellProps,
TableFooterProps,
TableHeaderProps,
TableHeaderCellProps,
TableRowProps,
TabPaneProps,
TextAreaProps,
} from "semantic-ui-react";
@ -262,10 +274,7 @@ export function Segment(props: SegmentProps) {
return <SSegment {...i} {...props}/>
}
export function Table(props: TableProps) {
const {i} = useContext(ThemeContext);
return <STable {...i} {...props}/>
}
// Table is now a compound component - see COMPOUND COMPONENTS section below
export function TextArea(props: TextAreaProps) {
const {i} = useContext(ThemeContext);
@ -498,3 +507,61 @@ const BreadcrumbBase: React.FC<BreadcrumbProps> = (props) => {
export const Breadcrumb: BreadcrumbComponent = Object.assign(BreadcrumbBase, {
Divider: BreadcrumbDivider,
});
// ----------------------------------------------------------------------------
// Table Compound Component
// ----------------------------------------------------------------------------
type TableBodyComponent = React.FC<TableBodyProps>;
type TableCellComponent = React.FC<TableCellProps>;
type TableFooterComponent = React.FC<TableFooterProps>;
type TableHeaderComponent = React.FC<TableHeaderProps>;
type TableHeaderCellComponent = React.FC<TableHeaderCellProps>;
type TableRowComponent = React.FC<TableRowProps>;
interface TableComponent extends React.FC<TableProps> {
Body: TableBodyComponent;
Cell: TableCellComponent;
Footer: TableFooterComponent;
Header: TableHeaderComponent;
HeaderCell: TableHeaderCellComponent;
Row: TableRowComponent;
}
const TableBody: TableBodyComponent = (props) => {
return <STableBody {...props}/>
};
const TableCell: TableCellComponent = (props) => {
return <STableCell {...props}/>
};
const TableFooter: TableFooterComponent = (props) => {
return <STableFooter {...props}/>
};
const TableHeader: TableHeaderComponent = (props) => {
return <STableHeader {...props}/>
};
const TableHeaderCell: TableHeaderCellComponent = (props) => {
return <STableHeaderCell {...props}/>
};
const TableRow: TableRowComponent = (props) => {
return <STableRow {...props}/>
};
const TableBase: React.FC<TableProps> = (props) => {
const {i} = useContext(ThemeContext);
return <STable {...i} {...props}/>
};
export const Table: TableComponent = Object.assign(TableBase, {
Body: TableBody,
Cell: TableCell,
Footer: TableFooter,
Header: TableHeader,
HeaderCell: TableHeaderCell,
Row: TableRow,
});

View file

@ -1,69 +1,10 @@
import React from 'react';
import {Container} from "semantic-ui-react";
import {getStatus, saveSettings} from "../../api";
import {PageContainer, TabLinks, Toggle} from "../Common";
import {PageContainer, TabLinks} from "../Common";
import {Route, Routes} from "react-router";
import {ThemeContext} from "../../contexts/contexts";
import {Header, Loader, Segment} from "../Theme";
import {DownloadsPage} from "./Downloads";
import {SettingsPage} from "./Settings";
import {StatusPage} from "./Status";
class WROLMode extends React.Component {
constructor(props) {
super(props);
this.state = {
WROLMode: false,
}
}
async componentDidMount() {
try {
const {wrol_mode} = await getStatus();
this.setState({ready: true, WROLMode: wrol_mode});
} catch (e) {
console.error(e);
}
}
toggleWROLMode = async (checked) => {
// Handle WROL Mode toggling by itself so that other settings are not modified.
let config = {
wrol_mode: checked,
}
await saveSettings(config);
this.setState({disabled: !this.state.WROLMode, WROLMode: checked});
}
render() {
if (this.state.ready === false) {
return <Loader active inline='centered'/>
}
return <ThemeContext.Consumer>
{({t}) => <Container fluid>
<Segment>
<Header as="h1">WROL Mode</Header>
<Header as='h4'>
Enable read-only mode. No content can be deleted or modified. Enable this when the SHTF and you
want to prevent any potential loss of data.
</Header>
<p {...t}>
Note: User settings and tags can still be modified.
</p>
<Toggle
checked={this.state.WROLMode}
onChange={this.toggleWROLMode}
label={this.state.WROLMode ? 'WROL Mode Enabled' : 'WROL Mode Disabled'}
/>
</Segment>
</Container>}
</ThemeContext.Consumer>
}
}
import {ControllerPage} from "./ControllerPage";
export default function AdminRoute() {
@ -71,7 +12,7 @@ export default function AdminRoute() {
{text: 'Downloads', to: '/admin', key: 'admin', end: true},
{text: 'Settings', to: '/admin/settings', key: 'settings'},
{text: 'Status', to: '/admin/status', key: 'status'},
{text: 'WROL Mode', to: '/admin/wrol', key: 'wrol'},
{text: 'Controller', to: '/admin/controller', key: 'controller'},
];
return <PageContainer>
@ -80,7 +21,7 @@ export default function AdminRoute() {
<Route path='/' exact element={<DownloadsPage/>}/>
<Route path='settings' exact element={<SettingsPage/>}/>
<Route path='status' exact element={<StatusPage/>}/>
<Route path='wrol' exact element={<WROLMode/>}/>
<Route path='controller' exact element={<ControllerPage/>}/>
</Routes>
</PageContainer>
}

View file

@ -0,0 +1,609 @@
import React from 'react';
import {Container, Icon} from "semantic-ui-react";
import {Button, Header, Loader, Modal, Segment, Table} from "../Theme";
import {
APIButton,
HotspotToggle,
ThrottleToggle,
Toggle,
} from "../Common";
import {useDockerized} from "../../hooks/customHooks";
import {
getServices,
startService,
stopService,
restartService,
enableService,
disableService,
getServiceLogs,
getDisks,
getMounts,
getSmartStatus,
restartServices,
} from "../../api/controller";
import {toast} from "react-semantic-toasts-2";
import {RestartButton, ShutdownButton} from "./Settings";
// Service status color mapping
const statusColors = {
running: 'green',
stopped: 'red',
failed: 'red',
unknown: 'yellow',
};
function ServiceRow({service, onAction, dockerized}) {
const [loading, setLoading] = React.useState(false);
const [logsOpen, setLogsOpen] = React.useState(false);
const [logs, setLogs] = React.useState('');
const [logsLoading, setLogsLoading] = React.useState(false);
const handleAction = async (action, actionFn) => {
setLoading(true);
try {
await actionFn(service.name);
toast({
type: 'success',
title: `${action} ${service.name}`,
description: `Successfully ${action.toLowerCase()}ed ${service.name}`,
time: 3000,
});
if (onAction) onAction();
} catch (e) {
toast({
type: 'error',
title: `${action} Failed`,
description: e.message,
time: 5000,
});
} finally {
setLoading(false);
}
};
const handleViewLogs = async () => {
setLogsOpen(true);
setLogsLoading(true);
try {
const result = await getServiceLogs(service.name, 100);
setLogs(result.logs || 'No logs available');
} catch (e) {
setLogs(`Error fetching logs: ${e.message}`);
} finally {
setLogsLoading(false);
}
};
const isRunning = service.status === 'running';
const isStopped = service.status === 'stopped';
const statusColor = statusColors[service.status] || 'grey';
// Build view URL if service is viewable
let viewUrl = null;
if (service.viewable && service.port && isRunning) {
const protocol = service.use_https ? 'https' : 'http';
const host = window.location.hostname;
const path = service.view_path || '';
viewUrl = `${protocol}://${host}:${service.port}${path}`;
}
return (
<Table.Row>
<Table.Cell>
<strong>{service.name}</strong>
{service.description && <div style={{fontSize: '0.9em', color: '#888'}}>{service.description}</div>}
</Table.Cell>
<Table.Cell>{service.port || '-'}</Table.Cell>
<Table.Cell>
<Icon name='circle' color={statusColor}/>
{service.status}
</Table.Cell>
<Table.Cell>
{isRunning ? (
<Button
size='small'
icon='stop'
color='red'
disabled={loading}
loading={loading}
onClick={() => handleAction('Stop', stopService)}
/>
) : (
<Button
size='small'
icon='play'
color='green'
disabled={loading}
loading={loading}
onClick={() => handleAction('Start', startService)}
/>
)}
<Button
size='small'
icon='refresh'
color='yellow'
disabled={loading}
loading={loading}
onClick={() => handleAction('Restart', restartService)}
/>
<Modal
open={logsOpen}
onClose={() => setLogsOpen(false)}
onOpen={handleViewLogs}
trigger={<Button size='small' icon='file text' content='Logs' color='blue'/>}
size='fullscreen'
>
<Modal.Header>Logs: {service.name}</Modal.Header>
<Modal.Content scrolling>
{logsLoading ? (
<Loader active inline='centered'/>
) : (
<pre style={{
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
maxHeight: '400px',
overflow: 'auto',
backgroundColor: '#1a1a1a',
color: '#00ff00',
padding: '1em',
fontFamily: 'monospace',
fontSize: '0.85em',
}}>
{logs}
</pre>
)}
</Modal.Content>
<Modal.Actions>
<Button onClick={() => setLogsOpen(false)}>Close</Button>
</Modal.Actions>
</Modal>
{viewUrl && (
<Button
size='small'
icon='external'
content='View'
as='a'
href={viewUrl}
target='_blank'
rel='noopener noreferrer'
color='violet'
/>
)}
</Table.Cell>
{!dockerized && (
<Table.Cell>
<Toggle
checked={service.enabled === true}
disabled={service.enabled === null || loading}
onChange={async (checked) => {
if (checked) {
await handleAction('Enable', enableService);
} else {
await handleAction('Disable', disableService);
}
}}
label={service.enabled ? 'Enabled' : 'Disabled'}
/>
</Table.Cell>
)}
</Table.Row>
);
}
function ServicesSection() {
const [services, setServices] = React.useState([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [restarting, setRestarting] = React.useState(false);
const dockerized = useDockerized();
const handleRestartServices = async () => {
setRestarting(true);
try {
await restartServices();
toast({
type: 'success',
title: 'Services Restarting',
description: 'All WROLPi services are being restarted.',
time: 5000,
});
// Refresh service list after restart initiated
fetchServices();
} catch (e) {
toast({
type: 'error',
title: 'Restart Failed',
description: e.message,
time: 5000,
});
} finally {
setRestarting(false);
}
};
const fetchServices = async () => {
setLoading(true);
setError(null);
try {
const result = await getServices();
setServices(Array.isArray(result) ? result : []);
} catch (e) {
setError(e.message);
setServices([]);
} finally {
setLoading(false);
}
};
React.useEffect(() => {
fetchServices();
// Refresh every 10 seconds
const interval = setInterval(fetchServices, 10000);
return () => clearInterval(interval);
}, []);
if (loading && services.length === 0) {
return <Segment>
<Header as='h3'>Services</Header>
<Loader active inline='centered'/>
</Segment>;
}
if (error) {
return <Segment>
<Header as='h3'>Services</Header>
<p style={{color: 'red'}}>Error loading services: {error}</p>
<Button onClick={fetchServices}>Retry</Button>
</Segment>;
}
return (
<Segment>
<Header as='h3'>
<Icon name='server'/>
Services
</Header>
<Table unstackable striped>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Service</Table.HeaderCell>
<Table.HeaderCell>Port</Table.HeaderCell>
<Table.HeaderCell>Status</Table.HeaderCell>
<Table.HeaderCell>Actions</Table.HeaderCell>
{!dockerized && <Table.HeaderCell>Boot</Table.HeaderCell>}
</Table.Row>
</Table.Header>
<Table.Body>
{services.map(service => (
<ServiceRow
dockerized={dockerized}
key={service.name}
service={service}
onAction={fetchServices}
/>
))}
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.HeaderCell colSpan={dockerized ? 4 : 5}>
<APIButton
color='yellow'
onClick={handleRestartServices}
confirmContent='Are you sure you want to restart all WROLPi services?'
confirmButton='Restart Services'
disabled={restarting}
>
<Icon name='refresh'/>
{restarting ? 'Restarting...' : 'Restart All Services'}
</APIButton>
</Table.HeaderCell>
</Table.Row>
</Table.Footer>
</Table>
{services.length === 0 && (
<p>No services found. Services may not be available in this environment.</p>
)}
</Segment>
);
}
// Helper to get health status color from SMART assessment
const getHealthColor = (assessment) => {
if (!assessment) return 'grey';
const upper = assessment.toUpperCase();
if (upper === 'PASS') return 'green';
if (upper === 'WARN') return 'yellow';
if (upper === 'FAIL') return 'red';
return 'grey';
};
// Helper to format power-on hours
const formatPowerOnHours = (hours) => {
if (hours === null || hours === undefined) return '-';
const days = Math.floor(hours / 24);
const years = Math.floor(days / 365);
if (years > 0) return `${hours.toLocaleString()} hrs (${years}y ${days % 365}d)`;
if (days > 0) return `${hours.toLocaleString()} hrs (${days}d)`;
return `${hours} hrs`;
};
function SmartDetailsModal({drive, open, onClose}) {
if (!drive) return null;
const healthColor = getHealthColor(drive.assessment);
return (
<Modal open={open} onClose={onClose} size='small'>
<Modal.Header>
<Icon name='heartbeat'/>
SMART Details: {drive.device}
</Modal.Header>
<Modal.Content>
<Table definition unstackable>
<Table.Body>
<Table.Row>
<Table.Cell width={6}>Model</Table.Cell>
<Table.Cell>{drive.model || '-'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Serial</Table.Cell>
<Table.Cell>{drive.serial || '-'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Capacity</Table.Cell>
<Table.Cell>{drive.capacity || '-'}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Assessment</Table.Cell>
<Table.Cell>
<Icon name='circle' color={healthColor}/>
{drive.assessment || 'Unknown'}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Temperature</Table.Cell>
<Table.Cell>
{drive.temperature !== null && drive.temperature !== undefined
? `${drive.temperature}°C`
: '-'}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Power-On Hours</Table.Cell>
<Table.Cell>{formatPowerOnHours(drive.power_on_hours)}</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Reallocated Sectors</Table.Cell>
<Table.Cell style={{color: drive.reallocated_sectors > 0 ? 'orange' : 'inherit'}}>
{drive.reallocated_sectors !== null && drive.reallocated_sectors !== undefined
? drive.reallocated_sectors
: '-'}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>Pending Sectors</Table.Cell>
<Table.Cell style={{color: drive.pending_sectors > 0 ? 'orange' : 'inherit'}}>
{drive.pending_sectors !== null && drive.pending_sectors !== undefined
? drive.pending_sectors
: '-'}
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell>SMART Enabled</Table.Cell>
<Table.Cell>{drive.smart_enabled ? 'Yes' : 'No'}</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</Modal.Content>
<Modal.Actions>
<Button onClick={onClose}>Close</Button>
</Modal.Actions>
</Modal>
);
}
function DiskSection() {
const [disks, setDisks] = React.useState([]);
const [mounts, setMounts] = React.useState([]);
const [smartDrives, setSmartDrives] = React.useState([]);
const [smartAvailable, setSmartAvailable] = React.useState(true);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState(null);
const [selectedDrive, setSelectedDrive] = React.useState(null);
const [detailsOpen, setDetailsOpen] = React.useState(false);
const dockerized = useDockerized();
const fetchDiskInfo = async () => {
setLoading(true);
setError(null);
try {
const [disksResult, mountsResult, smartResult] = await Promise.allSettled([
getDisks(),
getMounts(),
getSmartStatus(),
]);
if (disksResult.status === 'fulfilled') {
setDisks(Array.isArray(disksResult.value) ? disksResult.value : []);
}
if (mountsResult.status === 'fulfilled') {
setMounts(Array.isArray(mountsResult.value) ? mountsResult.value : []);
}
if (smartResult.status === 'fulfilled' && smartResult.value) {
// Handle new API format: {available: bool, drives: [...]}
const smartData = smartResult.value;
setSmartAvailable(smartData.available !== false);
setSmartDrives(Array.isArray(smartData.drives) ? smartData.drives : []);
} else {
setSmartAvailable(false);
setSmartDrives([]);
}
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
};
const handleShowDetails = (drive) => {
setSelectedDrive(drive);
setDetailsOpen(true);
};
React.useEffect(() => {
fetchDiskInfo();
}, []);
if (dockerized) {
return (
<Segment>
<Header as='h3'>
<Icon name='disk'/>
Disk Management
</Header>
<p>Disk management is not available in Docker environments.</p>
</Segment>
);
}
if (loading) {
return <Segment>
<Header as='h3'>Disk Management</Header>
<Loader active inline='centered'/>
</Segment>;
}
return (
<Segment>
<Header as='h3'>
<Icon name='disk'/>
Disk Management
</Header>
{error && <p style={{color: 'orange'}}>Some disk information unavailable: {error}</p>}
<Header as='h4'>Current Mounts</Header>
{mounts.length > 0 ? (
<Table unstackable striped compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Device</Table.HeaderCell>
<Table.HeaderCell>Mount Point</Table.HeaderCell>
<Table.HeaderCell>Filesystem</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{mounts.map((mount, idx) => (
<Table.Row key={idx}>
<Table.Cell>{mount.device}</Table.Cell>
<Table.Cell>{mount.mount_point}</Table.Cell>
<Table.Cell>{mount.fstype}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
) : (
<p>No mounts found.</p>
)}
<Header as='h4'>SMART Health</Header>
{!smartAvailable ? (
<p style={{color: '#888'}}>SMART monitoring not available (pySMART not installed or not supported)</p>
) : smartDrives.length > 0 ? (
<Table unstackable striped compact>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Device</Table.HeaderCell>
<Table.HeaderCell>Health</Table.HeaderCell>
<Table.HeaderCell>Temperature</Table.HeaderCell>
<Table.HeaderCell>Actions</Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{smartDrives.map((drive) => (
<Table.Row key={drive.device}>
<Table.Cell>{drive.device}</Table.Cell>
<Table.Cell>
<Icon
name='circle'
color={getHealthColor(drive.assessment)}
/>
{drive.assessment || 'Unknown'}
</Table.Cell>
<Table.Cell>
{drive.temperature !== null && drive.temperature !== undefined
? `${drive.temperature}°C`
: '-'}
</Table.Cell>
<Table.Cell>
<Button
size='small'
icon='info'
content='Details'
onClick={() => handleShowDetails(drive)}
/>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
) : (
<p>No SMART-capable drives detected.</p>
)}
<SmartDetailsModal
drive={selectedDrive}
open={detailsOpen}
onClose={() => setDetailsOpen(false)}
/>
<Button onClick={fetchDiskInfo} style={{marginTop: '1em'}}>
<Icon name='refresh'/>
Refresh
</Button>
</Segment>
);
}
function AdminControlsSection() {
const dockerized = useDockerized();
return (
<Segment>
<Header as='h3'>
<Icon name='settings'/>
Hardware Controls
</Header>
<HotspotToggle/>
<ThrottleToggle/>
{!dockerized && (
<div style={{marginTop: '1em'}}>
<RestartButton/>
<ShutdownButton/>
</div>
)}
</Segment>
);
}
export function ControllerPage() {
return (
<Container fluid>
<AdminControlsSection/>
<ServicesSection/>
<DiskSection/>
</Container>
);
}
export default ControllerPage;

View file

@ -0,0 +1,169 @@
import React from 'react';
import {render, screen, waitFor} from '@testing-library/react';
import {BrowserRouter} from 'react-router';
import {ThemeContext, SettingsContext} from '../../contexts/contexts';
import {ControllerPage} from './ControllerPage';
// Mock the controller API
jest.mock('../../api/controller', () => ({
getServices: jest.fn().mockResolvedValue([
{
name: 'wrolpi-api',
port: 8081,
status: 'running',
viewable: true,
view_path: '/docs',
enabled: true,
},
{
name: 'wrolpi-app',
port: 3000,
status: 'running',
viewable: true,
view_path: '',
enabled: true,
},
]),
startService: jest.fn().mockResolvedValue({success: true}),
stopService: jest.fn().mockResolvedValue({success: true}),
restartService: jest.fn().mockResolvedValue({success: true}),
enableService: jest.fn().mockResolvedValue({success: true}),
disableService: jest.fn().mockResolvedValue({success: true}),
getServiceLogs: jest.fn().mockResolvedValue({logs: 'Sample log output'}),
getDisks: jest.fn().mockResolvedValue([]),
getMounts: jest.fn().mockResolvedValue([]),
getSmartStatus: jest.fn().mockResolvedValue({}),
restartServices: jest.fn().mockResolvedValue({success: true}),
}));
// Mock useDockerized hook
jest.mock('../../hooks/customHooks', () => ({
useDockerized: jest.fn().mockReturnValue(false),
useHotspot: jest.fn().mockReturnValue({on: true, setHotspot: jest.fn()}),
useThrottle: jest.fn().mockReturnValue({on: false, setThrottle: jest.fn()}),
}));
// Mock Common.js components that have complex dependencies
jest.mock('../Common', () => {
const actual = jest.requireActual('../Common');
return {
...actual,
HotspotToggle: () => <div data-testid="hotspot-toggle">HotspotToggle</div>,
ThrottleToggle: () => <div data-testid="throttle-toggle">ThrottleToggle</div>,
Toggle: ({label, checked, onChange}) => (
<label>
<input
type="checkbox"
checked={checked || false}
onChange={e => onChange && onChange(e.target.checked)}
/>
{label}
</label>
),
APIButton: ({children, onClick, disabled}) => (
<button onClick={onClick} disabled={disabled}>{children}</button>
),
};
});
// Mock Settings.js components
jest.mock('./Settings', () => ({
RestartButton: () => <button data-testid="restart-button">Restart</button>,
ShutdownButton: () => <button data-testid="shutdown-button">Shutdown</button>,
}));
const defaultTheme = {
t: {style: {}},
inverted: false,
};
const defaultSettings = {
settings: {},
saveSettings: jest.fn(),
fetchSettings: jest.fn(),
};
const renderControllerPage = () => {
return render(
<BrowserRouter>
<ThemeContext.Provider value={defaultTheme}>
<SettingsContext.Provider value={defaultSettings}>
<ControllerPage/>
</SettingsContext.Provider>
</ThemeContext.Provider>
</BrowserRouter>
);
};
describe('ControllerPage', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset useDockerized to false for regular tests
const {useDockerized} = require('../../hooks/customHooks');
useDockerized.mockReturnValue(false);
});
test('renders Services section', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByText('Services')).toBeInTheDocument();
});
});
test('renders Disk Management section', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByText('Disk Management')).toBeInTheDocument();
});
});
test('renders Hardware Controls section', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByText('Hardware Controls')).toBeInTheDocument();
});
});
test('calls getServices on mount', async () => {
const {getServices} = require('../../api/controller');
renderControllerPage();
// Verify getServices was called
await waitFor(() => {
expect(getServices).toHaveBeenCalled();
});
});
test('shows Restart and Shutdown buttons in non-Docker mode', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByTestId('restart-button')).toBeInTheDocument();
expect(screen.getByTestId('shutdown-button')).toBeInTheDocument();
});
});
});
describe('ControllerPage in Docker mode', () => {
beforeEach(() => {
jest.clearAllMocks();
const {useDockerized} = require('../../hooks/customHooks');
useDockerized.mockReturnValue(true);
});
test('hides reboot/shutdown buttons in Docker mode', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByText('Hardware Controls')).toBeInTheDocument();
});
// Shutdown and Restart buttons should not be visible
expect(screen.queryByTestId('shutdown-button')).not.toBeInTheDocument();
expect(screen.queryByTestId('restart-button')).not.toBeInTheDocument();
});
test('shows disk management unavailable message in Docker mode', async () => {
renderControllerPage();
await waitFor(() => {
expect(screen.getByText('Disk management is not available in Docker environments.')).toBeInTheDocument();
});
});
});

View file

@ -1,6 +1,6 @@
import React from "react";
import {SettingsContext, StatusContext} from "../../contexts/contexts";
import {checkUpgrade, postRestart, postShutdown, triggerUpgrade} from "../../api";
import {checkUpgrade, postRestart, postShutdown, saveSettings as saveSettingsApi, triggerUpgrade} from "../../api";
import {
Button,
Divider,
@ -14,10 +14,8 @@ import {ButtonGroup, Container, Dimmer, Dropdown, GridColumn, GridRow, Icon, Inp
import {
APIButton,
ErrorMessage,
HotspotToggle,
InfoPopup,
RefreshHeader,
ThrottleToggle,
Toggle,
useMessageDismissal,
WROLModeMessage
@ -107,8 +105,8 @@ function UpgradeSegment() {
try {
const response = await triggerUpgrade();
if (response.ok) {
// Redirect to maintenance page
window.location.href = '/maintenance.html';
// Redirect to Controller UI for upgrade status
window.location.href = '/controller/?upgrade=true';
}
} catch (e) {
setUpgrading(false);
@ -250,6 +248,47 @@ function toApiLogLevel(logLevel) {
}[logLevel]
}
function WROLModeSection() {
const {settings, fetchSettings} = React.useContext(SettingsContext);
const wrolMode = settings?.wrol_mode || false;
const toggleWROLMode = async (checked) => {
try {
await saveSettingsApi({wrol_mode: checked});
await fetchSettings(); // Refresh context immediately
} catch (e) {
toast({
type: 'error',
title: 'Failed to toggle WROL Mode',
description: e.message,
time: 5000,
});
}
};
return (
<Segment>
<Header as='h3'>
<Icon name='shield'/>
WROL Mode
</Header>
<p>
Enable read-only mode. No content can be deleted or modified.
Enable this when the SHTF and you want to prevent any potential loss of data.
</p>
<p style={{fontSize: '0.9em', color: '#888'}}>
Note: User settings and tags can still be modified.
</p>
<Toggle
checked={wrolMode}
onChange={toggleWROLMode}
label={wrolMode ? 'WROL Mode Enabled' : 'WROL Mode Disabled'}
/>
</Segment>
);
}
export function SettingsPage() {
const [disabled, setDisabled] = React.useState(false);
const [editSpecialDirectories, setEditSpecialDirectories] = React.useState(false);
@ -579,15 +618,6 @@ export function SettingsPage() {
body = <ErrorMessage>Unable to fetch settings</ErrorMessage>
}
const controlSegment = <Segment>
<Header as='h3'>Control WROLPi</Header>
<HotspotToggle/>
<ThrottleToggle/>
<RestartButton/>
<ShutdownButton/>
</Segment>;
const configsSegment = <Segment>
<RefreshHeader
header='Configs'
@ -610,8 +640,6 @@ export function SettingsPage() {
return <Container fluid>
<WROLModeMessage content='Settings are disabled because WROL Mode is enabled.'/>
{controlSegment}
<Segment>
<Header as='h2'>Settings</Header>
{body}
@ -621,6 +649,8 @@ export function SettingsPage() {
<UpgradeSegment/>
<WROLModeSection/>
<Segment>
<Header as='h1'>Browser Settings</Header>
<APIButton onClick={clearAll}>Show All Hints</APIButton>

View file

@ -30,11 +30,16 @@ import {
searchDirectories,
searchVideos,
searchZim,
setHotspot,
setThrottle,
updateChannel,
updateDomain,
} from "../api";
import {
enableHotspot,
disableHotspot,
enableThrottle,
disableThrottle,
getControllerStats,
} from "../api/controller";
import {createSearchParams, useLocation, useSearchParams} from "react-router";
import {enumerate, filterToMimetypes, humanFileSize, secondsToFullDuration} from "../components/Common";
import {QueryContext, SettingsContext, StatusContext} from "../contexts/contexts";
@ -790,9 +795,23 @@ export const useHotspot = () => {
}
}, [status]);
const localSetHotspot = async (on) => {
const localSetHotspot = async (enable) => {
setOn(null);
await setHotspot(on);
try {
if (enable) {
await enableHotspot();
} else {
await disableHotspot();
}
} catch (e) {
console.error('Hotspot error:', e);
toast({
type: 'error',
title: 'Hotspot Error',
description: e.message || 'Could not modify hotspot. See server logs.',
time: 5000,
});
}
}
return {on, inUse, hotspotSsid: hotspot_ssid, setOn, setHotspot: localSetHotspot, dockerized};
@ -866,23 +885,38 @@ export const useConfigs = () => {
export const useThrottle = () => {
const [on, setOn] = useState(null);
const {settings, fetchSettings} = React.useContext(SettingsContext);
const {status, fetchStatus} = React.useContext(StatusContext);
useEffect(() => {
const status = settings['throttle_status'];
if (status === 'powersave') {
const throttleStatus = status?.throttle_status;
if (throttleStatus === 'powersave') {
setOn(true);
} else if (status === 'ondemand') {
} else if (throttleStatus === 'ondemand') {
setOn(false);
} else {
setOn(null);
}
}, [JSON.stringify(settings)]);
}, [status?.throttle_status]);
const localSetThrottle = async (on) => {
const localSetThrottle = async (enable) => {
setOn(null);
await setThrottle(on);
await fetchSettings();
try {
if (enable) {
await enableThrottle();
} else {
await disableThrottle();
}
} catch (e) {
console.error('Throttle error:', e);
toast({
type: 'error',
title: 'Throttle Error',
description: e.message || 'Could not modify throttle. See server logs.',
time: 5000,
});
}
// Refetch status to get updated throttle state
await fetchStatus();
}
return {on, setOn, setThrottle: localSetThrottle};
@ -973,7 +1007,24 @@ export const useStatus = () => {
const fetchStatus = async () => {
try {
setStatus(await getStatus());
// Fetch from both endpoints in parallel
const [appStatus, controllerStats] = await Promise.all([
getStatus().catch(e => {
if (e instanceof ApiDownError) {
window.apiDown = true;
return {};
}
throw e;
}),
getControllerStats().catch(e => {
// Controller may be down, continue with app status only
console.debug('Controller stats unavailable:', e.message);
return {};
}),
]);
// Merge both responses - controller stats override app status for system info
setStatus({...appStatus, ...controllerStats});
window.apiDown = false;
} catch (e) {
if (e instanceof ApiDownError) {
@ -1484,8 +1535,8 @@ export const useVINDecoder = (defaultVINNumber = '') => {
export const useWROLMode = () => {
// Returns the current boolean WROL Mode, during fetch this returns null.
const {status} = useContext(StatusContext);
return status ? status.wrol_mode : null;
const {settings} = useContext(SettingsContext);
return settings ? settings.wrol_mode : null;
}
export const useDockerized = () => {

3
controller/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""WROLPi Controller - System management service."""
__version__ = "1.0.0"

View file

@ -0,0 +1 @@
"""Controller API routes."""

135
controller/api/admin.py Normal file
View file

@ -0,0 +1,135 @@
"""
Admin API endpoints for WROLPi Controller.
"""
from fastapi import APIRouter, HTTPException
from controller.api.schemas import (
HotspotActionResponse,
HotspotStatusResponse,
RestartServicesResponse,
ServiceRestartResult,
SystemActionResponse,
ThrottleActionResponse,
ThrottleStatusResponse,
)
from controller.lib.admin import (
disable_hotspot,
disable_throttle,
enable_hotspot,
enable_throttle,
get_hotspot_status_dict,
get_throttle_status_dict,
reboot_system,
restart_all_services,
shutdown_system,
)
from controller.lib.config import is_docker_mode
router = APIRouter(tags=["admin"])
# --- Hotspot ---
@router.get("/api/hotspot/status", response_model=HotspotStatusResponse)
async def hotspot_status() -> HotspotStatusResponse:
"""Get WiFi hotspot status."""
return HotspotStatusResponse(**get_hotspot_status_dict())
@router.post("/api/hotspot/enable", response_model=HotspotActionResponse)
async def hotspot_enable() -> HotspotActionResponse:
"""Enable WiFi hotspot."""
result = enable_hotspot()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return HotspotActionResponse(**result)
@router.post("/api/hotspot/disable", response_model=HotspotActionResponse)
async def hotspot_disable() -> HotspotActionResponse:
"""Disable WiFi hotspot."""
result = disable_hotspot()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return HotspotActionResponse(**result)
# --- Throttle ---
@router.get("/api/throttle/status", response_model=ThrottleStatusResponse)
async def throttle_status() -> ThrottleStatusResponse:
"""Get CPU throttle status."""
return ThrottleStatusResponse(**get_throttle_status_dict())
@router.post("/api/throttle/enable", response_model=ThrottleActionResponse)
async def throttle_enable() -> ThrottleActionResponse:
"""Enable CPU throttle (powersave mode)."""
result = enable_throttle()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return ThrottleActionResponse(**result)
@router.post("/api/throttle/disable", response_model=ThrottleActionResponse)
async def throttle_disable() -> ThrottleActionResponse:
"""Disable CPU throttle (normal performance)."""
result = disable_throttle()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return ThrottleActionResponse(**result)
# --- System Control ---
@router.post("/api/shutdown", response_model=SystemActionResponse)
async def system_shutdown() -> SystemActionResponse:
"""Shut down the system."""
if is_docker_mode():
raise HTTPException(
status_code=501,
detail="Cannot shutdown from Docker container"
)
result = shutdown_system()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return SystemActionResponse(**result)
@router.post("/api/reboot", response_model=SystemActionResponse)
async def system_reboot() -> SystemActionResponse:
"""Reboot the system."""
if is_docker_mode():
raise HTTPException(
status_code=501,
detail="Cannot reboot from Docker container"
)
result = reboot_system()
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return SystemActionResponse(**result)
@router.post("/api/restart", response_model=RestartServicesResponse)
async def services_restart() -> RestartServicesResponse:
"""Restart all WROLPi services including Controller."""
if is_docker_mode():
raise HTTPException(
status_code=501,
detail="Service restart via systemctl not available in Docker mode"
)
result = await restart_all_services()
# Convert nested dicts to Pydantic models
services = {
name: ServiceRestartResult(**status)
for name, status in result.get("services", {}).items()
}
return RestartServicesResponse(
success=result.get("success", False),
services=services,
error=result.get("error"),
)

188
controller/api/disks.py Normal file
View file

@ -0,0 +1,188 @@
"""
Disk management API endpoints for WROLPi Controller.
"""
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from controller.lib.config import is_docker_mode
from controller.lib.disks import (
get_block_devices,
get_mounts,
mount_drive,
unmount_drive,
)
from controller.lib.fstab import (
add_fstab_entry,
get_wrolpi_fstab_entries,
remove_fstab_entry,
)
from controller.lib.smart import (
get_all_smart_status,
is_smart_available,
)
router = APIRouter(prefix="/api/disks", tags=["disks"])
class MountRequest(BaseModel):
"""Request model for mounting a drive."""
device: str = Field(description="Device path (e.g., /dev/sda1)")
mount_point: str = Field(description="Where to mount (must be under /media)")
fstype: Optional[str] = Field(default=None, description="Filesystem type (auto-detected if not specified)")
options: str = Field(default="defaults", description="Mount options")
persist: bool = Field(default=False, description="Add to fstab for persistent mounting")
class UnmountRequest(BaseModel):
"""Request model for unmounting a drive."""
mount_point: str = Field(description="Mount point to unmount")
lazy: bool = Field(default=False, description="Use lazy unmount if busy")
class FstabRequest(BaseModel):
"""Request model for adding an fstab entry."""
device: str = Field(description="Device path")
mount_point: str = Field(description="Where to mount")
fstype: str = Field(description="Filesystem type")
options: str = Field(default="defaults,nofail,x-systemd.device-timeout=10s", description="Mount options")
def _check_native_mode():
"""Raise 501 if in Docker mode."""
if is_docker_mode():
raise HTTPException(
status_code=501,
detail="Disk management is not available in Docker mode. "
"Configure mounts on the host system."
)
@router.get("")
async def list_disks():
"""List all detected disks/partitions."""
_check_native_mode()
devices = get_block_devices()
return [
{
"name": d.name,
"path": d.path,
"size": d.size,
"fstype": d.fstype,
"mountpoint": d.mountpoint,
"label": d.label,
"uuid": d.uuid,
"model": d.model,
}
for d in devices
]
@router.get("/mounts")
async def list_mounts():
"""List current mounts under /media."""
_check_native_mode()
return get_mounts()
@router.post("/mount")
async def disk_mount(request: MountRequest):
"""Mount a partition."""
_check_native_mode()
result = mount_drive(
device=request.device,
mount_point=request.mount_point,
fstype=request.fstype,
options=request.options,
)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Mount failed"))
# Add to fstab if requested
if request.persist:
fstab_result = add_fstab_entry(
device=request.device,
mount_point=request.mount_point,
fstype=request.fstype or "auto",
)
result["fstab"] = fstab_result
return result
@router.post("/unmount")
async def disk_unmount(request: UnmountRequest):
"""Unmount a partition."""
_check_native_mode()
result = unmount_drive(
mount_point=request.mount_point,
lazy=request.lazy,
)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Unmount failed"))
return result
@router.get("/fstab")
async def list_fstab():
"""Get WROLPi-related fstab entries."""
_check_native_mode()
return get_wrolpi_fstab_entries()
@router.post("/fstab")
async def add_fstab(request: FstabRequest):
"""Add an fstab entry for persistent mounting."""
_check_native_mode()
result = add_fstab_entry(
device=request.device,
mount_point=request.mount_point,
fstype=request.fstype,
options=request.options,
)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return result
@router.delete("/fstab/{mount_point:path}")
async def delete_fstab(mount_point: str):
"""Remove an fstab entry."""
_check_native_mode()
# URL decode the mount point
mount_point = f"/{mount_point}"
result = remove_fstab_entry(mount_point)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return result
# --- SMART ---
@router.get("/smart")
async def list_smart():
"""Get SMART status for all drives."""
_check_native_mode()
if not is_smart_available():
return {"available": False, "reason": "pySMART not installed or not supported"}
return {
"available": True,
"drives": get_all_smart_status(),
}

265
controller/api/schemas.py Normal file
View file

@ -0,0 +1,265 @@
"""
Pydantic models for Controller API requests and responses.
"""
from typing import Optional
from pydantic import BaseModel, Field
# ============================================================================
# Health
# ============================================================================
class HealthResponse(BaseModel):
"""Response model for /api/health endpoint."""
status: str = Field(description="Health status of the controller")
version: str = Field(description="Controller version")
docker_mode: bool = Field(description="Whether running in Docker mode")
drive_mounted: bool = Field(description="Whether the primary drive is mounted")
# ============================================================================
# Status - CPU
# ============================================================================
class CpuStatusResponse(BaseModel):
"""Response model for /api/stats/cpu endpoint."""
percent: float = Field(description="CPU usage percentage (0-100)")
frequency_mhz: Optional[int] = Field(default=None, description="Current CPU frequency in MHz")
frequency_max_mhz: Optional[int] = Field(default=None, description="Maximum CPU frequency in MHz")
temperature_c: Optional[float] = Field(default=None, description="CPU temperature in Celsius")
cores: int = Field(description="Number of CPU cores")
# ============================================================================
# Status - Memory
# ============================================================================
class MemoryStatusResponse(BaseModel):
"""Response model for /api/stats/memory endpoint."""
total_bytes: int = Field(description="Total memory in bytes")
available_bytes: int = Field(description="Available memory in bytes")
used_bytes: int = Field(description="Used memory in bytes")
percent: float = Field(description="Memory usage percentage (0-100)")
total_gb: float = Field(description="Total memory in GB")
used_gb: float = Field(description="Used memory in GB")
available_gb: float = Field(description="Available memory in GB")
# ============================================================================
# Status - Load
# ============================================================================
class LoadStatusResponse(BaseModel):
"""Response model for /api/stats/load endpoint."""
load_1min: float = Field(description="1-minute load average")
load_5min: float = Field(description="5-minute load average")
load_15min: float = Field(description="15-minute load average")
# ============================================================================
# Status - Drives
# ============================================================================
class DriveStatusResponse(BaseModel):
"""Response model for individual drive status."""
device: str = Field(description="Device path (e.g., /dev/sda1)")
mount_point: str = Field(description="Mount point path")
fstype: str = Field(description="Filesystem type")
total_bytes: int = Field(description="Total space in bytes")
used_bytes: int = Field(description="Used space in bytes")
free_bytes: int = Field(description="Free space in bytes")
percent: float = Field(description="Usage percentage (0-100)")
total_gb: float = Field(description="Total space in GB")
used_gb: float = Field(description="Used space in GB")
free_gb: float = Field(description="Free space in GB")
class PrimaryDriveStatusResponse(BaseModel):
"""Response model for /api/stats/drives/primary endpoint."""
mounted: bool = Field(description="Whether the primary drive is mounted")
device: Optional[str] = Field(default=None, description="Device path")
mount_point: Optional[str] = Field(default=None, description="Mount point path")
fstype: Optional[str] = Field(default=None, description="Filesystem type")
total_bytes: Optional[int] = Field(default=None, description="Total space in bytes")
used_bytes: Optional[int] = Field(default=None, description="Used space in bytes")
free_bytes: Optional[int] = Field(default=None, description="Free space in bytes")
percent: Optional[float] = Field(default=None, description="Usage percentage")
total_gb: Optional[float] = Field(default=None, description="Total space in GB")
used_gb: Optional[float] = Field(default=None, description="Used space in GB")
free_gb: Optional[float] = Field(default=None, description="Free space in GB")
# ============================================================================
# Status - Network
# ============================================================================
class NetworkInterfaceResponse(BaseModel):
"""Response model for individual network interface."""
name: str = Field(description="Interface name (e.g., eth0, wlan0)")
ipv4: Optional[str] = Field(default=None, description="IPv4 address")
ipv6: Optional[str] = Field(default=None, description="IPv6 address")
mac: Optional[str] = Field(default=None, description="MAC address")
bytes_sent: int = Field(description="Total bytes sent")
bytes_recv: int = Field(description="Total bytes received")
bytes_sent_mb: float = Field(description="Total MB sent")
bytes_recv_mb: float = Field(description="Total MB received")
# ============================================================================
# Status - Power
# ============================================================================
class PowerStatusResponse(BaseModel):
"""Response model for /api/stats/power endpoint."""
undervoltage_detected: bool = Field(description="Currently experiencing undervoltage")
currently_throttled: bool = Field(description="Currently throttled due to voltage/temp")
undervoltage_occurred: bool = Field(description="Undervoltage has occurred since boot")
throttling_occurred: bool = Field(description="Throttling has occurred since boot")
# ============================================================================
# Status - Full
# ============================================================================
class FullStatusResponse(BaseModel):
"""Response model for /api/stats endpoint."""
cpu: CpuStatusResponse = Field(description="CPU status")
memory: MemoryStatusResponse = Field(description="Memory status")
load: LoadStatusResponse = Field(description="System load")
drives: list[DriveStatusResponse] = Field(description="All mounted drives")
primary_drive: Optional[DriveStatusResponse] = Field(default=None, description="Primary WROLPi drive")
network: list[NetworkInterfaceResponse] = Field(description="Network interfaces")
power: PowerStatusResponse = Field(description="Power status")
# ============================================================================
# Admin - Hotspot
# ============================================================================
class HotspotStatusResponse(BaseModel):
"""Response model for /api/hotspot/status endpoint."""
enabled: bool = Field(description="Whether hotspot is currently enabled")
available: bool = Field(description="Whether hotspot functionality is available")
reason: Optional[str] = Field(default=None, description="Reason if unavailable")
ssid: Optional[str] = Field(default=None, description="Hotspot SSID when enabled")
device: Optional[str] = Field(default=None, description="WiFi device name")
class HotspotActionResponse(BaseModel):
"""Response model for hotspot enable/disable actions."""
success: bool = Field(description="Whether the action succeeded")
ssid: Optional[str] = Field(default=None, description="Hotspot SSID")
device: Optional[str] = Field(default=None, description="WiFi device name")
error: Optional[str] = Field(default=None, description="Error message if failed")
# ============================================================================
# Admin - Throttle
# ============================================================================
class ThrottleStatusResponse(BaseModel):
"""Response model for /api/throttle/status endpoint."""
enabled: bool = Field(description="Whether CPU throttle (powersave) is enabled")
available: bool = Field(description="Whether throttle control is available")
reason: Optional[str] = Field(default=None, description="Reason if unavailable")
governor: Optional[str] = Field(default=None, description="Current CPU governor")
available_governors: Optional[list[str]] = Field(default=None, description="Available governors")
class ThrottleActionResponse(BaseModel):
"""Response model for throttle enable/disable actions."""
success: bool = Field(description="Whether the action succeeded")
governor: Optional[str] = Field(default=None, description="New CPU governor")
error: Optional[str] = Field(default=None, description="Error message if failed")
# ============================================================================
# Admin - System Control
# ============================================================================
class SystemActionResponse(BaseModel):
"""Response model for system shutdown/reboot actions."""
success: bool = Field(description="Whether the action was initiated")
message: Optional[str] = Field(default=None, description="Status message")
error: Optional[str] = Field(default=None, description="Error message if failed")
class ServiceRestartResult(BaseModel):
"""Result of restarting a single service."""
success: bool = Field(description="Whether the service restart succeeded")
error: Optional[str] = Field(default=None, description="Error message if failed")
pending: Optional[bool] = Field(default=None, description="Whether restart is pending")
class RestartServicesResponse(BaseModel):
"""Response model for /api/restart endpoint."""
success: bool = Field(description="Whether the restart was initiated")
services: dict[str, ServiceRestartResult] = Field(description="Results per service")
error: Optional[str] = Field(default=None, description="Error message if failed")
# ============================================================================
# Services - Status
# ============================================================================
class ServiceStatusResponse(BaseModel):
"""Response model for individual service status."""
name: str = Field(description="Service name")
status: str = Field(description="Service status (running, stopped, failed, unknown)")
systemd_name: Optional[str] = Field(default=None, description="Systemd unit name")
container_name: Optional[str] = Field(default=None, description="Docker container name")
active: Optional[str] = Field(default=None, description="Systemd active state")
docker_status: Optional[str] = Field(default=None, description="Docker container status")
enabled: Optional[bool] = Field(default=None, description="Whether service starts at boot")
port: Optional[int] = Field(default=None, description="Service port")
viewable: Optional[bool] = Field(default=False, description="Whether service has a web UI")
view_path: Optional[str] = Field(default="", description="Path to web UI")
use_https: Optional[bool] = Field(default=False, description="Whether service uses HTTPS")
description: Optional[str] = Field(default="", description="Service description")
available: Optional[bool] = Field(default=None, description="Whether management is available")
reason: Optional[str] = Field(default=None, description="Reason if unavailable")
error: Optional[str] = Field(default=None, description="Error message if any")
class ServiceActionResponse(BaseModel):
"""Response model for service start/stop/restart/enable/disable actions."""
success: bool = Field(description="Whether the action succeeded")
service: str = Field(description="Service name")
action: str = Field(description="Action performed (start, stop, restart, enable, disable)")
error: Optional[str] = Field(default=None, description="Error message if failed")
class ServiceLogsResponse(BaseModel):
"""Response model for service logs."""
service: str = Field(description="Service name")
lines: int = Field(description="Number of lines requested")
since: Optional[str] = Field(default=None, description="Time filter if specified")
logs: str = Field(description="Log content")
class ServicesListErrorResponse(BaseModel):
"""Response model when services list fails."""
error: str = Field(description="Error message")
reason: Optional[str] = Field(default=None, description="Detailed reason")

162
controller/api/services.py Normal file
View file

@ -0,0 +1,162 @@
"""
Service management API endpoints for WROLPi Controller.
"""
from typing import Optional, Union
from fastapi import APIRouter, HTTPException, Query
from controller.api.schemas import (
ServiceActionResponse,
ServiceLogsResponse,
ServiceStatusResponse,
ServicesListErrorResponse,
)
from controller.lib.config import is_docker_mode
from controller.lib.docker_services import (
can_manage_containers,
get_all_containers_status,
get_container_logs,
get_container_status,
restart_container,
start_container,
stop_container,
)
from controller.lib.systemd import (
disable_service,
enable_service,
get_all_services_status,
get_service_logs,
get_service_status,
restart_service,
start_service,
stop_service,
)
router = APIRouter(prefix="/api/services", tags=["services"])
@router.get(
"",
response_model=Union[list[ServiceStatusResponse], ServicesListErrorResponse],
)
async def list_services() -> Union[list[ServiceStatusResponse], ServicesListErrorResponse]:
"""Get status of all managed services."""
if is_docker_mode():
if can_manage_containers():
statuses = get_all_containers_status()
return [ServiceStatusResponse(**s) for s in statuses if "error" not in s]
else:
return ServicesListErrorResponse(
error="Docker management not available",
reason="Docker socket not mounted or docker library not installed",
)
else:
statuses = get_all_services_status()
return [ServiceStatusResponse(**s) for s in statuses]
@router.get("/{name}", response_model=ServiceStatusResponse)
async def get_service(name: str) -> ServiceStatusResponse:
"""Get status of a specific service."""
if is_docker_mode():
status = get_container_status(name)
else:
status = get_service_status(name)
if "error" in status and status.get("status") is None:
raise HTTPException(status_code=404, detail=status["error"])
return ServiceStatusResponse(**status)
@router.post("/{name}/start", response_model=ServiceActionResponse)
async def service_start(name: str) -> ServiceActionResponse:
"""Start a service."""
if is_docker_mode():
result = start_container(name)
else:
result = start_service(name)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed to start"))
return ServiceActionResponse(**result)
@router.post("/{name}/stop", response_model=ServiceActionResponse)
async def service_stop(name: str) -> ServiceActionResponse:
"""Stop a service."""
if is_docker_mode():
result = stop_container(name)
else:
result = stop_service(name)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed to stop"))
return ServiceActionResponse(**result)
@router.post("/{name}/restart", response_model=ServiceActionResponse)
async def service_restart(name: str) -> ServiceActionResponse:
"""Restart a service."""
if is_docker_mode():
result = restart_container(name)
else:
result = restart_service(name)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed to restart"))
return ServiceActionResponse(**result)
@router.post("/{name}/enable", response_model=ServiceActionResponse)
async def service_enable(name: str) -> ServiceActionResponse:
"""Enable a service to start at boot (systemd only)."""
if is_docker_mode():
raise HTTPException(
status_code=501,
detail="Enable/disable not available in Docker mode"
)
result = enable_service(name)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return ServiceActionResponse(**result)
@router.post("/{name}/disable", response_model=ServiceActionResponse)
async def service_disable(name: str) -> ServiceActionResponse:
"""Disable a service from starting at boot (systemd only)."""
if is_docker_mode():
raise HTTPException(
status_code=501,
detail="Enable/disable not available in Docker mode"
)
result = disable_service(name)
if not result.get("success"):
raise HTTPException(status_code=500, detail=result.get("error", "Failed"))
return ServiceActionResponse(**result)
@router.get("/{name}/logs", response_model=ServiceLogsResponse)
async def service_logs(
name: str,
lines: int = Query(default=100, ge=1, le=10000),
since: Optional[str] = Query(default=None),
) -> ServiceLogsResponse:
"""Get service logs."""
if is_docker_mode():
result = get_container_logs(name, lines)
else:
result = get_service_logs(name, lines, since)
if "error" in result:
raise HTTPException(status_code=500, detail=result["error"])
return ServiceLogsResponse(**result)

145
controller/api/status.py Normal file
View file

@ -0,0 +1,145 @@
"""
Stats API endpoints for WROLPi Controller.
Endpoints read from cached status data collected by the background worker.
Individual endpoints remain for backwards compatibility.
New aggregated /api/stats endpoint returns all status at once.
This endpoint provides system-level stats. App-level status (flags, downloads,
wrol_mode) remain in the main WROLPi API at /api/status.
"""
from fastapi import APIRouter, Request
from fastapi.responses import JSONResponse
from controller.lib.admin import get_hotspot_status, get_throttle_status, HotspotStatus
from controller.lib.config import is_docker_mode, is_rpi, is_rpi4, is_rpi5
from controller.lib.status import (
get_cpu_status,
get_load_status,
get_memory_status,
get_network_status,
get_power_status,
get_primary_drive_status,
)
router = APIRouter(prefix="/api/stats", tags=["stats"])
def get_cached_status(request: Request) -> dict:
"""Get cached status from app.state, or empty dict if not yet available."""
return getattr(request.app.state, "cached_status", {})
def get_system_info() -> dict:
"""
Get static system info that doesn't need frequent updates.
These are collected on each request as they rarely change.
"""
from controller.lib.config import get_config
hotspot_status = get_hotspot_status()
throttle_status = get_throttle_status()
# Get hotspot SSID from config if hotspot is connected
config = get_config()
hotspot_config = config.get("hotspot", {})
hotspot_ssid = hotspot_config.get("ssid", "WROLPi") if hotspot_status == HotspotStatus.connected else None
return {
"dockerized": is_docker_mode(),
"is_rpi": is_rpi(),
"is_rpi4": is_rpi4(),
"is_rpi5": is_rpi5(),
"hotspot_status": hotspot_status.name,
"hotspot_ssid": hotspot_ssid,
"throttle_status": throttle_status.name,
}
@router.get("")
@router.get("/")
async def get_all_stats(request: Request):
"""
Get all system status at once (cached data + system info).
Returns aggregated CPU, memory, load, drives, network, power, iostat,
processes, disk_bandwidth stats along with system info (dockerized,
is_rpi, hotspot, throttle).
This endpoint returns O(1) cached data updated every ~5 seconds,
plus system info collected on each request.
"""
cached = get_cached_status(request)
if not cached:
# Fallback to on-demand collection if cache not yet populated
from controller.lib.status_worker import collect_all_status
cached = await collect_all_status()
# Add system info to response
response = {**cached, **get_system_info()}
return JSONResponse(content=response)
@router.get("/cpu")
async def get_cpu(request: Request):
"""Get CPU status (usage, frequency, temperature)."""
cached = get_cached_status(request)
if cached and cached.get("cpu_stats"):
return JSONResponse(content=cached["cpu_stats"])
return JSONResponse(content=get_cpu_status())
@router.get("/memory")
async def get_memory(request: Request):
"""Get memory usage statistics."""
cached = get_cached_status(request)
if cached and cached.get("memory_stats"):
return JSONResponse(content=cached["memory_stats"])
return JSONResponse(content=get_memory_status())
@router.get("/load")
async def get_load(request: Request):
"""Get system load averages."""
cached = get_cached_status(request)
if cached and cached.get("load_stats"):
return JSONResponse(content=cached["load_stats"])
return JSONResponse(content=get_load_status())
@router.get("/drives/primary")
async def get_primary_drive(request: Request):
"""Get status of the primary WROLPi drive."""
cached = get_cached_status(request)
if cached and cached.get("drives_stats"):
# Find primary drive from cached drives
for drive in cached["drives_stats"]:
if drive.get("mount") == "/media/wrolpi":
return JSONResponse(content={"mounted": True, **drive})
return JSONResponse(content={"mounted": False})
# Fallback to direct call
status = get_primary_drive_status()
if status is None:
return JSONResponse(content={"mounted": False})
return JSONResponse(content={"mounted": True, **status})
@router.get("/network")
async def get_network(request: Request):
"""Get network interface statistics."""
cached = get_cached_status(request)
if cached and cached.get("nic_bandwidth_stats"):
return JSONResponse(content=cached["nic_bandwidth_stats"])
return JSONResponse(content=get_network_status())
@router.get("/power")
async def get_power(request: Request):
"""Get power status (undervoltage, throttling)."""
cached = get_cached_status(request)
if cached and cached.get("power_stats"):
return JSONResponse(content=cached["power_stats"])
return JSONResponse(content=get_power_status())

106
controller/conftest.py Normal file
View file

@ -0,0 +1,106 @@
"""
Pytest fixtures for Controller tests.
"""
import copy
import tempfile
from pathlib import Path
from typing import Generator
from unittest import mock
import pytest
from fastapi.testclient import TestClient
from controller.defaults import DEFAULT_CONFIG
@pytest.fixture
def test_directory() -> Generator[Path, None, None]:
"""Create a temporary directory for testing."""
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = Path(tmp_dir).resolve()
tmp_path.chmod(0o40755)
yield tmp_path
@pytest.fixture
def test_config_directory(test_directory: Path) -> Path:
"""Create a config directory inside test_directory."""
config_dir = test_directory / "config"
config_dir.mkdir(parents=True, exist_ok=True)
return config_dir
@pytest.fixture
def mock_config_path(test_config_directory: Path):
"""Mock the CONFIG_PATH_ON_DRIVE to use test directory."""
config_path = test_config_directory / "controller.yaml"
with mock.patch("controller.lib.config.CONFIG_PATH_ON_DRIVE", config_path):
yield config_path
@pytest.fixture
def reset_runtime_config():
"""Reset the runtime config to defaults before and after each test."""
import controller.lib.config as config_module
# Save original
original = copy.deepcopy(config_module._runtime_config)
# Reset to defaults
config_module._runtime_config = copy.deepcopy(DEFAULT_CONFIG)
yield
# Restore original
config_module._runtime_config = original
@pytest.fixture
def mock_docker_mode():
"""Mock Docker mode to be disabled."""
with mock.patch.dict("os.environ", {"DOCKERIZED": "false"}):
yield
@pytest.fixture
def mock_docker_mode_enabled():
"""Mock Docker mode to be enabled."""
with mock.patch.dict("os.environ", {"DOCKERIZED": "true"}):
yield
@pytest.fixture
def mock_drive_mounted(test_config_directory: Path):
"""Mock the primary drive as mounted by creating config directory."""
# test_config_directory fixture already creates it
with mock.patch(
"controller.lib.config.is_primary_drive_mounted", return_value=True
):
yield test_config_directory
@pytest.fixture
def mock_drive_not_mounted():
"""Mock the primary drive as not mounted."""
with mock.patch(
"controller.lib.config.is_primary_drive_mounted", return_value=False
):
yield
@pytest.fixture
def test_client(reset_runtime_config, mock_docker_mode) -> TestClient:
"""Create a FastAPI TestClient for integration testing."""
from controller.main import app
with TestClient(app) as client:
yield client
@pytest.fixture
def test_client_docker_mode(reset_runtime_config, mock_docker_mode_enabled) -> TestClient:
"""Create a FastAPI TestClient with Docker mode enabled."""
from controller.main import app
with TestClient(app) as client:
yield client

115
controller/defaults.py Normal file
View file

@ -0,0 +1,115 @@
"""
Code-defined default configuration for the Controller.
Controller starts with these defaults and loads overrides from
/media/wrolpi/config/controller.yaml after the drive is mounted.
"""
DEFAULT_CONFIG = {
"port": 8087,
"media_directory": "/media/wrolpi",
"drives": {
"supported_filesystems": ["ext4", "btrfs", "vfat", "exfat"],
"auto_mount": True,
"mounts": [], # Populated from controller.yaml after drive mount
},
"managed_services": [
# WROLPi services
{
"name": "wrolpi-controller",
"systemd_name": "wrolpi-controller",
"port": 8087,
"viewable": True,
"use_https": False,
"description": "Controller API (FastAPI)",
},
{
"name": "wrolpi-api",
"systemd_name": "wrolpi-api",
"port": 8081,
"viewable": True,
"use_https": False,
"description": "Python API (Sanic)",
},
{
"name": "wrolpi-app",
"systemd_name": "wrolpi-app",
"port": 3000,
"viewable": False,
"use_https": False,
"description": "React frontend",
},
{
"name": "wrolpi-help",
"systemd_name": "wrolpi-help",
"port": 8086,
"viewable": True,
"use_https": True,
"description": "Help documentation",
},
{
"name": "wrolpi-kiwix",
"systemd_name": "wrolpi-kiwix",
"port": 8085,
"viewable": True,
"use_https": True,
"description": "Kiwix/Zim server",
},
# Map services
{
"name": "renderd",
"systemd_name": "renderd",
"port": None,
"viewable": False,
"use_https": False,
"description": "Map tile rendering daemon",
},
{
"name": "apache2",
"systemd_name": "apache2",
"port": 8084,
"viewable": True,
"use_https": True,
"description": "Map tile web server",
},
# System services
{
"name": "postgresql",
"systemd_name": "postgresql",
"port": 5432,
"viewable": False,
"use_https": False,
"description": "PostgreSQL database",
},
{
"name": "nginx",
"systemd_name": "nginx",
"port": 80,
"viewable": True,
"use_https": False,
"description": "Web proxy",
},
# Upgrade service - only shown when running
{
"name": "wrolpi-upgrade",
"systemd_name": "wrolpi-upgrade",
"port": None,
"viewable": False,
"use_https": False,
"description": "WROLPi upgrade process",
"show_only_when_running": True,
},
],
"hotspot": {
"device": "wlan0",
"ssid": "WROLPi",
"password": "wrolpi hotspot",
},
"throttle": {
"default_governor": "ondemand",
},
}

View file

@ -0,0 +1 @@
"""Controller library modules."""

480
controller/lib/admin.py Normal file
View file

@ -0,0 +1,480 @@
"""
Admin operations for WROLPi Controller.
Migrated from wrolpi/admin.py - provides system control functions.
"""
import asyncio
import enum
import subprocess
from pathlib import Path
from controller.lib.config import get_config, is_docker_mode
class HotspotStatus(enum.Enum):
"""Hotspot status enum matching wrolpi/admin.py"""
disconnected = enum.auto() # Radio is on, but Hotspot is not connected.
unavailable = enum.auto() # Radio is off.
connected = enum.auto() # Radio is on, Hotspot is on.
unknown = enum.auto() # Unknown status. Hotspot may not be supported.
in_use = enum.auto() # Wi-Fi device is in use for a network connection.
class GovernorStatus(enum.Enum):
"""CPU governor status enum matching wrolpi/admin.py"""
ondemand = enum.auto()
powersave = enum.auto()
unknown = enum.auto()
def get_current_ssid(interface: str = 'wlan0') -> str | None:
"""
Returns the name of the SSID that is currently connected.
Returns None if Hotspot is active, or no Wi-Fi network is being used.
"""
try:
result = subprocess.run(
['iwgetid', interface, '--raw'],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
ssid = result.stdout.strip()
return ssid if ssid else None
elif result.returncode == 255:
# Hotspot is in use
return None
else:
return None
except (FileNotFoundError, subprocess.TimeoutExpired, subprocess.SubprocessError):
return None
def get_hotspot_status() -> HotspotStatus:
"""
Get current hotspot status matching wrolpi/admin.py logic.
Returns:
HotspotStatus enum value
"""
if is_docker_mode():
return HotspotStatus.unknown
config = get_config()
hotspot_config = config.get("hotspot", {})
device = hotspot_config.get("device", "wlan0")
# Check if device is connected to a Wi-Fi network (not hotspot)
if get_current_ssid(device):
return HotspotStatus.in_use
# Check nmcli status
try:
result = subprocess.run(
["nmcli"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode != 0:
return HotspotStatus.unknown
output = result.stdout.strip()
for line in output.splitlines():
if line.startswith(f'{device}: connected'):
return HotspotStatus.connected
elif line.startswith(f'{device}: disconnected'):
return HotspotStatus.disconnected
elif line.startswith(f'{device}: unavailable'):
return HotspotStatus.unavailable
return HotspotStatus.unknown
except subprocess.TimeoutExpired:
return HotspotStatus.unknown
except FileNotFoundError:
return HotspotStatus.unknown
except subprocess.SubprocessError:
return HotspotStatus.unknown
def get_hotspot_status_dict() -> dict:
"""
Get hotspot status as a dict for API responses.
Returns dict matching HotspotStatusResponse schema:
enabled: bool - Whether hotspot is currently enabled
available: bool - Whether hotspot functionality is available
reason: Optional[str] - Reason if unavailable
ssid: Optional[str] - Hotspot SSID when enabled
device: Optional[str] - WiFi device name
"""
config = get_config()
hotspot_config = config.get("hotspot", {})
device = hotspot_config.get("device", "wlan0")
status = get_hotspot_status()
# Map enum status to enabled/available flags
enabled = status == HotspotStatus.connected
available = status not in (HotspotStatus.unknown, HotspotStatus.unavailable)
reason = None
if status == HotspotStatus.unknown:
reason = "Hotspot not supported or nmcli unavailable"
elif status == HotspotStatus.unavailable:
reason = "WiFi radio is off"
elif status == HotspotStatus.in_use:
reason = "WiFi device is connected to a network"
return {
"enabled": enabled,
"available": available,
"reason": reason,
"ssid": hotspot_config.get("ssid", "WROLPi") if enabled else None,
"device": device,
}
def enable_hotspot() -> dict:
"""
Enable the WiFi hotspot.
Returns:
dict with success status and message
"""
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
config = get_config()
hotspot_config = config.get("hotspot", {})
device = hotspot_config.get("device", "wlan0")
ssid = hotspot_config.get("ssid", "WROLPi")
password = hotspot_config.get("password", "wrolpi hotspot")
try:
# First ensure radio is on
subprocess.run(
["nmcli", "radio", "wifi", "on"],
capture_output=True,
text=True,
timeout=10,
)
# Create hotspot using NetworkManager
result = subprocess.run(
[
"nmcli", "device", "wifi", "hotspot",
"ifname", device,
"ssid", ssid,
"password", password,
],
capture_output=True,
text=True,
timeout=30,
)
if result.returncode == 0:
return {"success": True, "ssid": ssid, "device": device}
else:
return {"success": False, "error": result.stderr.strip() or "Unknown error"}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Timeout enabling hotspot"}
except FileNotFoundError:
return {"success": False, "error": "nmcli not found"}
except subprocess.SubprocessError as e:
return {"success": False, "error": str(e)}
def disable_hotspot() -> dict:
"""
Disable the WiFi hotspot.
Returns:
dict with success status
"""
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
try:
# Turn off the radio to disconnect hotspot
result = subprocess.run(
["nmcli", "radio", "wifi", "off"],
capture_output=True,
text=True,
timeout=10,
)
# Success even if already off
return {"success": True}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Timeout disabling hotspot"}
except FileNotFoundError:
return {"success": False, "error": "nmcli not found"}
except subprocess.SubprocessError as e:
return {"success": False, "error": str(e)}
GOVERNOR_MAP = {
'ondemand': GovernorStatus.ondemand,
'powersave': GovernorStatus.powersave,
}
def get_throttle_status() -> GovernorStatus:
"""
Get CPU throttle (governor) status matching wrolpi/admin.py logic.
Returns:
GovernorStatus enum value
"""
if is_docker_mode():
return GovernorStatus.unknown
governor_path = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
if not governor_path.exists():
return GovernorStatus.unknown
try:
current = governor_path.read_text().strip()
return GOVERNOR_MAP.get(current, GovernorStatus.unknown)
except IOError:
return GovernorStatus.unknown
def get_throttle_status_dict() -> dict:
"""
Get throttle status as a dict for API responses.
Returns dict matching ThrottleStatusResponse schema:
enabled: bool - Whether CPU throttle (powersave) is enabled
available: bool - Whether throttle control is available
reason: Optional[str] - Reason if unavailable
governor: Optional[str] - Current CPU governor
available_governors: Optional[list[str]] - Available governors
"""
if is_docker_mode():
return {
"enabled": False,
"available": False,
"reason": "Docker mode",
"governor": None,
"available_governors": None,
}
governor_path = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor")
available_path = Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors")
if not governor_path.exists():
return {
"enabled": False,
"available": False,
"reason": "cpufreq not available",
"governor": None,
"available_governors": None,
}
try:
current = governor_path.read_text().strip()
available_governors = available_path.read_text().strip().split() if available_path.exists() else []
return {
"enabled": current == "powersave",
"available": True,
"reason": None,
"governor": current,
"available_governors": available_governors,
}
except IOError as e:
return {
"enabled": False,
"available": False,
"reason": str(e),
"governor": None,
"available_governors": None,
}
def enable_throttle() -> dict:
"""
Enable CPU throttle (set powersave governor).
Returns:
dict with success status
"""
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
try:
# Try cpufreq-set first
result = subprocess.run(
["cpufreq-set", "-g", "powersave"],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return {"success": True, "governor": "powersave"}
else:
return {"success": False, "error": result.stderr.strip() or "Unknown error"}
except FileNotFoundError:
# Try direct write to sysfs
try:
for cpu_path in Path("/sys/devices/system/cpu/").glob("cpu[0-9]*/cpufreq/scaling_governor"):
cpu_path.write_text("powersave")
return {"success": True, "governor": "powersave"}
except IOError as e:
return {"success": False, "error": str(e)}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Timeout enabling throttle"}
except subprocess.SubprocessError as e:
return {"success": False, "error": str(e)}
def disable_throttle() -> dict:
"""
Disable CPU throttle (set ondemand or performance governor).
Returns:
dict with success status
"""
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
config = get_config()
default_governor = config.get("throttle", {}).get("default_governor", "ondemand")
try:
result = subprocess.run(
["cpufreq-set", "-g", default_governor],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return {"success": True, "governor": default_governor}
else:
return {"success": False, "error": result.stderr.strip() or "Unknown error"}
except FileNotFoundError:
# Try direct write to sysfs
try:
for cpu_path in Path("/sys/devices/system/cpu/").glob("cpu[0-9]*/cpufreq/scaling_governor"):
cpu_path.write_text(default_governor)
return {"success": True, "governor": default_governor}
except IOError as e:
return {"success": False, "error": str(e)}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Timeout disabling throttle"}
except subprocess.SubprocessError as e:
return {"success": False, "error": str(e)}
def shutdown_system() -> dict:
"""
Shut down the system.
Returns:
dict with success status
"""
if is_docker_mode():
return {"success": False, "error": "Cannot shutdown from Docker container"}
try:
subprocess.Popen(["shutdown", "-h", "now"])
return {"success": True, "message": "System shutting down"}
except FileNotFoundError:
return {"success": False, "error": "shutdown command not found"}
except Exception as e:
return {"success": False, "error": str(e)}
def reboot_system() -> dict:
"""
Reboot the system.
Returns:
dict with success status
"""
if is_docker_mode():
return {"success": False, "error": "Cannot reboot from Docker container"}
try:
subprocess.Popen(["reboot"])
return {"success": True, "message": "System rebooting"}
except FileNotFoundError:
return {"success": False, "error": "reboot command not found"}
except Exception as e:
return {"success": False, "error": str(e)}
async def restart_all_services() -> dict:
"""
Restart all WROLPi services including Controller itself.
Controller restarts itself LAST so it can report success for other services.
Returns:
dict with results for each service
"""
if is_docker_mode():
# In Docker mode, we can't use systemctl
return {
"success": False,
"error": "Service restart via systemctl not available in Docker mode",
}
services_to_restart = [
"wrolpi-api",
"wrolpi-app",
"wrolpi-kiwix",
"wrolpi-help",
"renderd",
"apache2",
# Controller restarts itself last (scheduled after response)
]
results = {}
for service in services_to_restart:
try:
result = subprocess.run(
["systemctl", "restart", service],
capture_output=True,
text=True,
timeout=30,
)
results[service] = {
"success": result.returncode == 0,
"error": result.stderr.strip() if result.returncode != 0 else None,
}
except subprocess.TimeoutExpired:
results[service] = {"success": False, "error": "Timeout"}
except FileNotFoundError:
results[service] = {"success": False, "error": "systemctl not found"}
except Exception as e:
results[service] = {"success": False, "error": str(e)}
# Schedule Controller self-restart after response is sent
loop = asyncio.get_event_loop()
loop.call_later(1.0, _restart_self)
results["wrolpi-controller"] = {"success": True, "pending": True}
return {"success": True, "services": results}
def _restart_self():
"""Restart the Controller service. Called after response is sent."""
try:
subprocess.Popen(["systemctl", "restart", "wrolpi-controller"])
except Exception:
# If we can't restart ourselves, not much we can do
pass

163
controller/lib/config.py Normal file
View file

@ -0,0 +1,163 @@
"""
Configuration management for the Controller.
Controller uses code-defined defaults and optionally loads overrides
from controller.yaml on the WROLPi drive after it's mounted.
"""
import copy
import os
from pathlib import Path
from typing import Any
import yaml
from controller.defaults import DEFAULT_CONFIG
# Path to config file on the WROLPi drive
CONFIG_PATH_ON_DRIVE = Path("/media/wrolpi/config/controller.yaml")
# Runtime config - starts as defaults, updated after drive mount
_runtime_config: dict = copy.deepcopy(DEFAULT_CONFIG)
def get_config() -> dict:
"""Get the current runtime configuration."""
return _runtime_config
def get_config_value(key: str, default: Any = None) -> Any:
"""Get a specific config value by dot-notation key (e.g., 'drives.auto_mount')."""
keys = key.split(".")
value = _runtime_config
for k in keys:
if isinstance(value, dict) and k in value:
value = value[k]
else:
return default
return value
def is_docker_mode() -> bool:
"""Check if running in Docker container."""
return os.environ.get("DOCKERIZED", "").lower() == "true"
# Raspberry Pi detection
RPI_DEVICE_PATH = Path('/proc/device-tree/model')
_RPI_DEVICE_MODEL_CONTENTS = RPI_DEVICE_PATH.read_text() if RPI_DEVICE_PATH.is_file() else None
def is_rpi4() -> bool:
"""Check if running on Raspberry Pi 4."""
return 'Raspberry Pi 4' in _RPI_DEVICE_MODEL_CONTENTS if _RPI_DEVICE_MODEL_CONTENTS else False
def is_rpi5() -> bool:
"""Check if running on Raspberry Pi 5."""
return 'Raspberry Pi 5' in _RPI_DEVICE_MODEL_CONTENTS if _RPI_DEVICE_MODEL_CONTENTS else False
def is_rpi() -> bool:
"""Check if running on any Raspberry Pi."""
return is_rpi4() or is_rpi5()
def get_media_directory() -> Path:
"""Get the media directory path."""
return Path(os.environ.get("MEDIA_DIRECTORY", "/media/wrolpi"))
def is_primary_drive_mounted() -> bool:
"""Check if the primary WROLPi drive is mounted."""
return Path("/media/wrolpi/config").exists()
def reload_config_from_drive() -> bool:
"""
Load config from the mounted WROLPi drive and merge with defaults.
Called after the primary drive is mounted.
Returns True if config was loaded, False if no config file found.
"""
global _runtime_config
if not CONFIG_PATH_ON_DRIVE.exists():
# No config file on drive - keep defaults
return False
try:
with open(CONFIG_PATH_ON_DRIVE) as f:
drive_config = yaml.safe_load(f) or {}
except (IOError, yaml.YAMLError) as e:
# Failed to load - keep defaults
print(f"Warning: Failed to load controller.yaml: {e}")
return False
# Deep merge: drive_config overrides defaults
_runtime_config = _deep_merge(copy.deepcopy(DEFAULT_CONFIG), drive_config)
return True
def save_config() -> None:
"""
Save current runtime config to the WROLPi drive.
Only saves the diff from defaults to keep the file clean.
"""
if not is_primary_drive_mounted():
raise RuntimeError("Cannot save config: primary drive not mounted")
# Calculate diff from defaults
diff = _get_config_diff(_runtime_config, DEFAULT_CONFIG)
if not diff:
# Nothing to save - config matches defaults
return
CONFIG_PATH_ON_DRIVE.parent.mkdir(parents=True, exist_ok=True)
with open(CONFIG_PATH_ON_DRIVE, "w") as f:
yaml.dump(diff, f, default_flow_style=False, sort_keys=False)
def update_config(key: str, value: Any) -> None:
"""
Update a config value by dot-notation key.
Does not automatically save - call save_config() after updates.
"""
keys = key.split(".")
config = _runtime_config
# Navigate to parent of target key
for k in keys[:-1]:
if k not in config:
config[k] = {}
config = config[k]
# Set the value
config[keys[-1]] = value
def _deep_merge(base: dict, override: dict) -> dict:
"""Deep merge two dicts. Override values take precedence."""
result = base.copy()
for key, value in override.items():
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
def _get_config_diff(current: dict, default: dict) -> dict:
"""Get the difference between current config and defaults."""
diff = {}
for key, value in current.items():
if key not in default:
diff[key] = value
elif isinstance(value, dict) and isinstance(default.get(key), dict):
nested_diff = _get_config_diff(value, default[key])
if nested_diff:
diff[key] = nested_diff
elif value != default.get(key):
diff[key] = value
return diff

276
controller/lib/disks.py Normal file
View file

@ -0,0 +1,276 @@
"""
Disk utilities for WROLPi Controller.
Provides disk detection, mounting, and WROLPi drive identification.
"""
import json
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from controller.lib.config import get_config, is_docker_mode
SUPPORTED_FILESYSTEMS = {"ext4", "btrfs", "vfat", "exfat"}
@dataclass
class BlockDevice:
"""Represents a block device/partition."""
name: str
path: str
size: str
fstype: Optional[str]
mountpoint: Optional[str]
label: Optional[str]
uuid: Optional[str]
model: Optional[str]
is_wrolpi_drive: bool = False
def get_block_devices() -> list[BlockDevice]:
"""
Get all block devices using lsblk.
Returns:
list of BlockDevice objects
"""
if is_docker_mode():
return []
cmd = ["lsblk", "-J", "-o", "NAME,PATH,SIZE,TYPE,FSTYPE,MOUNTPOINT,LABEL,UUID,MODEL"]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
if result.returncode != 0:
return []
data = json.loads(result.stdout)
devices = []
def process_device(dev: dict, parent_model: str = None):
if dev.get("type") == "part" and dev.get("fstype") in SUPPORTED_FILESYSTEMS:
devices.append(BlockDevice(
name=dev["name"],
path=dev.get("path", f"/dev/{dev['name']}"),
size=dev.get("size", ""),
fstype=dev.get("fstype"),
mountpoint=dev.get("mountpoint"),
label=dev.get("label"),
uuid=dev.get("uuid"),
model=parent_model,
))
# Process children (partitions)
for child in dev.get("children", []):
process_device(child, dev.get("model"))
for device in data.get("blockdevices", []):
process_device(device)
return devices
except (subprocess.SubprocessError, json.JSONDecodeError):
return []
def get_uuid(device: str) -> Optional[str]:
"""
Get UUID for a device using blkid.
Args:
device: Device path (e.g., /dev/sda1)
Returns:
UUID string or None
"""
try:
cmd = ["blkid", "-s", "UUID", "-o", "value", device]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
if result.returncode == 0:
return result.stdout.strip()
except subprocess.SubprocessError:
pass
return None
def validate_mount_point(mount_point: str) -> None:
"""
Validate that mount point is safe.
Args:
mount_point: Path to validate
Raises:
ValueError: If mount point is not allowed
"""
import os
if not mount_point.startswith("/media"):
raise ValueError(
f"Mount point must be under /media, got: {mount_point}. "
"Mounting to other locations is not allowed."
)
# Prevent path traversal
normalized = os.path.normpath(mount_point)
if not normalized.startswith("/media"):
raise ValueError(f"Invalid mount point after normalization: {normalized}")
def mount_drive(
device: str,
mount_point: str,
fstype: Optional[str] = None,
options: str = "defaults",
) -> dict:
"""
Mount a drive.
Args:
device: Device path or UUID
mount_point: Where to mount
fstype: Filesystem type (auto-detected if not specified)
options: Mount options
Returns:
dict with success status
"""
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
# Validate mount point
try:
validate_mount_point(mount_point)
except ValueError as e:
return {"success": False, "error": str(e)}
# Create mount point if needed
Path(mount_point).mkdir(parents=True, exist_ok=True)
# Build mount command
cmd = ["mount"]
if fstype:
cmd.extend(["-t", fstype])
if options:
cmd.extend(["-o", options])
cmd.extend([device, mount_point])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0:
return {"success": True, "mount_point": mount_point}
else:
return {"success": False, "error": result.stderr.strip()}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Mount timed out"}
def unmount_drive(mount_point: str, lazy: bool = False) -> dict:
"""
Unmount a drive with safety checks.
Args:
mount_point: Path to unmount
lazy: Use lazy unmount if busy
Returns:
dict with success status
"""
if is_docker_mode():
return {"success": False, "error": "Not available in Docker mode"}
# Safety rule: Don't unmount /media/wrolpi if no drives configured
if mount_point == "/media/wrolpi":
config = get_config()
configured_mounts = config.get("drives", {}).get("mounts", [])
if not configured_mounts:
return {
"success": False,
"error": "Cannot unmount /media/wrolpi: No drives are configured. "
"This prevents accidental data loss.",
}
# Check if this is the primary drive
primary_mount = next(
(m for m in configured_mounts if m.get("is_primary")),
None
)
if primary_mount:
return {
"success": False,
"error": "Cannot unmount /media/wrolpi: This is the primary WROLPi drive.",
}
# Check if mount is busy
if is_mount_busy(mount_point) and not lazy:
return {
"success": False,
"error": f"Mount {mount_point} is busy. Use lazy unmount or stop services first.",
}
# Unmount
cmd = ["umount"]
if lazy:
cmd.append("-l")
cmd.append(mount_point)
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode == 0:
return {"success": True, "mount_point": mount_point}
else:
return {"success": False, "error": result.stderr.strip()}
except subprocess.TimeoutExpired:
return {"success": False, "error": "Unmount timed out"}
def is_mount_busy(mount_point: str) -> bool:
"""Check if any processes are using the mount point."""
try:
result = subprocess.run(
["lsof", "+D", mount_point],
capture_output=True,
text=True,
timeout=10,
)
return result.returncode == 0 and result.stdout.strip()
except (subprocess.SubprocessError, FileNotFoundError):
return False
def get_mounts() -> list[dict]:
"""
Get current mounts under /media.
Returns:
list of mount info dicts
"""
try:
result = subprocess.run(
["findmnt", "-J", "-l", "-o", "TARGET,SOURCE,FSTYPE,OPTIONS"],
capture_output=True,
text=True,
timeout=10,
)
if result.returncode != 0:
return []
data = json.loads(result.stdout)
mounts = []
for fs in data.get("filesystems", []):
target = fs.get("target", "")
if target.startswith("/media"):
mounts.append({
"mount_point": target,
"device": fs.get("source"),
"fstype": fs.get("fstype"),
"options": fs.get("options"),
})
return mounts
except (subprocess.SubprocessError, json.JSONDecodeError):
return []

View file

@ -0,0 +1,240 @@
"""
Docker container management for WROLPi Controller.
Used when running in Docker mode instead of systemd.
"""
import os
from controller.lib.config import is_docker_mode
# Import docker only if available (optional dependency)
try:
import docker
from docker.errors import NotFound, APIError
DOCKER_AVAILABLE = True
except ImportError:
DOCKER_AVAILABLE = False
docker = None
NotFound = Exception
APIError = Exception
# Container name prefix (from docker-compose project)
CONTAINER_PREFIX = os.environ.get("COMPOSE_PROJECT_NAME", "wrolpi")
# Containers that should not have a "View" button (non-HTTP services)
NON_VIEWABLE_CONTAINERS = {"db", "postgres", "postgresql", "redis", "memcached"}
# Containers that should use HTTPS (in addition to *_https suffix detection)
HTTPS_CONTAINERS = {"web"}
def can_manage_containers() -> bool:
"""Check if we can manage Docker containers."""
if not is_docker_mode():
return False
if not DOCKER_AVAILABLE:
return False
if not os.path.exists("/var/run/docker.sock"):
return False
return True
def _get_client():
"""Get Docker client."""
if not DOCKER_AVAILABLE:
raise RuntimeError("Docker library not installed")
return docker.from_env()
def _get_container_name(service_name: str) -> str:
"""Convert service name to container name."""
# docker-compose names: {project}-{service}-{number}
return f"{CONTAINER_PREFIX}-{service_name}-1"
def get_container_status(name: str) -> dict:
"""
Get status of a Docker container.
Args:
name: Service name
Returns:
dict with status info
"""
if not can_manage_containers():
return {
"name": name,
"status": "unknown",
"available": False,
"reason": "Docker management not available",
}
try:
client = _get_client()
container_name = _get_container_name(name)
container = client.containers.get(container_name)
# Map Docker status to our status
docker_status = container.status
if docker_status == "running":
status = "running"
elif docker_status == "exited":
status = "stopped"
elif docker_status == "restarting":
status = "restarting"
else:
status = docker_status
# Get port info from container
ports = container.attrs.get("NetworkSettings", {}).get("Ports", {})
port = None
for container_port, host_bindings in ports.items():
if host_bindings:
port = int(host_bindings[0].get("HostPort", 0))
break
return {
"name": name,
"container_name": container_name,
"status": status,
"docker_status": docker_status,
"port": port,
"available": True,
"viewable": name not in NON_VIEWABLE_CONTAINERS,
"use_https": name.endswith("_https") or name in HTTPS_CONTAINERS,
}
except NotFound:
return {
"name": name,
"status": "not_found",
"available": True,
}
except Exception as e:
return {
"name": name,
"status": "error",
"error": str(e),
"available": False,
}
def get_all_containers_status() -> list[dict]:
"""
Get status of all WROLPi containers.
Returns:
list of container status dicts
"""
if not can_manage_containers():
return []
try:
client = _get_client()
containers = client.containers.list(all=True)
results = []
for container in containers:
if container.name.startswith(CONTAINER_PREFIX):
# Extract service name from container name
parts = container.name.split("-")
if len(parts) >= 2:
service_name = parts[1]
else:
service_name = container.name
# Get port info from container
ports = container.attrs.get("NetworkSettings", {}).get("Ports", {})
port = None
for container_port, host_bindings in ports.items():
if host_bindings:
port = int(host_bindings[0].get("HostPort", 0))
break
results.append({
"name": service_name,
"container_name": container.name,
"status": "running" if container.status == "running" else "stopped",
"docker_status": container.status,
"port": port,
"viewable": service_name not in NON_VIEWABLE_CONTAINERS,
"use_https": service_name.endswith("_https") or service_name in HTTPS_CONTAINERS,
})
return results
except Exception as e:
return [{"error": str(e)}]
def start_container(name: str) -> dict:
"""Start a Docker container."""
if not can_manage_containers():
return {"success": False, "error": "Docker management not available"}
try:
client = _get_client()
container_name = _get_container_name(name)
container = client.containers.get(container_name)
container.start()
return {"success": True, "service": name, "action": "start"}
except NotFound:
return {"success": False, "error": f"Container not found: {name}"}
except Exception as e:
return {"success": False, "error": str(e)}
def stop_container(name: str) -> dict:
"""Stop a Docker container."""
if not can_manage_containers():
return {"success": False, "error": "Docker management not available"}
try:
client = _get_client()
container_name = _get_container_name(name)
container = client.containers.get(container_name)
container.stop(timeout=30)
return {"success": True, "service": name, "action": "stop"}
except NotFound:
return {"success": False, "error": f"Container not found: {name}"}
except Exception as e:
return {"success": False, "error": str(e)}
def restart_container(name: str) -> dict:
"""Restart a Docker container."""
if not can_manage_containers():
return {"success": False, "error": "Docker management not available"}
try:
client = _get_client()
container_name = _get_container_name(name)
container = client.containers.get(container_name)
container.restart(timeout=30)
return {"success": True, "service": name, "action": "restart"}
except NotFound:
return {"success": False, "error": f"Container not found: {name}"}
except Exception as e:
return {"success": False, "error": str(e)}
def get_container_logs(name: str, lines: int = 100) -> dict:
"""Get logs from a Docker container."""
if not can_manage_containers():
return {"error": "Docker management not available"}
try:
client = _get_client()
container_name = _get_container_name(name)
container = client.containers.get(container_name)
logs = container.logs(tail=lines).decode("utf-8")
return {
"service": name,
"lines": lines,
"logs": logs,
}
except NotFound:
return {"error": f"Container not found: {name}"}
except Exception as e:
return {"error": str(e)}

198
controller/lib/fstab.py Normal file
View file

@ -0,0 +1,198 @@
"""
fstab management for WROLPi Controller.
Provides persistent mount configuration via /etc/fstab.
"""
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from controller.lib.disks import get_uuid, validate_mount_point
from controller.lib.wrol_mode import require_normal_mode
FSTAB_PATH = Path("/etc/fstab")
def backup_fstab() -> Path:
"""Create a timestamped backup of fstab."""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = Path(f"/etc/fstab.backup.{timestamp}")
shutil.copy(FSTAB_PATH, backup_path)
# Also keep a known backup location for easy recovery
shutil.copy(FSTAB_PATH, Path("/etc/fstab.wrolpi.backup"))
return backup_path
def parse_fstab() -> list[dict]:
"""Parse fstab into a list of entries."""
entries = []
with open(FSTAB_PATH, "r") as f:
for line_num, line in enumerate(f, 1):
stripped = line.strip()
# Preserve comments and blank lines
if not stripped or stripped.startswith("#"):
entries.append({
"type": "comment",
"line": line,
"line_num": line_num,
})
continue
parts = stripped.split()
if len(parts) >= 4:
entries.append({
"type": "mount",
"device": parts[0],
"mount_point": parts[1],
"fstype": parts[2],
"options": parts[3],
"dump": parts[4] if len(parts) > 4 else "0",
"pass": parts[5] if len(parts) > 5 else "0",
"line_num": line_num,
})
return entries
def add_fstab_entry(
device: str,
mount_point: str,
fstype: str,
options: str = "defaults,nofail,x-systemd.device-timeout=10s",
use_uuid: bool = True,
) -> dict:
"""
Add or update an fstab entry for persistent mounting.
Args:
device: Device path (will be converted to UUID if use_uuid=True)
mount_point: Where to mount
fstype: Filesystem type
options: Mount options
use_uuid: Convert device to UUID (recommended)
Returns:
dict with success status
"""
# WROL Mode check
try:
require_normal_mode("add fstab entry")
except PermissionError as e:
return {"success": False, "error": str(e)}
# Validate mount point
try:
validate_mount_point(mount_point)
except ValueError as e:
return {"success": False, "error": str(e)}
# Convert to UUID if requested
if use_uuid and device.startswith("/dev/"):
uuid = get_uuid(device)
if uuid:
device_spec = f"UUID={uuid}"
else:
device_spec = device
else:
device_spec = device
# Backup fstab
backup_fstab()
# Read current fstab
entries = parse_fstab()
# Remove any existing entry for this mount point
entries = [e for e in entries if e.get("mount_point") != mount_point]
# Build new entry line
new_entry = f"{device_spec} {mount_point} {fstype} {options} 0 2\n"
# Write updated fstab
with open(FSTAB_PATH, "w") as f:
for entry in entries:
if entry["type"] == "comment":
f.write(entry["line"])
else:
f.write(
f"{entry['device']} {entry['mount_point']} {entry['fstype']} "
f"{entry['options']} {entry['dump']} {entry['pass']}\n"
)
# Add new entry with comment
f.write(f"# WROLPi managed mount - added {datetime.now().isoformat()}\n")
f.write(new_entry)
# Reload systemd
subprocess.run(["systemctl", "daemon-reload"], capture_output=True)
return {
"success": True,
"device": device_spec,
"mount_point": mount_point,
}
def remove_fstab_entry(mount_point: str) -> dict:
"""
Remove an fstab entry by mount point.
Args:
mount_point: Mount point to remove
Returns:
dict with success status
"""
# WROL Mode check
try:
require_normal_mode("remove fstab entry")
except PermissionError as e:
return {"success": False, "error": str(e)}
# Validate mount point
try:
validate_mount_point(mount_point)
except ValueError as e:
return {"success": False, "error": str(e)}
# Backup fstab
backup_fstab()
entries = parse_fstab()
original_count = len([e for e in entries if e["type"] == "mount"])
# Remove entry
entries = [e for e in entries if e.get("mount_point") != mount_point]
new_count = len([e for e in entries if e["type"] == "mount"])
if original_count == new_count:
return {"success": False, "error": f"No entry found for {mount_point}"}
# Write updated fstab
with open(FSTAB_PATH, "w") as f:
for entry in entries:
if entry["type"] == "comment":
f.write(entry["line"])
else:
f.write(
f"{entry['device']} {entry['mount_point']} {entry['fstype']} "
f"{entry['options']} {entry['dump']} {entry['pass']}\n"
)
subprocess.run(["systemctl", "daemon-reload"], capture_output=True)
return {"success": True, "mount_point": mount_point}
def get_wrolpi_fstab_entries() -> list[dict]:
"""Get all fstab entries for mounts under /media/wrolpi."""
entries = parse_fstab()
return [
e for e in entries
if e["type"] == "mount" and e["mount_point"].startswith("/media/wrolpi")
]

83
controller/lib/smart.py Normal file
View file

@ -0,0 +1,83 @@
"""
SMART disk health monitoring for WROLPi Controller.
"""
from typing import Optional
# Import pySMART only if available
try:
from pySMART import DeviceList, Device
SMART_AVAILABLE = True
except ImportError:
SMART_AVAILABLE = False
from controller.lib.config import is_docker_mode
def is_smart_available() -> bool:
"""Check if SMART monitoring is available."""
if is_docker_mode():
return False
return SMART_AVAILABLE
def get_all_smart_status() -> list[dict]:
"""
Get SMART status for all drives.
Returns:
list of SMART status dicts
"""
if not is_smart_available():
return []
try:
devices = DeviceList()
results = []
for device in devices.devices:
results.append(_get_device_smart(device))
return results
except Exception:
return []
def _get_device_smart(device) -> dict:
"""Extract SMART data from a pySMART device."""
return {
"device": device.name,
"path": f"/dev/{device.name}",
"model": device.model,
"serial": device.serial,
"capacity": device.capacity,
"assessment": device.assessment, # PASS, FAIL, WARN
"temperature": _get_temperature(device),
"power_on_hours": _get_attribute(device, "Power_On_Hours"),
"reallocated_sectors": _get_attribute(device, "Reallocated_Sector_Ct"),
"pending_sectors": _get_attribute(device, "Current_Pending_Sector"),
"smart_enabled": device.smart_enabled,
}
def _get_temperature(device) -> Optional[int]:
"""Get drive temperature from SMART attributes."""
temp = _get_attribute(device, "Temperature_Celsius")
if temp is None:
temp = _get_attribute(device, "Airflow_Temperature_Cel")
return temp
def _get_attribute(device, name: str) -> Optional[int]:
"""Get a specific SMART attribute value."""
if not hasattr(device, 'attributes') or device.attributes is None:
return None
for attr in device.attributes:
if attr and attr.name == name:
try:
return int(attr.raw)
except (ValueError, TypeError):
return None
return None

362
controller/lib/status.py Normal file
View file

@ -0,0 +1,362 @@
"""
System status collection for WROLPi Controller.
Provides functions to collect CPU, memory, load, disk, network, and power
status information. This is the authoritative source for system hardware status.
The status format matches the main WROLPi API's format so the React app
can seamlessly switch between data sources.
"""
import statistics
from pathlib import Path
from typing import Optional
import psutil
# CPU frequency paths
MIN_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_min_freq')
MAX_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq')
CUR_FREQUENCY_PATH = Path('/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq')
def get_cpu_status() -> dict:
"""
Get CPU usage, frequency, temperature, and core count.
Returns format compatible with main API's cpu_stats:
dict with keys: cores, cur_frequency, max_frequency, min_frequency,
percent, temperature, high_temperature, critical_temperature
"""
cpu_percent = int(psutil.cpu_percent(interval=0.1))
cpu_count = psutil.cpu_count()
# Get frequency from sys files (more reliable than psutil on RPi)
min_frequency = max_frequency = cur_frequency = None
try:
if MIN_FREQUENCY_PATH.exists():
min_frequency = int(MIN_FREQUENCY_PATH.read_text())
if MAX_FREQUENCY_PATH.exists():
max_frequency = int(MAX_FREQUENCY_PATH.read_text())
if CUR_FREQUENCY_PATH.exists():
cur_frequency = int(CUR_FREQUENCY_PATH.read_text())
except (ValueError, IOError):
pass
# Get temperature using psutil
temperature = high_temperature = critical_temperature = None
try:
temp = psutil.sensors_temperatures()
if temp:
name = 'coretemp' if 'coretemp' in temp else list(temp.keys())[0]
temperatures = temp.get(name)
if temperatures:
temperature = statistics.median([i.current or 0 for i in temperatures])
high_temperature = statistics.median([i.high or 0 for i in temperatures])
critical_temperature = statistics.median([i.critical or 0 for i in temperatures])
# Temperatures may not exist.
high_temperature = high_temperature or 60
critical_temperature = critical_temperature or 95
if high_temperature and high_temperature == critical_temperature:
# Display yellow warning before red warning.
high_temperature = critical_temperature - 25
except Exception:
pass
return {
"cores": cpu_count,
"cur_frequency": cur_frequency - min_frequency if cur_frequency and min_frequency else None,
"max_frequency": max_frequency,
"min_frequency": min_frequency,
"percent": cpu_percent,
"temperature": int(temperature) if temperature else None,
"high_temperature": int(high_temperature) if high_temperature else None,
"critical_temperature": int(critical_temperature) if critical_temperature else None,
}
def get_memory_status() -> dict:
"""
Get memory usage statistics.
Returns format compatible with main API's memory_stats:
dict with keys: total, used, free, cached
"""
mem = psutil.virtual_memory()
return {
"total": mem.total,
"used": mem.used,
"free": mem.free,
"cached": getattr(mem, 'cached', 0), # cached may not exist on all platforms (e.g., macOS)
}
def get_load_status() -> dict:
"""
Get system load averages.
Returns format compatible with main API's load_stats:
dict with keys: minute_1, minute_5, minute_15
"""
load1, load5, load15 = psutil.getloadavg()
return {
"minute_1": str(round(load1, 2)),
"minute_5": str(round(load5, 2)),
"minute_15": str(round(load15, 2)),
}
IGNORED_DRIVES = ['/boot', '/etc']
VALID_FORMATS = {'btrfs', 'ext4', 'ext3', 'ext2', 'vfat', 'exfat', 'apfs'}
def get_drive_status() -> list[dict]:
"""
Get disk usage for mounted filesystems.
Returns format compatible with main API's drives_stats:
list of dicts with keys: mount, percent, size, used
"""
drives = []
seen_devices = set()
for partition in psutil.disk_partitions():
# Skip ignored directories
if any(partition.mountpoint.startswith(i) for i in IGNORED_DRIVES):
continue
# Skip non-standard filesystems
if partition.fstype not in VALID_FORMATS:
continue
# Skip duplicate devices (only use first partition)
if partition.device in seen_devices:
continue
seen_devices.add(partition.device)
try:
usage = psutil.disk_usage(partition.mountpoint)
drives.append({
"mount": partition.mountpoint,
"percent": int(usage.percent),
"size": int(usage.total),
"used": int(usage.used),
})
except (PermissionError, OSError):
pass
return sorted(drives, key=lambda i: i["mount"])
def get_primary_drive_status() -> Optional[dict]:
"""
Get status of the primary WROLPi drive (/media/wrolpi).
Returns:
dict with drive status, or None if not mounted
"""
for drive in get_drive_status():
if drive["mount"] == "/media/wrolpi":
return drive
return None
IGNORED_NIC_NAMES = {'lo', 'veth', 'tun', 'docker', 'br-'}
def get_network_status() -> dict:
"""
Get network interface statistics.
Returns format compatible with main API's nic_bandwidth_stats:
dict of interface_name -> stats
"""
from datetime import datetime
timestamp = datetime.now().timestamp()
counters = dict()
if_stats = psutil.net_if_stats()
for name, counter in sorted(psutil.net_io_counters(pernic=True, nowrap=True).items(), key=lambda i: i[0]):
if name in IGNORED_NIC_NAMES:
continue
stats = if_stats.get(name)
counters[name] = {
'name': name,
'now': timestamp,
'bytes_recv': counter.bytes_recv,
'bytes_sent': counter.bytes_sent,
'speed': int(stats.speed) if stats else 0,
# Initialize bandwidth per second to 0 (requires historical data to calculate)
'bytes_recv_ps': 0,
'bytes_sent_ps': 0,
}
return counters
def get_power_status() -> dict:
"""
Get power/voltage status (Raspberry Pi specific).
Returns format compatible with main API's power_stats:
dict with keys: under_voltage, over_current
"""
import subprocess
result = {
"under_voltage": False,
"over_current": False,
}
try:
# Check via dmesg (same method as main API)
proc = subprocess.run(
['dmesg'],
capture_output=True,
timeout=5,
)
if proc.returncode == 0 and proc.stdout:
result["under_voltage"] = b' Undervoltage detected' in proc.stdout
result["over_current"] = b' over-current change ' in proc.stdout
except Exception:
pass
return result
def get_iostat_status() -> dict:
"""
Get IO statistics using iostat command.
Returns format compatible with main API's iostat_stats:
dict with keys: percent_idle, percent_iowait, percent_nice,
percent_steal, percent_system, percent_user
"""
import subprocess
result = {
"percent_idle": None,
"percent_iowait": None,
"percent_nice": None,
"percent_steal": None,
"percent_system": None,
"percent_user": None,
}
try:
# Run iostat with 1 second interval, 2 counts (use second measurement)
proc = subprocess.run(
['iostat', '1', '2'],
capture_output=True,
timeout=10,
)
if proc.returncode == 0 and proc.stdout:
try:
import jc
iostat_stats = jc.parse('iostat', proc.stdout.decode(), quiet=True)
if iostat_stats:
# Filter CPU stats and get the second one (first is since boot)
cpu_stats_list = [s for s in iostat_stats if s.get('type') == 'cpu']
if len(cpu_stats_list) >= 2:
cpu_stats = cpu_stats_list[1]
result = {
"percent_idle": cpu_stats.get('percent_idle'),
"percent_iowait": cpu_stats.get('percent_iowait'),
"percent_nice": cpu_stats.get('percent_nice'),
"percent_steal": cpu_stats.get('percent_steal'),
"percent_system": cpu_stats.get('percent_system'),
"percent_user": cpu_stats.get('percent_user'),
}
except ImportError:
# jc not available, parse manually
pass
except Exception:
pass
return result
IGNORED_PROCESS_COMMANDS = {'<defunct>'}
def get_processes_status() -> list[dict]:
"""
Get top CPU-consuming processes.
Returns format compatible with main API's processes_stats:
list of dicts with keys: pid, percent_cpu, percent_mem, command
"""
processes = []
try:
# Get processes sorted by CPU usage
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'cmdline']):
try:
info = proc.info
cpu_percent = info.get('cpu_percent', 0)
# Only include processes using > 1% CPU
if cpu_percent and cpu_percent > 1:
cmdline = info.get('cmdline') or [info.get('name', '')]
command = ' '.join(cmdline) if cmdline else info.get('name', '')
# Skip ignored commands
if any(ignored in command for ignored in IGNORED_PROCESS_COMMANDS):
continue
processes.append({
"pid": info.get('pid'),
"percent_cpu": int(cpu_percent),
"percent_mem": int(info.get('memory_percent', 0)),
"command": command[:512], # Limit command length
})
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
# Sort by CPU usage and return top 10
processes = sorted(processes, key=lambda x: x['percent_cpu'], reverse=True)[:10]
except Exception:
pass
return processes
IGNORED_DISK_NAMES = ('loop', 'ram')
def get_disk_bandwidth_status() -> dict:
"""
Get disk IO bandwidth statistics.
Returns format compatible with main API's disk_bandwidth_stats:
dict of disk_name -> stats with bytes_read, bytes_write, etc.
"""
from datetime import datetime
timestamp = datetime.now().timestamp()
counters = dict()
try:
for name, counter in sorted(psutil.disk_io_counters(perdisk=True).items(), key=lambda i: i[0]):
# Skip partitions (only report main disks)
if any(name.startswith(i) for i in counters):
continue
# Skip ignored disk types
if any(name.startswith(i) for i in IGNORED_DISK_NAMES):
continue
counters[name] = {
'name': name,
'now': timestamp,
'bytes_read': counter.read_bytes,
'bytes_write': counter.write_bytes,
# Initialize per-second rates to 0 (requires historical data)
'bytes_read_ps': 0,
'bytes_write_ps': 0,
'max_read_ps': 500_000, # Default max for display scaling
'max_write_ps': 500_000,
}
except Exception:
pass
return counters

View file

@ -0,0 +1,129 @@
"""
Background status worker for WROLPi Controller.
Periodically collects all system status and caches it in app.state
for O(1) response times on API endpoints.
"""
import asyncio
import multiprocessing
from datetime import datetime
from typing import Optional
from controller.lib.status import (
get_cpu_status,
get_disk_bandwidth_status,
get_drive_status,
get_iostat_status,
get_load_status,
get_memory_status,
get_network_status,
get_power_status,
get_processes_status,
)
# Type alias for cached status dict
CachedStatus = dict
async def collect_all_status() -> CachedStatus:
"""
Collect all status information concurrently.
Returns a dict with all status types. Individual failures return None
for that stat type rather than failing the entire collection.
"""
# Run all status collections concurrently using asyncio.to_thread
# since the status functions are synchronous (use psutil)
cpu, memory, load, drives, network, power, iostat, processes, disk_bandwidth = await asyncio.gather(
asyncio.to_thread(get_cpu_status),
asyncio.to_thread(get_memory_status),
asyncio.to_thread(get_load_status),
asyncio.to_thread(get_drive_status),
asyncio.to_thread(get_network_status),
asyncio.to_thread(get_power_status),
asyncio.to_thread(get_iostat_status),
asyncio.to_thread(get_processes_status),
asyncio.to_thread(get_disk_bandwidth_status),
return_exceptions=True,
)
# Handle individual failures gracefully - return None for failed stats
status = {
"cpu_stats": cpu if not isinstance(cpu, Exception) else None,
"memory_stats": memory if not isinstance(memory, Exception) else None,
"load_stats": load if not isinstance(load, Exception) else None,
"drives_stats": drives if not isinstance(drives, Exception) else [],
"nic_bandwidth_stats": network if not isinstance(network, Exception) else {},
"power_stats": power if not isinstance(power, Exception) else None,
"iostat_stats": iostat if not isinstance(iostat, Exception) else None,
"processes_stats": processes if not isinstance(processes, Exception) else [],
"disk_bandwidth_stats": disk_bandwidth if not isinstance(disk_bandwidth, Exception) else {},
"last_status": datetime.now().isoformat(),
}
return status
def get_adaptive_sleep(load_stats: Optional[dict], base_sleep: float = 5.0) -> float:
"""
Calculate adaptive sleep time based on system load.
If system is under heavy load (load_1 > cpu_count), increase sleep time
to reduce overhead on the system.
Args:
load_stats: Dict with minute_1 key (string), or None
base_sleep: Base sleep interval in seconds
Returns:
Sleep time in seconds (may be > base_sleep if system is stressed)
"""
if load_stats is None:
return base_sleep
try:
load_1 = float(load_stats.get("minute_1", 0))
cpu_count = multiprocessing.cpu_count()
if load_1 > cpu_count:
# System is stressed, slow down status updates
return (base_sleep * load_1) / cpu_count
except (ValueError, TypeError, ZeroDivisionError):
pass
return base_sleep
async def status_worker_loop(app, base_sleep: float = 5.0):
"""
Background loop that continuously collects status data.
Runs indefinitely until cancelled. Stores collected status in
app.state.cached_status for fast endpoint access.
Args:
app: FastAPI application instance
base_sleep: Base sleep interval in seconds (default 5s)
"""
print("Status worker started")
while True:
try:
# Collect all status data
status = await collect_all_status()
# Store in app.state (thread-safe for reading in single-worker uvicorn)
app.state.cached_status = status
# Calculate adaptive sleep based on load
sleep_time = get_adaptive_sleep(status.get("load_stats"), base_sleep)
except asyncio.CancelledError:
print("Status worker cancelled")
raise
except Exception as e:
print(f"Status worker error: {e}")
sleep_time = base_sleep
await asyncio.sleep(sleep_time)

322
controller/lib/systemd.py Normal file
View file

@ -0,0 +1,322 @@
"""
Systemd service management for WROLPi Controller.
Provides start/stop/restart/status/logs for systemd services.
"""
import subprocess
from typing import Optional
from controller.lib.config import get_config
def get_managed_services() -> list[dict]:
"""
Get list of managed services from config.
Returns:
list of service configs with name, systemd_name, port, etc.
"""
config = get_config()
return config.get("managed_services", [])
def get_service_config(name: str) -> Optional[dict]:
"""
Get config for a specific service by name.
Args:
name: Service name (e.g., "wrolpi-api")
Returns:
Service config dict or None if not found
"""
for service in get_managed_services():
if service.get("name") == name:
return service
return None
def _run_systemctl(command: str, service: str, timeout: int = 30) -> dict:
"""
Run a systemctl command.
Args:
command: systemctl command (start, stop, restart, status, etc.)
service: systemd service name
timeout: Command timeout in seconds
Returns:
dict with success, output, and error fields
"""
try:
result = subprocess.run(
["systemctl", command, service],
capture_output=True,
text=True,
timeout=timeout,
)
return {
"success": result.returncode == 0,
"output": result.stdout.strip(),
"error": result.stderr.strip() if result.returncode != 0 else None,
}
except subprocess.TimeoutExpired:
return {"success": False, "output": "", "error": "Command timed out"}
except FileNotFoundError:
return {"success": False, "output": "", "error": "systemctl not found"}
def get_service_status(name: str) -> dict:
"""
Get status of a managed service.
Args:
name: Service name from config
Returns:
dict with status, active, enabled, etc.
"""
service_config = get_service_config(name)
if not service_config:
return {"error": f"Unknown service: {name}"}
systemd_name = service_config.get("systemd_name", name)
try:
# Get active state
active_result = subprocess.run(
["systemctl", "is-active", systemd_name],
capture_output=True,
text=True,
timeout=5,
)
is_active = active_result.stdout.strip()
# Get enabled state
enabled_result = subprocess.run(
["systemctl", "is-enabled", systemd_name],
capture_output=True,
text=True,
timeout=5,
)
is_enabled = enabled_result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError):
return {
"name": name,
"systemd_name": systemd_name,
"status": "unknown",
"active": "unknown",
"enabled": False,
"port": service_config.get("port"),
"viewable": service_config.get("viewable", False),
"view_path": service_config.get("view_path", ""),
"use_https": service_config.get("use_https", False),
"description": service_config.get("description", ""),
"error": "systemctl not available",
}
# Map to simple status
if is_active == "active":
status = "running"
elif is_active == "activating":
status = "running" # oneshot services show "activating" while running
elif is_active == "inactive":
status = "stopped"
elif is_active == "failed":
status = "failed"
else:
status = "unknown"
return {
"name": name,
"systemd_name": systemd_name,
"status": status,
"active": is_active,
"enabled": is_enabled == "enabled",
"port": service_config.get("port"),
"viewable": service_config.get("viewable", False),
"view_path": service_config.get("view_path", ""),
"use_https": service_config.get("use_https", False),
"description": service_config.get("description", ""),
}
def get_all_services_status() -> list[dict]:
"""
Get status of all managed services.
Services with show_only_when_running=True are excluded when not running.
Returns:
list of service status dicts
"""
results = []
for service in get_managed_services():
status = get_service_status(service["name"])
# Skip services with show_only_when_running if not running
if service.get("show_only_when_running") and status.get("status") != "running":
continue
results.append(status)
return results
def start_service(name: str) -> dict:
"""
Start a managed service.
Args:
name: Service name from config
Returns:
dict with success status
"""
service_config = get_service_config(name)
if not service_config:
return {"success": False, "error": f"Unknown service: {name}"}
systemd_name = service_config.get("systemd_name", name)
result = _run_systemctl("start", systemd_name)
return {
"success": result["success"],
"service": name,
"action": "start",
"error": result.get("error"),
}
def stop_service(name: str) -> dict:
"""
Stop a managed service.
Args:
name: Service name from config
Returns:
dict with success status
"""
service_config = get_service_config(name)
if not service_config:
return {"success": False, "error": f"Unknown service: {name}"}
systemd_name = service_config.get("systemd_name", name)
result = _run_systemctl("stop", systemd_name)
return {
"success": result["success"],
"service": name,
"action": "stop",
"error": result.get("error"),
}
def restart_service(name: str) -> dict:
"""
Restart a managed service.
Args:
name: Service name from config
Returns:
dict with success status
"""
service_config = get_service_config(name)
if not service_config:
return {"success": False, "error": f"Unknown service: {name}"}
systemd_name = service_config.get("systemd_name", name)
result = _run_systemctl("restart", systemd_name)
return {
"success": result["success"],
"service": name,
"action": "restart",
"error": result.get("error"),
}
def enable_service(name: str) -> dict:
"""
Enable a service to start at boot.
Args:
name: Service name from config
Returns:
dict with success status
"""
service_config = get_service_config(name)
if not service_config:
return {"success": False, "error": f"Unknown service: {name}"}
systemd_name = service_config.get("systemd_name", name)
result = _run_systemctl("enable", systemd_name)
return {
"success": result["success"],
"service": name,
"action": "enable",
"error": result.get("error"),
}
def disable_service(name: str) -> dict:
"""
Disable a service from starting at boot.
Args:
name: Service name from config
Returns:
dict with success status
"""
service_config = get_service_config(name)
if not service_config:
return {"success": False, "error": f"Unknown service: {name}"}
systemd_name = service_config.get("systemd_name", name)
result = _run_systemctl("disable", systemd_name)
return {
"success": result["success"],
"service": name,
"action": "disable",
"error": result.get("error"),
}
def get_service_logs(name: str, lines: int = 100, since: Optional[str] = None) -> dict:
"""
Get logs for a service.
Args:
name: Service name from config
lines: Number of lines to return
since: Time specification (e.g., "1h", "30m", "2024-01-01")
Returns:
dict with logs and metadata
"""
service_config = get_service_config(name)
if not service_config:
return {"error": f"Unknown service: {name}"}
systemd_name = service_config.get("systemd_name", name)
cmd = ["journalctl", "-u", systemd_name, "-n", str(lines), "--no-pager"]
if since:
cmd.extend(["--since", since])
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return {
"service": name,
"lines": lines,
"since": since,
"logs": result.stdout,
}
except subprocess.TimeoutExpired:
return {"error": "Timeout getting logs"}
except FileNotFoundError:
return {"error": "journalctl not found"}

View file

@ -0,0 +1,45 @@
"""
WROL Mode detection for WROLPi Controller.
WROL Mode (Without Rule of Law) is an emergency mode that restricts
persistent configuration changes.
"""
from pathlib import Path
from controller.lib.config import get_media_directory
def get_wrol_mode_flag_path() -> Path:
"""Get the path to the WROL Mode flag file."""
return get_media_directory() / "config" / ".wrol_mode"
def is_wrol_mode() -> bool:
"""
Check if WROL Mode is active.
WROL Mode is indicated by the presence of a flag file.
This file is created/deleted by the API when the user toggles WROL Mode.
"""
return get_wrol_mode_flag_path().exists()
def require_normal_mode(operation: str) -> None:
"""
Raise an error if WROL Mode is active.
Use this to guard persistent operations that shouldn't be
allowed during emergencies.
Args:
operation: Description of the operation being attempted
Raises:
PermissionError: If WROL Mode is active
"""
if is_wrol_mode():
raise PermissionError(
f"Cannot {operation} while WROL Mode is active. "
"Disable WROL Mode to make persistent changes."
)

188
controller/main.py Normal file
View file

@ -0,0 +1,188 @@
"""
WROLPi Controller - FastAPI Application
The Controller provides:
- System status monitoring (CPU, memory, drives, network)
- Service management (start/stop/restart systemd services)
- Disk mounting and management
- Admin operations (hotspot, throttle, shutdown, reboot)
- Simple emergency UI when main app is down
"""
import asyncio
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from controller import __version__
from controller.api.admin import router as admin_router
from controller.api.disks import router as disks_router
from controller.api.schemas import HealthResponse
from controller.api.services import router as services_router
from controller.api.status import router as status_router
from controller.lib.config import (
is_docker_mode,
is_primary_drive_mounted,
reload_config_from_drive,
)
from controller.lib.docker_services import (
can_manage_containers,
get_all_containers_status,
)
from controller.lib.status import (
get_cpu_status,
get_load_status,
get_memory_status,
get_primary_drive_status,
)
from controller.lib.status_worker import status_worker_loop
from controller.lib.systemd import get_all_services_status
# Templates directory
TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Application lifespan handler.
Runs on startup and shutdown.
"""
# Startup
print(f"WROLPi Controller v{__version__} starting...")
print(f"Docker mode: {is_docker_mode()}")
# Try to load config from drive if mounted
if is_primary_drive_mounted():
if reload_config_from_drive():
print("Loaded configuration from drive")
else:
print("Using default configuration (no controller.yaml found)")
else:
print("Primary drive not mounted - using default configuration")
# Initialize cached status
app.state.cached_status = {}
# Start status worker background task
status_task = asyncio.create_task(status_worker_loop(app))
yield
# Shutdown
print("WROLPi Controller shutting down...")
status_task.cancel()
try:
await status_task
except asyncio.CancelledError:
pass
# Create FastAPI app
app = FastAPI(
title="WROLPi Controller",
description="System management service for WROLPi",
version=__version__,
lifespan=lifespan,
)
# Include routers
app.include_router(admin_router)
app.include_router(disks_router)
app.include_router(services_router)
app.include_router(status_router)
def get_host(request: Request) -> str:
"""Get the host for links (use request host, fallback to localhost)."""
host = request.headers.get("host", "localhost")
# Strip port if present
if ":" in host:
host = host.split(":")[0]
return host
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""Render the main dashboard UI with real status data."""
# Try to use cached status first
cached = getattr(request.app.state, "cached_status", {})
if cached:
cpu = cached.get("cpu_stats") or {}
memory = cached.get("memory_stats") or {}
load = cached.get("load_stats") or {}
drives = cached.get("drives_stats") or []
primary_drive = None
for drive in drives:
if drive.get("mount") == "/media/wrolpi":
primary_drive = drive
break
else:
# Fallback to direct calls if cache not yet populated
cpu = get_cpu_status()
memory = get_memory_status()
load = get_load_status()
primary_drive = get_primary_drive_status()
# Format storage data
storage = {}
if primary_drive:
free_bytes = primary_drive["size"] - primary_drive["used"]
storage = {
"percent": primary_drive["percent"],
"free_gb": round(free_bytes / (1024 ** 3), 1),
}
# Get service status
if is_docker_mode():
if can_manage_containers():
services = get_all_containers_status()
else:
services = []
else:
services = get_all_services_status()
context = {
"version": __version__,
"docker_mode": is_docker_mode(),
"drive_mounted": is_primary_drive_mounted(),
"host": get_host(request),
# Real status data
"cpu": {
"percent": cpu["percent"],
"temperature_c": cpu["temperature"], # Field renamed
},
"memory": {
"percent": round((memory["used"] / memory["total"]) * 100, 1) if memory["total"] > 0 else 0,
"used_gb": round(memory["used"] / (1024 ** 3), 1),
},
"load": {
"load_1min": load["minute_1"], # Field renamed
},
"storage": storage,
# Real service status
"services": services,
}
return templates.TemplateResponse(request, "index.html", context)
@app.get("/api/health", response_model=HealthResponse)
async def health_check() -> HealthResponse:
"""
Health check endpoint.
Returns basic status for monitoring and load balancer checks.
"""
return HealthResponse(
status="healthy",
version=__version__,
docker_mode=is_docker_mode(),
drive_mounted=is_primary_drive_mounted(),
)

View file

@ -0,0 +1,27 @@
# Controller dependencies
# Separate from main WROLPi API to keep venvs independent
# Web framework
fastapi>=0.100.0
uvicorn[standard]>=0.23.0
pydantic>=2.0.0
# Templates
jinja2>=3.1.0
# System monitoring
psutil>=5.9.0
# Disk health
pySMART>=1.3.0
# Config file handling
pyyaml>=6.0
# Docker management (optional, for Docker mode)
docker>=6.0.0
# Testing
pytest>=7.0.0
pytest-asyncio>=0.21.0
httpx>=0.24.0

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1 @@
"""Controller tests."""

View file

@ -0,0 +1,242 @@
"""
Unit tests for controller.lib.admin module.
"""
from unittest import mock
import pytest
from controller.lib.admin import (
disable_hotspot,
disable_throttle,
enable_hotspot,
enable_throttle,
get_hotspot_status,
get_throttle_status,
reboot_system,
restart_all_services,
shutdown_system,
HotspotStatus,
GovernorStatus,
)
class TestGetHotspotStatus:
"""Tests for get_hotspot_status function."""
def test_returns_hotspot_status_enum(self):
"""Should return a HotspotStatus enum value."""
result = get_hotspot_status()
assert isinstance(result, HotspotStatus)
def test_returns_unknown_in_docker_mode(self):
"""Should return unknown when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = get_hotspot_status()
assert result == HotspotStatus.unknown
def test_handles_nmcli_not_found(self):
"""Should handle nmcli not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_current_ssid", return_value=None):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = get_hotspot_status()
assert result == HotspotStatus.unknown
def test_handles_timeout(self):
"""Should handle subprocess timeout."""
import subprocess
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_current_ssid", return_value=None):
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("nmcli", 5)):
result = get_hotspot_status()
assert result == HotspotStatus.unknown
class TestEnableHotspot:
"""Tests for enable_hotspot function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = enable_hotspot()
assert result["success"] is False
assert "Docker" in result.get("error", "")
def test_handles_nmcli_not_found(self):
"""Should handle nmcli not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("controller.lib.admin.get_config", return_value={}):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = enable_hotspot()
assert result["success"] is False
assert "nmcli" in result.get("error", "").lower()
class TestDisableHotspot:
"""Tests for disable_hotspot function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = disable_hotspot()
assert result["success"] is False
assert "Docker" in result.get("error", "")
def test_handles_nmcli_not_found(self):
"""Should handle nmcli not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = disable_hotspot()
assert result["success"] is False
assert "nmcli" in result.get("error", "").lower()
class TestGetThrottleStatus:
"""Tests for get_throttle_status function."""
def test_returns_governor_status_enum(self):
"""Should return a GovernorStatus enum value."""
result = get_throttle_status()
assert isinstance(result, GovernorStatus)
def test_returns_unknown_in_docker_mode(self):
"""Should return unknown when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = get_throttle_status()
assert result == GovernorStatus.unknown
def test_returns_unknown_when_cpufreq_not_available(self, tmp_path):
"""Should return unknown when cpufreq doesn't exist."""
from pathlib import Path
nonexistent_path = tmp_path / "nonexistent"
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch.object(Path, "exists", return_value=False):
result = get_throttle_status()
assert result == GovernorStatus.unknown
def test_reads_governor_from_sysfs(self, tmp_path):
"""Should read governor from sysfs when available."""
from pathlib import Path
governor_path = tmp_path / "scaling_governor"
governor_path.write_text("ondemand")
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
# This test verifies the function structure - actual sysfs testing
# would require a real Raspberry Pi environment
result = get_throttle_status()
assert isinstance(result, GovernorStatus)
class TestEnableThrottle:
"""Tests for enable_throttle function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = enable_throttle()
assert result["success"] is False
assert "Docker" in result.get("error", "")
class TestDisableThrottle:
"""Tests for disable_throttle function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = disable_throttle()
assert result["success"] is False
assert "Docker" in result.get("error", "")
class TestShutdownSystem:
"""Tests for shutdown_system function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = shutdown_system()
assert result["success"] is False
assert "Docker" in result.get("error", "")
def test_handles_shutdown_not_found(self):
"""Should handle shutdown command not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.Popen", side_effect=FileNotFoundError()):
result = shutdown_system()
assert result["success"] is False
assert "not found" in result.get("error", "").lower()
def test_calls_shutdown_command(self):
"""Should call shutdown command with correct args."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
mock_popen = mock.Mock()
with mock.patch("subprocess.Popen", mock_popen):
result = shutdown_system()
mock_popen.assert_called_once_with(["shutdown", "-h", "now"])
assert result["success"] is True
class TestRebootSystem:
"""Tests for reboot_system function."""
def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = reboot_system()
assert result["success"] is False
assert "Docker" in result.get("error", "")
def test_handles_reboot_not_found(self):
"""Should handle reboot command not being available."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
with mock.patch("subprocess.Popen", side_effect=FileNotFoundError()):
result = reboot_system()
assert result["success"] is False
assert "not found" in result.get("error", "").lower()
def test_calls_reboot_command(self):
"""Should call reboot command."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
mock_popen = mock.Mock()
with mock.patch("subprocess.Popen", mock_popen):
result = reboot_system()
mock_popen.assert_called_once_with(["reboot"])
assert result["success"] is True
class TestRestartAllServices:
"""Tests for restart_all_services function."""
@pytest.mark.asyncio
async def test_returns_error_in_docker_mode(self):
"""Should return error when in Docker mode."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=True):
result = await restart_all_services()
assert result["success"] is False
assert "Docker" in result.get("error", "")
@pytest.mark.asyncio
async def test_restarts_services(self):
"""Should attempt to restart all services."""
with mock.patch("controller.lib.admin.is_docker_mode", return_value=False):
# Mock subprocess.run to succeed
mock_result = mock.Mock()
mock_result.returncode = 0
mock_result.stderr = ""
# Mock _restart_self to avoid actual restart
with mock.patch("subprocess.run", return_value=mock_result):
with mock.patch("controller.lib.admin._restart_self"):
with mock.patch("asyncio.get_event_loop") as mock_loop:
mock_loop.return_value.call_later = mock.Mock()
result = await restart_all_services()
assert result["success"] is True
assert "services" in result
# Should include controller as pending
assert result["services"]["wrolpi-controller"]["pending"] is True

View file

@ -0,0 +1,145 @@
"""
Integration tests for Controller admin API endpoints.
"""
class TestHotspotStatusEndpoint:
"""Tests for /api/hotspot/status endpoint."""
def test_hotspot_status_returns_200(self, test_client):
"""Hotspot status endpoint should return 200 OK."""
response = test_client.get("/api/hotspot/status")
assert response.status_code == 200
def test_hotspot_status_returns_expected_fields(self, test_client):
"""Hotspot status should return enabled and available fields."""
response = test_client.get("/api/hotspot/status")
data = response.json()
assert "enabled" in data
assert "available" in data
def test_hotspot_status_shows_unavailable_in_docker(self, test_client_docker_mode):
"""Hotspot should be unavailable in Docker mode."""
response = test_client_docker_mode.get("/api/hotspot/status")
data = response.json()
assert data["available"] is False
class TestHotspotEnableEndpoint:
"""Tests for /api/hotspot/enable endpoint."""
def test_hotspot_enable_fails_in_docker(self, test_client_docker_mode):
"""Hotspot enable should fail in Docker mode."""
response = test_client_docker_mode.post("/api/hotspot/enable")
assert response.status_code == 500
assert "Docker" in response.json()["detail"]
class TestHotspotDisableEndpoint:
"""Tests for /api/hotspot/disable endpoint."""
def test_hotspot_disable_fails_in_docker(self, test_client_docker_mode):
"""Hotspot disable should fail in Docker mode."""
response = test_client_docker_mode.post("/api/hotspot/disable")
assert response.status_code == 500
assert "Docker" in response.json()["detail"]
class TestThrottleStatusEndpoint:
"""Tests for /api/throttle/status endpoint."""
def test_throttle_status_returns_200(self, test_client):
"""Throttle status endpoint should return 200 OK."""
response = test_client.get("/api/throttle/status")
assert response.status_code == 200
def test_throttle_status_returns_expected_fields(self, test_client):
"""Throttle status should return enabled and available fields."""
response = test_client.get("/api/throttle/status")
data = response.json()
assert "enabled" in data
assert "available" in data
def test_throttle_status_shows_unavailable_in_docker(self, test_client_docker_mode):
"""Throttle should be unavailable in Docker mode."""
response = test_client_docker_mode.get("/api/throttle/status")
data = response.json()
assert data["available"] is False
class TestThrottleEnableEndpoint:
"""Tests for /api/throttle/enable endpoint."""
def test_throttle_enable_fails_in_docker(self, test_client_docker_mode):
"""Throttle enable should fail in Docker mode."""
response = test_client_docker_mode.post("/api/throttle/enable")
assert response.status_code == 500
assert "Docker" in response.json()["detail"]
class TestThrottleDisableEndpoint:
"""Tests for /api/throttle/disable endpoint."""
def test_throttle_disable_fails_in_docker(self, test_client_docker_mode):
"""Throttle disable should fail in Docker mode."""
response = test_client_docker_mode.post("/api/throttle/disable")
assert response.status_code == 500
assert "Docker" in response.json()["detail"]
class TestShutdownEndpoint:
"""Tests for /api/shutdown endpoint."""
def test_shutdown_returns_501_in_docker(self, test_client_docker_mode):
"""Shutdown should return 501 in Docker mode."""
response = test_client_docker_mode.post("/api/shutdown")
assert response.status_code == 501
assert "Docker" in response.json()["detail"]
class TestRebootEndpoint:
"""Tests for /api/reboot endpoint."""
def test_reboot_returns_501_in_docker(self, test_client_docker_mode):
"""Reboot should return 501 in Docker mode."""
response = test_client_docker_mode.post("/api/reboot")
assert response.status_code == 501
assert "Docker" in response.json()["detail"]
class TestRestartEndpoint:
"""Tests for /api/restart endpoint."""
def test_restart_returns_501_in_docker(self, test_client_docker_mode):
"""Restart should return 501 in Docker mode."""
response = test_client_docker_mode.post("/api/restart")
assert response.status_code == 501
assert "Docker" in response.json()["detail"]
class TestOpenAPIIncludesAdminEndpoints:
"""Tests that OpenAPI documentation includes admin endpoints."""
def test_openapi_has_hotspot_paths(self, test_client):
"""OpenAPI schema should include hotspot paths."""
response = test_client.get("/openapi.json")
data = response.json()
assert "/api/hotspot/status" in data["paths"]
assert "/api/hotspot/enable" in data["paths"]
assert "/api/hotspot/disable" in data["paths"]
def test_openapi_has_throttle_paths(self, test_client):
"""OpenAPI schema should include throttle paths."""
response = test_client.get("/openapi.json")
data = response.json()
assert "/api/throttle/status" in data["paths"]
assert "/api/throttle/enable" in data["paths"]
assert "/api/throttle/disable" in data["paths"]
def test_openapi_has_system_control_paths(self, test_client):
"""OpenAPI schema should include system control paths."""
response = test_client.get("/openapi.json")
data = response.json()
assert "/api/shutdown" in data["paths"]
assert "/api/reboot" in data["paths"]
assert "/api/restart" in data["paths"]

222
controller/test/test_api.py Normal file
View file

@ -0,0 +1,222 @@
"""
Integration tests for Controller API endpoints.
"""
from unittest import mock
import pytest
from fastapi.testclient import TestClient
from controller import __version__
class TestHealthEndpoint:
"""Tests for /api/health endpoint."""
def test_health_returns_200(self, test_client):
"""Health endpoint should return 200 OK."""
response = test_client.get("/api/health")
assert response.status_code == 200
def test_health_returns_expected_fields(self, test_client):
"""Health endpoint should return expected fields."""
response = test_client.get("/api/health")
data = response.json()
assert data["status"] == "healthy"
assert data["version"] == __version__
assert data["docker_mode"] is False
assert "drive_mounted" in data
assert isinstance(data["drive_mounted"], bool)
def test_health_returns_docker_mode_true(self, test_client_docker_mode):
"""Health endpoint should return docker_mode=true when in Docker."""
response = test_client_docker_mode.get("/api/health")
data = response.json()
assert data["docker_mode"] is True
class TestDashboardEndpoint:
"""Tests for / dashboard endpoint (UI)."""
def test_dashboard_returns_200(self, test_client):
"""Dashboard endpoint should return 200 OK."""
response = test_client.get("/")
assert response.status_code == 200
def test_dashboard_returns_html(self, test_client):
"""Dashboard endpoint should return HTML."""
response = test_client.get("/")
assert "text/html" in response.headers["content-type"]
def test_dashboard_contains_title(self, test_client):
"""Dashboard should contain title."""
response = test_client.get("/")
assert b"WROLPi Controller" in response.content
def test_dashboard_contains_version(self, test_client):
"""Dashboard should contain version."""
response = test_client.get("/")
assert __version__.encode() in response.content
def test_dashboard_contains_status_cards(self, test_client):
"""Dashboard should contain status cards."""
response = test_client.get("/")
content = response.text
assert "CPU" in content
assert "Memory" in content
assert "Load" in content
assert "Storage" in content
def test_dashboard_contains_disks_section(self, test_client):
"""Dashboard should contain disks section."""
response = test_client.get("/")
content = response.text
assert "Disks" in content
assert 'id="disks"' in content
def test_dashboard_contains_services_section(self, test_client):
"""Dashboard should contain services section."""
response = test_client.get("/")
content = response.text
assert "Services" in content
assert 'id="services"' in content
def test_dashboard_contains_system_actions(self, test_client):
"""Dashboard should contain system actions section."""
response = test_client.get("/")
content = response.text
assert "System Actions" in content
assert "Restart All Services" in content
def test_dashboard_shows_docker_banner_when_dockerized(self, test_client_docker_mode):
"""Dashboard should show Docker mode banner when in Docker."""
response = test_client_docker_mode.get("/")
content = response.text
assert "Running in Docker mode" in content
def test_dashboard_no_docker_banner_when_not_dockerized(self, test_client):
"""Dashboard should not show Docker mode banner when not in Docker."""
response = test_client.get("/")
content = response.text
assert "Running in Docker mode" not in content
def test_dashboard_shows_boot_column_when_not_dockerized(self, test_client):
"""Dashboard should show Boot column in services table when not in Docker."""
response = test_client.get("/")
content = response.text
assert "<th>Boot</th>" in content
# Should have toggle switches
assert 'class="toggle"' in content
def test_dashboard_no_boot_column_when_dockerized(self, test_client_docker_mode):
"""Dashboard should not show Boot column in services table when in Docker."""
response = test_client_docker_mode.get("/")
content = response.text
assert "<th>Boot</th>" not in content
# The table body (rendered server-side) should not have boot toggles
# Note: JavaScript template literals may contain 'id="boot-' for dynamic updates,
# but the actual rendered <tbody> should not have boot toggles in Docker mode
import re
tbody_match = re.search(r'<tbody>(.*?)</tbody>', content, re.DOTALL)
if tbody_match:
tbody_content = tbody_match.group(1)
assert 'id="boot-' not in tbody_content
def test_dashboard_has_upgrade_banner_element(self, test_client):
"""Dashboard should have upgrade banner element for upgrade mode."""
response = test_client.get("/")
content = response.text
assert 'id="upgrade-banner"' in content
assert "Upgrade in Progress" in content
assert "Do not power off" in content
def test_dashboard_has_mount_modal(self, test_client):
"""Dashboard should have mount modal for disk mounting."""
response = test_client.get("/")
content = response.text
assert 'id="mount-modal"' in content
assert 'id="mount-point-input"' in content
assert 'id="mount-persist-checkbox"' in content
assert "Persistent (survive reboots)" in content
def test_dashboard_has_persist_column_in_disks_js(self, test_client):
"""Dashboard JavaScript should include Persist column in disk table."""
response = test_client.get("/")
content = response.text
# The loadDisks() function should create a table with Persist column
assert "<th>Persist</th>" in content
# Should have togglePersist function
assert "function togglePersist" in content
# Should fetch fstab entries
assert "api/disks/fstab" in content
class TestOpenAPIEndpoint:
"""Tests for OpenAPI documentation endpoint."""
def test_openapi_returns_200(self, test_client):
"""OpenAPI endpoint should return 200 OK."""
response = test_client.get("/openapi.json")
assert response.status_code == 200
def test_openapi_has_title(self, test_client):
"""OpenAPI schema should have correct title."""
response = test_client.get("/openapi.json")
data = response.json()
assert data["info"]["title"] == "WROLPi Controller"
def test_openapi_has_version(self, test_client):
"""OpenAPI schema should have version."""
response = test_client.get("/openapi.json")
data = response.json()
assert data["info"]["version"] == __version__
def test_openapi_has_api_paths(self, test_client):
"""OpenAPI schema should have API paths defined."""
response = test_client.get("/openapi.json")
data = response.json()
assert "/api/health" in data["paths"]
class TestDocsEndpoint:
"""Tests for Swagger UI documentation endpoint."""
def test_docs_returns_200(self, test_client):
"""Docs endpoint should return 200 OK."""
response = test_client.get("/docs")
assert response.status_code == 200
def test_docs_returns_html(self, test_client):
"""Docs endpoint should return HTML."""
response = test_client.get("/docs")
assert "text/html" in response.headers["content-type"]
class TestDriveMountedStatus:
"""Tests for drive mounted status in responses."""
@pytest.mark.parametrize("endpoint,mounted,expected", [
("/api/health", True, True),
("/api/health", False, False),
])
def test_drive_mounted_status(self, reset_runtime_config, mock_docker_mode, endpoint, mounted, expected):
"""Endpoints should return correct drive_mounted status."""
from controller.main import app
with mock.patch(
"controller.main.is_primary_drive_mounted", return_value=mounted
):
with TestClient(app) as client:
response = client.get(endpoint)
data = response.json()
assert data["drive_mounted"] is expected
def test_dashboard_shows_drive_not_mounted_banner(self, reset_runtime_config, mock_docker_mode):
"""Dashboard should show banner when drive is not mounted."""
from controller.main import app
with mock.patch(
"controller.main.is_primary_drive_mounted", return_value=False
):
with TestClient(app) as client:
response = client.get("/")
assert "Primary drive not mounted" in response.text

View file

@ -0,0 +1,268 @@
"""
Unit tests for controller.lib.config module.
"""
import copy
import pytest
import yaml
from controller.lib.config import (
get_config,
get_config_value,
is_docker_mode,
is_primary_drive_mounted,
reload_config_from_drive,
save_config,
update_config,
_deep_merge,
_get_config_diff,
)
class TestGetConfig:
"""Tests for get_config function."""
def test_get_config_returns_dict(self, reset_runtime_config):
"""get_config should return a dictionary."""
config = get_config()
assert isinstance(config, dict)
def test_get_config_has_default_keys(self, reset_runtime_config):
"""get_config should have all default keys."""
config = get_config()
assert "port" in config
assert "media_directory" in config
assert "drives" in config
assert "managed_services" in config
assert "hotspot" in config
assert "throttle" in config
def test_get_config_default_port(self, reset_runtime_config):
"""Default port should be 8087."""
config = get_config()
assert config["port"] == 8087
class TestGetConfigValue:
"""Tests for get_config_value function."""
@pytest.mark.parametrize("key,expected", [
("port", 8087),
("media_directory", "/media/wrolpi"),
("drives.auto_mount", True),
("hotspot.ssid", "WROLPi"),
("throttle.default_governor", "ondemand"),
])
def test_get_value(self, reset_runtime_config, key, expected):
"""Should get values using dot notation for nested keys."""
assert get_config_value(key) == expected
@pytest.mark.parametrize("key,default,expected", [
("nonexistent", None, None),
("nonexistent", "fallback", "fallback"),
("drives.nonexistent", 42, 42),
("a.b.c.d.e", "default", "default"),
])
def test_get_missing_value_returns_default(self, reset_runtime_config, key, default, expected):
"""Should return default for missing keys."""
assert get_config_value(key, default) == expected
class TestIsDockerMode:
"""Tests for is_docker_mode function."""
def test_docker_mode_false_when_not_set(self, mock_docker_mode):
"""Docker mode should be False when env var not set."""
assert is_docker_mode() is False
def test_docker_mode_true_when_set(self, mock_docker_mode_enabled):
"""Docker mode should be True when DOCKERIZED=true."""
assert is_docker_mode() is True
class TestIsPrimaryDriveMounted:
"""Tests for is_primary_drive_mounted function."""
def test_drive_mounted_when_config_dir_exists(self, test_config_directory):
"""Should return True when /media/wrolpi/config exists."""
# We need to mock the path check to use our test directory
from unittest import mock
with mock.patch(
"controller.lib.config.Path"
) as mock_path:
mock_path.return_value.exists.return_value = True
# Re-import to get fresh function
# Actually test with real path
# The real test - check that the function works correctly
assert test_config_directory.exists()
def test_drive_not_mounted_when_config_dir_missing(self, test_directory):
"""Should return False when config dir doesn't exist."""
from unittest import mock
with mock.patch(
"controller.lib.config.Path"
) as mock_path:
mock_instance = mock.MagicMock()
mock_instance.exists.return_value = False
mock_path.return_value = mock_instance
assert is_primary_drive_mounted() is False
class TestReloadConfigFromDrive:
"""Tests for reload_config_from_drive function."""
def test_reload_returns_false_when_no_config_file(
self, reset_runtime_config, mock_config_path
):
"""Should return False when controller.yaml doesn't exist."""
result = reload_config_from_drive()
assert result is False
def test_reload_returns_true_when_config_exists(
self, reset_runtime_config, mock_config_path
):
"""Should return True and load config when file exists."""
# Write a test config
mock_config_path.write_text(yaml.dump({"port": 9999}))
result = reload_config_from_drive()
assert result is True
assert get_config_value("port") == 9999
def test_reload_merges_with_defaults(
self, reset_runtime_config, mock_config_path
):
"""Config from file should be merged with defaults."""
# Write partial config - only override port
mock_config_path.write_text(yaml.dump({"port": 8888}))
reload_config_from_drive()
# Port should be overridden
assert get_config_value("port") == 8888
# Other defaults should still exist
assert get_config_value("media_directory") == "/media/wrolpi"
assert get_config_value("drives.auto_mount") is True
def test_reload_handles_invalid_yaml(
self, reset_runtime_config, mock_config_path
):
"""Should return False and keep defaults for invalid YAML."""
mock_config_path.write_text("invalid: yaml: content: [}")
result = reload_config_from_drive()
assert result is False
# Should still have defaults
assert get_config_value("port") == 8087
def test_reload_handles_empty_file(
self, reset_runtime_config, mock_config_path
):
"""Should handle empty config file gracefully."""
mock_config_path.write_text("")
result = reload_config_from_drive()
# Empty file is valid YAML (returns None), merged with defaults
# File exists and was processed, so returns True
assert result is True
# Config should still have all defaults
assert get_config_value("port") == 8087
class TestSaveConfig:
"""Tests for save_config function."""
def test_save_config_raises_when_drive_not_mounted(
self, reset_runtime_config, mock_drive_not_mounted
):
"""Should raise RuntimeError when drive not mounted."""
with pytest.raises(RuntimeError, match="primary drive not mounted"):
save_config()
def test_save_config_creates_file(
self, reset_runtime_config, mock_config_path, mock_drive_mounted
):
"""Should create config file when saving."""
update_config("port", 9999)
save_config()
assert mock_config_path.exists()
saved = yaml.safe_load(mock_config_path.read_text())
assert saved["port"] == 9999
def test_save_config_only_saves_diff(
self, reset_runtime_config, mock_config_path, mock_drive_mounted
):
"""Should only save values that differ from defaults."""
# Only change port
update_config("port", 9999)
save_config()
saved = yaml.safe_load(mock_config_path.read_text())
# Only port should be in the file
assert saved == {"port": 9999}
class TestUpdateConfig:
"""Tests for update_config function."""
def test_update_simple_value(self, reset_runtime_config):
"""Should update simple top-level values."""
update_config("port", 9999)
assert get_config_value("port") == 9999
def test_update_nested_value(self, reset_runtime_config):
"""Should update nested values using dot notation."""
update_config("hotspot.ssid", "MyNetwork")
assert get_config_value("hotspot.ssid") == "MyNetwork"
def test_update_creates_nested_structure(self, reset_runtime_config):
"""Should create nested structure if it doesn't exist."""
update_config("new.nested.key", "value")
assert get_config_value("new.nested.key") == "value"
class TestDeepMerge:
"""Tests for _deep_merge helper function."""
@pytest.mark.parametrize("base,override,expected", [
# Flat dict merge
({"a": 1, "b": 2}, {"b": 3, "c": 4}, {"a": 1, "b": 3, "c": 4}),
# Nested dict merge
({"a": {"x": 1, "y": 2}}, {"a": {"y": 3, "z": 4}}, {"a": {"x": 1, "y": 3, "z": 4}}),
# Override replaces non-dict
({"a": {"x": 1}}, {"a": "string"}, {"a": "string"}),
])
def test_merge_dicts(self, base, override, expected):
"""Should merge dictionaries correctly."""
result = _deep_merge(base, override)
assert result == expected
def test_merge_does_not_modify_original(self):
"""Should not modify original dictionaries."""
base = {"a": 1}
override = {"b": 2}
original_base = copy.deepcopy(base)
_deep_merge(base, override)
assert base == original_base
class TestGetConfigDiff:
"""Tests for _get_config_diff helper function."""
@pytest.mark.parametrize("config,default,expected", [
# Equal configs return empty
({"a": 1, "b": 2}, {"a": 1, "b": 2}, {}),
# Changed values only
({"a": 1, "b": 3}, {"a": 1, "b": 2}, {"b": 3}),
# New keys
({"a": 1, "b": 2, "c": 3}, {"a": 1, "b": 2}, {"c": 3}),
# Nested changes
({"a": {"x": 1, "y": 3}}, {"a": {"x": 1, "y": 2}}, {"a": {"y": 3}}),
])
def test_get_config_diff(self, config, default, expected):
"""Should return only values that differ from defaults."""
assert _get_config_diff(config, default) == expected

View file

@ -0,0 +1,130 @@
"""
Unit tests for controller.defaults module.
"""
import pytest
from controller.defaults import DEFAULT_CONFIG
class TestDefaultConfig:
"""Tests for DEFAULT_CONFIG structure."""
def test_default_config_is_dict(self):
"""DEFAULT_CONFIG should be a dictionary."""
assert isinstance(DEFAULT_CONFIG, dict)
def test_default_port(self):
"""Default port should be 8087."""
assert DEFAULT_CONFIG["port"] == 8087
def test_default_media_directory(self):
"""Default media directory should be /media/wrolpi."""
assert DEFAULT_CONFIG["media_directory"] == "/media/wrolpi"
class TestDrivesConfig:
"""Tests for drives configuration."""
def test_drives_config_exists(self):
"""Drives config should exist."""
assert "drives" in DEFAULT_CONFIG
assert isinstance(DEFAULT_CONFIG["drives"], dict)
@pytest.mark.parametrize("filesystem", ["ext4", "btrfs", "vfat", "exfat"])
def test_supported_filesystem(self, filesystem):
"""Should have expected filesystems in supported list."""
filesystems = DEFAULT_CONFIG["drives"]["supported_filesystems"]
assert isinstance(filesystems, list)
assert filesystem in filesystems
def test_auto_mount_default(self):
"""Auto mount should be True by default."""
assert DEFAULT_CONFIG["drives"]["auto_mount"] is True
def test_mounts_empty_by_default(self):
"""Mounts list should be empty by default."""
assert DEFAULT_CONFIG["drives"]["mounts"] == []
class TestManagedServicesConfig:
"""Tests for managed services configuration."""
def test_managed_services_exists(self):
"""Managed services config should exist."""
assert "managed_services" in DEFAULT_CONFIG
assert isinstance(DEFAULT_CONFIG["managed_services"], list)
def test_managed_services_count(self):
"""Should have expected number of managed services."""
services = DEFAULT_CONFIG["managed_services"]
assert len(services) == 10
@pytest.mark.parametrize("name,expected", [
("wrolpi-api",
{"systemd_name": "wrolpi-api", "port": 8081, "viewable": True, "description": "Python API (Sanic)"}),
("wrolpi-app", {"port": 3000, "viewable": False}),
("nginx", {"port": 80}),
("postgresql", {"port": 5432, "viewable": False}),
("wrolpi-upgrade", {"port": None, "viewable": False, "show_only_when_running": True}),
])
def test_service_config(self, name, expected):
"""Should have service configured with expected values."""
services = DEFAULT_CONFIG["managed_services"]
service = next((s for s in services if s["name"] == name), None)
assert service is not None, f"Service {name} not found"
for key, value in expected.items():
assert service[key] == value, f"Service {name} has {key}={service.get(key)}, expected {value}"
@pytest.mark.parametrize("name,use_https", [
("wrolpi-help", True),
("wrolpi-kiwix", True),
("apache2", True),
("wrolpi-api", False),
("wrolpi-app", False),
("postgresql", False),
])
def test_service_use_https(self, name, use_https):
"""HTTPS services should have use_https=True, others should have False."""
services = DEFAULT_CONFIG["managed_services"]
service = next((s for s in services if s["name"] == name), None)
assert service is not None, f"Service {name} not found"
assert service.get("use_https", False) == use_https, \
f"Service {name} should have use_https={use_https}"
def test_all_services_have_required_fields(self):
"""All services should have required fields."""
required_fields = ["name", "systemd_name", "port", "viewable", "use_https", "description"]
for service in DEFAULT_CONFIG["managed_services"]:
for field in required_fields:
assert field in service, f"Service {service.get('name')} missing {field}"
class TestHotspotConfig:
"""Tests for hotspot configuration."""
def test_hotspot_config_exists(self):
"""Hotspot config should exist."""
assert "hotspot" in DEFAULT_CONFIG
assert isinstance(DEFAULT_CONFIG["hotspot"], dict)
@pytest.mark.parametrize("key,expected", [
("device", "wlan0"),
("ssid", "WROLPi"),
("password", "wrolpi hotspot"),
])
def test_hotspot_defaults(self, key, expected):
"""Hotspot should have expected default values."""
assert DEFAULT_CONFIG["hotspot"][key] == expected
class TestThrottleConfig:
"""Tests for throttle configuration."""
def test_throttle_config_exists(self):
"""Throttle config should exist."""
assert "throttle" in DEFAULT_CONFIG
assert isinstance(DEFAULT_CONFIG["throttle"], dict)
def test_default_governor(self):
"""Default governor should be ondemand."""
assert DEFAULT_CONFIG["throttle"]["default_governor"] == "ondemand"

View file

@ -0,0 +1,285 @@
"""
Unit tests for controller.lib.disks module.
"""
import json
from unittest import mock
import pytest
from controller.lib.disks import (
BlockDevice,
get_block_devices,
get_uuid,
validate_mount_point,
mount_drive,
unmount_drive,
is_mount_busy,
get_mounts,
)
class TestBlockDevice:
"""Tests for BlockDevice dataclass."""
def test_create_block_device(self):
"""Should create a BlockDevice instance."""
device = BlockDevice(
name="sda1",
path="/dev/sda1",
size="100G",
fstype="ext4",
mountpoint="/media/data",
label="DATA",
uuid="1234-5678",
model="Samsung SSD",
)
assert device.name == "sda1"
assert device.path == "/dev/sda1"
assert device.is_wrolpi_drive is False
def test_block_device_defaults(self):
"""Should have default values."""
device = BlockDevice(
name="sda1",
path="/dev/sda1",
size="100G",
fstype=None,
mountpoint=None,
label=None,
uuid=None,
model=None,
)
assert device.is_wrolpi_drive is False
class TestGetBlockDevices:
"""Tests for get_block_devices function."""
def test_returns_empty_in_docker_mode(self):
"""Should return empty list in Docker mode."""
with mock.patch("controller.lib.disks.is_docker_mode", return_value=True):
result = get_block_devices()
assert result == []
def test_returns_devices_from_lsblk(self):
"""Should parse lsblk output and return devices."""
lsblk_output = json.dumps({
"blockdevices": [
{
"name": "sda",
"path": "/dev/sda",
"size": "500G",
"type": "disk",
"model": "Samsung SSD",
"children": [
{
"name": "sda1",
"path": "/dev/sda1",
"size": "500G",
"type": "part",
"fstype": "ext4",
"mountpoint": "/media/data",
"label": "DATA",
"uuid": "1234-5678",
}
]
}
]
})
with mock.patch("controller.lib.disks.is_docker_mode", return_value=False):
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout=lsblk_output)
result = get_block_devices()
assert len(result) == 1
assert result[0].name == "sda1"
assert result[0].fstype == "ext4"
assert result[0].model == "Samsung SSD"
def test_filters_unsupported_filesystems(self):
"""Should only return devices with supported filesystems."""
lsblk_output = json.dumps({
"blockdevices": [
{
"name": "sda",
"type": "disk",
"children": [
{"name": "sda1", "path": "/dev/sda1", "size": "100G", "type": "part", "fstype": "ext4"},
{"name": "sda2", "path": "/dev/sda2", "size": "100G", "type": "part", "fstype": "ntfs"},
]
}
]
})
with mock.patch("controller.lib.disks.is_docker_mode", return_value=False):
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout=lsblk_output)
result = get_block_devices()
assert len(result) == 1
assert result[0].name == "sda1"
def test_handles_lsblk_failure(self):
"""Should return empty list on lsblk failure."""
with mock.patch("controller.lib.disks.is_docker_mode", return_value=False):
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=1, stdout="", stderr="error")
result = get_block_devices()
assert result == []
class TestGetUuid:
"""Tests for get_uuid function."""
def test_returns_uuid_from_blkid(self):
"""Should return UUID from blkid output."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="1234-5678-90ab\n")
result = get_uuid("/dev/sda1")
assert result == "1234-5678-90ab"
def test_returns_none_on_failure(self):
"""Should return None if blkid fails."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=1, stdout="")
result = get_uuid("/dev/sda1")
assert result is None
class TestValidateMountPoint:
"""Tests for validate_mount_point function."""
def test_accepts_media_path(self):
"""Should accept paths under /media."""
validate_mount_point("/media/wrolpi")
validate_mount_point("/media/usb")
validate_mount_point("/media/wrolpi/data")
def test_rejects_non_media_paths(self):
"""Should reject paths not under /media."""
with pytest.raises(ValueError, match="must be under /media"):
validate_mount_point("/mnt/data")
with pytest.raises(ValueError, match="must be under /media"):
validate_mount_point("/home/user")
with pytest.raises(ValueError, match="must be under /media"):
validate_mount_point("/")
def test_rejects_path_traversal(self):
"""Should reject path traversal attempts."""
with pytest.raises(ValueError):
validate_mount_point("/media/../etc")
class TestMountDrive:
"""Tests for mount_drive function."""
def test_returns_error_in_docker_mode(self):
"""Should return error in Docker mode."""
with mock.patch("controller.lib.disks.is_docker_mode", return_value=True):
result = mount_drive("/dev/sda1", "/media/test")
assert result["success"] is False
assert "Docker" in result["error"]
def test_validates_mount_point(self):
"""Should validate mount point."""
with mock.patch("controller.lib.disks.is_docker_mode", return_value=False):
result = mount_drive("/dev/sda1", "/mnt/test")
assert result["success"] is False
assert "must be under /media" in result["error"]
def test_mounts_drive_successfully(self):
"""Should mount drive successfully."""
with mock.patch("controller.lib.disks.is_docker_mode", return_value=False):
with mock.patch("pathlib.Path.mkdir"):
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
result = mount_drive("/dev/sda1", "/media/test")
assert result["success"] is True
assert result["mount_point"] == "/media/test"
def test_handles_mount_failure(self):
"""Should handle mount failure."""
with mock.patch("controller.lib.disks.is_docker_mode", return_value=False):
with mock.patch("pathlib.Path.mkdir"):
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=1, stdout="", stderr="mount failed")
result = mount_drive("/dev/sda1", "/media/test")
assert result["success"] is False
assert "mount failed" in result["error"]
class TestUnmountDrive:
"""Tests for unmount_drive function."""
def test_returns_error_in_docker_mode(self):
"""Should return error in Docker mode."""
with mock.patch("controller.lib.disks.is_docker_mode", return_value=True):
result = unmount_drive("/media/test")
assert result["success"] is False
assert "Docker" in result["error"]
def test_protects_primary_wrolpi_mount(self):
"""Should protect /media/wrolpi from accidental unmount."""
with mock.patch("controller.lib.disks.is_docker_mode", return_value=False):
with mock.patch("controller.lib.disks.get_config", return_value={"drives": {}}):
result = unmount_drive("/media/wrolpi")
assert result["success"] is False
assert "Cannot unmount /media/wrolpi" in result["error"]
def test_unmounts_successfully(self):
"""Should unmount successfully."""
with mock.patch("controller.lib.disks.is_docker_mode", return_value=False):
with mock.patch("controller.lib.disks.is_mount_busy", return_value=False):
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0)
result = unmount_drive("/media/test")
assert result["success"] is True
def test_checks_if_mount_busy(self):
"""Should check if mount is busy."""
with mock.patch("controller.lib.disks.is_docker_mode", return_value=False):
with mock.patch("controller.lib.disks.is_mount_busy", return_value=True):
result = unmount_drive("/media/test", lazy=False)
assert result["success"] is False
assert "busy" in result["error"]
class TestIsMountBusy:
"""Tests for is_mount_busy function."""
def test_returns_true_if_processes_using(self):
"""Should return True if processes are using mount."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="python /media/test/file.py")
result = is_mount_busy("/media/test")
# is_mount_busy returns truthy value (non-empty string) when busy
assert result
def test_returns_false_if_no_processes(self):
"""Should return False if no processes using mount."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=1, stdout="")
result = is_mount_busy("/media/test")
assert not result
class TestGetMounts:
"""Tests for get_mounts function."""
def test_returns_mounts_under_media(self):
"""Should return mounts under /media."""
findmnt_output = json.dumps({
"filesystems": [
{"target": "/", "source": "/dev/sda1", "fstype": "ext4", "options": "rw"},
{"target": "/media/wrolpi", "source": "/dev/sdb1", "fstype": "ext4", "options": "rw"},
{"target": "/media/usb", "source": "/dev/sdc1", "fstype": "vfat", "options": "rw"},
]
})
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout=findmnt_output)
result = get_mounts()
assert len(result) == 2
assert result[0]["mount_point"] == "/media/wrolpi"
assert result[1]["mount_point"] == "/media/usb"

View file

@ -0,0 +1,184 @@
"""
Integration tests for Controller disks API endpoints.
"""
from unittest import mock
class TestDisksListEndpoint:
"""Tests for /api/disks endpoint."""
def test_returns_501_in_docker_mode(self, test_client_docker_mode):
"""Should return 501 in Docker mode."""
response = test_client_docker_mode.get("/api/disks")
assert response.status_code == 501
def test_returns_list_in_native_mode(self, test_client):
"""Should return list of disks in native mode."""
with mock.patch("controller.api.disks.get_block_devices", return_value=[]):
response = test_client.get("/api/disks")
assert response.status_code == 200
assert isinstance(response.json(), list)
class TestMountsEndpoint:
"""Tests for /api/disks/mounts endpoint."""
def test_returns_501_in_docker_mode(self, test_client_docker_mode):
"""Should return 501 in Docker mode."""
response = test_client_docker_mode.get("/api/disks/mounts")
assert response.status_code == 501
def test_returns_mounts(self, test_client):
"""Should return current mounts."""
mock_mounts = [{"mount_point": "/media/wrolpi", "device": "/dev/sda1"}]
with mock.patch("controller.api.disks.get_mounts", return_value=mock_mounts):
response = test_client.get("/api/disks/mounts")
assert response.status_code == 200
assert response.json() == mock_mounts
class TestMountEndpoint:
"""Tests for /api/disks/mount endpoint."""
def test_returns_501_in_docker_mode(self, test_client_docker_mode):
"""Should return 501 in Docker mode."""
response = test_client_docker_mode.post(
"/api/disks/mount",
json={"device": "/dev/sda1", "mount_point": "/media/test"}
)
assert response.status_code == 501
def test_mounts_drive_successfully(self, test_client):
"""Should mount drive successfully."""
with mock.patch(
"controller.api.disks.mount_drive",
return_value={"success": True, "mount_point": "/media/test"}
):
response = test_client.post(
"/api/disks/mount",
json={"device": "/dev/sda1", "mount_point": "/media/test"}
)
assert response.status_code == 200
assert response.json()["success"] is True
def test_returns_500_on_failure(self, test_client):
"""Should return 500 on mount failure."""
with mock.patch(
"controller.api.disks.mount_drive",
return_value={"success": False, "error": "Mount failed"}
):
response = test_client.post(
"/api/disks/mount",
json={"device": "/dev/sda1", "mount_point": "/media/test"}
)
assert response.status_code == 500
class TestUnmountEndpoint:
"""Tests for /api/disks/unmount endpoint."""
def test_returns_501_in_docker_mode(self, test_client_docker_mode):
"""Should return 501 in Docker mode."""
response = test_client_docker_mode.post(
"/api/disks/unmount",
json={"mount_point": "/media/test"}
)
assert response.status_code == 501
def test_unmounts_drive_successfully(self, test_client):
"""Should unmount drive successfully."""
with mock.patch(
"controller.api.disks.unmount_drive",
return_value={"success": True, "mount_point": "/media/test"}
):
response = test_client.post(
"/api/disks/unmount",
json={"mount_point": "/media/test"}
)
assert response.status_code == 200
class TestFstabEndpoints:
"""Tests for /api/disks/fstab endpoints."""
def test_list_fstab_returns_501_in_docker(self, test_client_docker_mode):
"""Should return 501 in Docker mode."""
response = test_client_docker_mode.get("/api/disks/fstab")
assert response.status_code == 501
def test_list_fstab_returns_entries(self, test_client):
"""Should return fstab entries."""
mock_entries = [{"mount_point": "/media/wrolpi", "device": "UUID=1234"}]
with mock.patch("controller.api.disks.get_wrolpi_fstab_entries", return_value=mock_entries):
response = test_client.get("/api/disks/fstab")
assert response.status_code == 200
assert response.json() == mock_entries
def test_add_fstab_returns_501_in_docker(self, test_client_docker_mode):
"""Should return 501 in Docker mode."""
response = test_client_docker_mode.post(
"/api/disks/fstab",
json={"device": "/dev/sda1", "mount_point": "/media/test", "fstype": "ext4"}
)
assert response.status_code == 501
def test_add_fstab_entry(self, test_client):
"""Should add fstab entry."""
with mock.patch(
"controller.api.disks.add_fstab_entry",
return_value={"success": True, "mount_point": "/media/test"}
):
response = test_client.post(
"/api/disks/fstab",
json={"device": "/dev/sda1", "mount_point": "/media/test", "fstype": "ext4"}
)
assert response.status_code == 200
def test_delete_fstab_returns_501_in_docker(self, test_client_docker_mode):
"""Should return 501 in Docker mode."""
response = test_client_docker_mode.delete("/api/disks/fstab/media/test")
assert response.status_code == 501
class TestSmartEndpoints:
"""Tests for /api/disks/smart endpoints."""
def test_list_smart_returns_501_in_docker(self, test_client_docker_mode):
"""Should return 501 in Docker mode."""
response = test_client_docker_mode.get("/api/disks/smart")
assert response.status_code == 501
def test_list_smart_when_not_available(self, test_client):
"""Should return unavailable when SMART not available."""
with mock.patch("controller.api.disks.is_smart_available", return_value=False):
response = test_client.get("/api/disks/smart")
assert response.status_code == 200
data = response.json()
assert data["available"] is False
def test_list_smart_returns_drives(self, test_client):
"""Should return SMART data for all drives."""
mock_drives = [{"device": "sda", "assessment": "PASS"}]
with mock.patch("controller.api.disks.is_smart_available", return_value=True):
with mock.patch("controller.api.disks.get_all_smart_status", return_value=mock_drives):
response = test_client.get("/api/disks/smart")
assert response.status_code == 200
data = response.json()
assert data["available"] is True
assert len(data["drives"]) == 1
class TestOpenAPIIncludesDisksEndpoints:
"""Tests that OpenAPI documentation includes disks endpoints."""
def test_openapi_has_disks_paths(self, test_client):
"""OpenAPI schema should include disks paths."""
response = test_client.get("/openapi.json")
data = response.json()
assert "/api/disks" in data["paths"]
assert "/api/disks/mounts" in data["paths"]
assert "/api/disks/mount" in data["paths"]
assert "/api/disks/unmount" in data["paths"]
assert "/api/disks/fstab" in data["paths"]
assert "/api/disks/smart" in data["paths"]

View file

@ -0,0 +1,297 @@
"""
Unit tests for controller.lib.docker_services module.
"""
from unittest import mock
import pytest
from controller.lib.docker_services import (
can_manage_containers,
get_container_status,
get_all_containers_status,
start_container,
stop_container,
restart_container,
get_container_logs,
_get_container_name,
CONTAINER_PREFIX,
)
class TestCanManageContainers:
"""Tests for can_manage_containers function."""
def test_returns_false_when_not_docker_mode(self):
"""Should return False when not in Docker mode."""
with mock.patch("controller.lib.docker_services.is_docker_mode", return_value=False):
assert can_manage_containers() is False
def test_returns_false_when_docker_not_available(self):
"""Should return False when docker library not available."""
with mock.patch("controller.lib.docker_services.is_docker_mode", return_value=True):
with mock.patch("controller.lib.docker_services.DOCKER_AVAILABLE", False):
assert can_manage_containers() is False
def test_returns_false_when_socket_missing(self):
"""Should return False when Docker socket doesn't exist."""
with mock.patch("controller.lib.docker_services.is_docker_mode", return_value=True):
with mock.patch("controller.lib.docker_services.DOCKER_AVAILABLE", True):
with mock.patch("os.path.exists", return_value=False):
assert can_manage_containers() is False
def test_returns_true_when_all_conditions_met(self):
"""Should return True when Docker mode, library, and socket available."""
with mock.patch("controller.lib.docker_services.is_docker_mode", return_value=True):
with mock.patch("controller.lib.docker_services.DOCKER_AVAILABLE", True):
with mock.patch("os.path.exists", return_value=True):
assert can_manage_containers() is True
class TestGetContainerName:
"""Tests for _get_container_name function."""
def test_formats_container_name(self):
"""Should format container name with prefix and suffix."""
result = _get_container_name("api")
assert result == f"{CONTAINER_PREFIX}-api-1"
def test_uses_service_name(self):
"""Should use service name in container name."""
result = _get_container_name("web")
assert "web" in result
class TestGetContainerStatus:
"""Tests for get_container_status function."""
def test_returns_unavailable_when_cannot_manage(self):
"""Should return unavailable when can't manage containers."""
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=False):
result = get_container_status("api")
assert result["status"] == "unknown"
assert result["available"] is False
def test_returns_running_status(self):
"""Should return running status for running container."""
mock_container = mock.Mock()
mock_container.status = "running"
mock_container.attrs = {"NetworkSettings": {"Ports": {"8080/tcp": [{"HostPort": "8081"}]}}}
mock_client = mock.Mock()
mock_client.containers.get.return_value = mock_container
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = get_container_status("api")
assert result["status"] == "running"
assert result["available"] is True
def test_returns_stopped_for_exited_container(self):
"""Should return stopped for exited container."""
mock_container = mock.Mock()
mock_container.status = "exited"
mock_container.attrs = {"NetworkSettings": {"Ports": {}}}
mock_client = mock.Mock()
mock_client.containers.get.return_value = mock_container
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = get_container_status("api")
assert result["status"] == "stopped"
class TestGetAllContainersStatus:
"""Tests for get_all_containers_status function."""
def test_returns_empty_when_cannot_manage(self):
"""Should return empty list when can't manage containers."""
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=False):
result = get_all_containers_status()
assert result == []
def test_returns_container_list(self):
"""Should return list of container statuses."""
mock_container = mock.Mock()
mock_container.name = f"{CONTAINER_PREFIX}-api-1"
mock_container.status = "running"
mock_container.attrs = {"NetworkSettings": {"Ports": {}}}
mock_client = mock.Mock()
mock_client.containers.list.return_value = [mock_container]
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = get_all_containers_status()
assert len(result) == 1
assert result[0]["name"] == "api"
def test_db_container_not_viewable(self):
"""Database container should not be viewable (port 5432 is not HTTP)."""
mock_container = mock.Mock()
mock_container.name = f"{CONTAINER_PREFIX}-db-1"
mock_container.status = "running"
mock_container.attrs = {"NetworkSettings": {"Ports": {"5432/tcp": [{"HostPort": "5432"}]}}}
mock_client = mock.Mock()
mock_client.containers.list.return_value = [mock_container]
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = get_all_containers_status()
assert len(result) == 1
assert result[0]["name"] == "db"
assert result[0]["viewable"] is False
def test_api_container_is_viewable(self):
"""API container should be viewable (HTTP service)."""
mock_container = mock.Mock()
mock_container.name = f"{CONTAINER_PREFIX}-api-1"
mock_container.status = "running"
mock_container.attrs = {"NetworkSettings": {"Ports": {"8081/tcp": [{"HostPort": "8081"}]}}}
mock_client = mock.Mock()
mock_client.containers.list.return_value = [mock_container]
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = get_all_containers_status()
assert len(result) == 1
assert result[0]["name"] == "api"
assert result[0]["viewable"] is True
def test_https_container_uses_https(self):
"""Containers with _https suffix should have use_https=True."""
mock_container = mock.Mock()
mock_container.name = f"{CONTAINER_PREFIX}-help_https-1"
mock_container.status = "running"
mock_container.attrs = {"NetworkSettings": {"Ports": {"8086/tcp": [{"HostPort": "8086"}]}}}
mock_client = mock.Mock()
mock_client.containers.list.return_value = [mock_container]
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = get_all_containers_status()
assert len(result) == 1
assert result[0]["name"] == "help_https"
assert result[0]["use_https"] is True
def test_non_https_container_uses_http(self):
"""Containers without _https suffix should have use_https=False."""
mock_container = mock.Mock()
mock_container.name = f"{CONTAINER_PREFIX}-api-1"
mock_container.status = "running"
mock_container.attrs = {"NetworkSettings": {"Ports": {"8081/tcp": [{"HostPort": "8081"}]}}}
mock_client = mock.Mock()
mock_client.containers.list.return_value = [mock_container]
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = get_all_containers_status()
assert len(result) == 1
assert result[0]["name"] == "api"
assert result[0].get("use_https", False) is False
def test_web_container_uses_https(self):
"""Web container (nginx proxy) should have use_https=True."""
mock_container = mock.Mock()
mock_container.name = f"{CONTAINER_PREFIX}-web-1"
mock_container.status = "running"
mock_container.attrs = {"NetworkSettings": {"Ports": {"443/tcp": [{"HostPort": "8443"}]}}}
mock_client = mock.Mock()
mock_client.containers.list.return_value = [mock_container]
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = get_all_containers_status()
assert len(result) == 1
assert result[0]["name"] == "web"
assert result[0]["use_https"] is True
def test_filters_by_prefix(self):
"""Should only return containers with correct prefix."""
mock_container1 = mock.Mock()
mock_container1.name = f"{CONTAINER_PREFIX}-api-1"
mock_container1.status = "running"
mock_container1.attrs = {"NetworkSettings": {"Ports": {}}}
mock_container2 = mock.Mock()
mock_container2.name = "other-container"
mock_container2.status = "running"
mock_container2.attrs = {"NetworkSettings": {"Ports": {}}}
mock_client = mock.Mock()
mock_client.containers.list.return_value = [mock_container1, mock_container2]
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = get_all_containers_status()
assert len(result) == 1
assert result[0]["name"] == "api"
class TestContainerActions:
"""Tests for container action functions."""
@pytest.mark.parametrize("func", [
start_container,
stop_container,
restart_container,
])
def test_returns_error_when_cannot_manage(self, func):
"""Should return error when can't manage containers."""
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=False):
result = func("api")
assert result["success"] is False
@pytest.mark.parametrize("func,method_name,expected_action,call_kwargs", [
(start_container, "start", "start", {}),
(stop_container, "stop", "stop", {"timeout": 30}),
(restart_container, "restart", "restart", {"timeout": 30}),
])
def test_performs_container_action(self, func, method_name, expected_action, call_kwargs):
"""Should perform the correct container action."""
mock_container = mock.Mock()
mock_client = mock.Mock()
mock_client.containers.get.return_value = mock_container
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = func("api")
method = getattr(mock_container, method_name)
if call_kwargs:
method.assert_called_once_with(**call_kwargs)
else:
method.assert_called_once()
assert result["success"] is True
assert result["action"] == expected_action
class TestGetContainerLogs:
"""Tests for get_container_logs function."""
def test_returns_error_when_cannot_manage(self):
"""Should return error when can't manage containers."""
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=False):
result = get_container_logs("api")
assert "error" in result
def test_returns_logs(self):
"""Should return container logs."""
mock_container = mock.Mock()
mock_container.logs.return_value = b"log line 1\nlog line 2"
mock_client = mock.Mock()
mock_client.containers.get.return_value = mock_container
with mock.patch("controller.lib.docker_services.can_manage_containers", return_value=True):
with mock.patch("controller.lib.docker_services._get_client", return_value=mock_client):
result = get_container_logs("api", lines=50)
mock_container.logs.assert_called_once_with(tail=50)
assert result["logs"] == "log line 1\nlog line 2"
assert result["service"] == "api"
assert result["lines"] == 50

View file

@ -0,0 +1,154 @@
"""
Unit tests for controller.lib.fstab module.
"""
from unittest import mock
from controller.lib.fstab import (
parse_fstab,
add_fstab_entry,
remove_fstab_entry,
get_wrolpi_fstab_entries,
)
class TestParseFstab:
"""Tests for parse_fstab function."""
def test_parses_mount_entries(self):
"""Should parse mount entries from fstab."""
fstab_content = """\
# /etc/fstab
UUID=1234-5678 / ext4 defaults 0 1
UUID=abcd-efgh /media/wrolpi ext4 defaults,nofail 0 2
"""
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
result = parse_fstab()
mounts = [e for e in result if e["type"] == "mount"]
assert len(mounts) == 2
assert mounts[0]["device"] == "UUID=1234-5678"
assert mounts[0]["mount_point"] == "/"
assert mounts[1]["mount_point"] == "/media/wrolpi"
def test_preserves_comments(self):
"""Should preserve comments and blank lines."""
fstab_content = """\
# This is a comment
UUID=1234 / ext4 defaults 0 1
"""
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
result = parse_fstab()
comments = [e for e in result if e["type"] == "comment"]
assert len(comments) == 2
def test_handles_minimal_entries(self):
"""Should handle entries with minimal fields."""
fstab_content = "UUID=1234 /media/data ext4 defaults\n"
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
result = parse_fstab()
mount = [e for e in result if e["type"] == "mount"][0]
assert mount["dump"] == "0"
assert mount["pass"] == "0"
class TestAddFstabEntry:
"""Tests for add_fstab_entry function."""
def test_blocked_in_wrol_mode(self):
"""Should be blocked in WROL mode."""
with mock.patch("controller.lib.fstab.require_normal_mode", side_effect=PermissionError("WROL")):
result = add_fstab_entry("/dev/sda1", "/media/test", "ext4")
assert result["success"] is False
assert "WROL" in result["error"]
def test_validates_mount_point(self):
"""Should validate mount point."""
with mock.patch("controller.lib.fstab.require_normal_mode"):
result = add_fstab_entry("/dev/sda1", "/mnt/test", "ext4")
assert result["success"] is False
assert "must be under /media" in result["error"]
def test_converts_device_to_uuid(self):
"""Should convert device path to UUID."""
fstab_content = "# empty\n"
with mock.patch("controller.lib.fstab.require_normal_mode"):
with mock.patch("controller.lib.fstab.get_uuid", return_value="1234-5678"):
with mock.patch("controller.lib.fstab.backup_fstab"):
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
with mock.patch("subprocess.run"):
result = add_fstab_entry("/dev/sda1", "/media/test", "ext4")
assert result["success"] is True
assert result["device"] == "UUID=1234-5678"
def test_adds_entry_to_fstab(self):
"""Should add entry to fstab."""
fstab_content = "# existing\n"
written_content = []
def mock_write(content):
written_content.append(content)
mock_file = mock.mock_open(read_data=fstab_content)
mock_file().write = mock_write
with mock.patch("controller.lib.fstab.require_normal_mode"):
with mock.patch("controller.lib.fstab.get_uuid", return_value=None):
with mock.patch("controller.lib.fstab.backup_fstab"):
with mock.patch("builtins.open", mock_file):
with mock.patch("subprocess.run"):
result = add_fstab_entry("/dev/sda1", "/media/test", "ext4")
assert result["success"] is True
# Check that something was written
assert len(written_content) > 0
class TestRemoveFstabEntry:
"""Tests for remove_fstab_entry function."""
def test_blocked_in_wrol_mode(self):
"""Should be blocked in WROL mode."""
with mock.patch("controller.lib.fstab.require_normal_mode", side_effect=PermissionError("WROL")):
result = remove_fstab_entry("/media/test")
assert result["success"] is False
def test_validates_mount_point(self):
"""Should validate mount point."""
with mock.patch("controller.lib.fstab.require_normal_mode"):
result = remove_fstab_entry("/mnt/test")
assert result["success"] is False
def test_returns_error_if_not_found(self):
"""Should return error if entry not found."""
fstab_content = "UUID=1234 / ext4 defaults 0 1\n"
with mock.patch("controller.lib.fstab.require_normal_mode"):
with mock.patch("controller.lib.fstab.backup_fstab"):
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
result = remove_fstab_entry("/media/nonexistent")
assert result["success"] is False
assert "No entry found" in result["error"]
class TestGetWrolpiFstabEntries:
"""Tests for get_wrolpi_fstab_entries function."""
def test_returns_wrolpi_entries(self):
"""Should return entries for /media/wrolpi."""
fstab_content = """\
UUID=1234 / ext4 defaults 0 1
UUID=5678 /media/wrolpi ext4 defaults 0 2
UUID=9abc /media/wrolpi/data ext4 defaults 0 2
UUID=defg /media/usb vfat defaults 0 0
"""
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
result = get_wrolpi_fstab_entries()
assert len(result) == 2
assert result[0]["mount_point"] == "/media/wrolpi"
assert result[1]["mount_point"] == "/media/wrolpi/data"
def test_returns_empty_if_no_wrolpi_entries(self):
"""Should return empty list if no WROLPi entries."""
fstab_content = "UUID=1234 / ext4 defaults 0 1\n"
with mock.patch("builtins.open", mock.mock_open(read_data=fstab_content)):
result = get_wrolpi_fstab_entries()
assert result == []

View file

@ -0,0 +1,50 @@
"""
Unit tests for controller.api.schemas module.
"""
import pytest
from pydantic import ValidationError
from controller.api.schemas import HealthResponse
class TestHealthResponse:
"""Tests for HealthResponse model."""
def test_valid_health_response(self):
"""Should create valid health response."""
response = HealthResponse(
status="healthy",
version="1.0.0",
docker_mode=False,
drive_mounted=True,
)
assert response.status == "healthy"
assert response.version == "1.0.0"
assert response.docker_mode is False
assert response.drive_mounted is True
def test_health_response_serialization(self):
"""Should serialize to dict correctly."""
response = HealthResponse(
status="healthy",
version="1.0.0",
docker_mode=True,
drive_mounted=False,
)
data = response.model_dump()
assert data == {
"status": "healthy",
"version": "1.0.0",
"docker_mode": True,
"drive_mounted": False,
}
def test_health_response_missing_field(self):
"""Should raise validation error for missing required field."""
with pytest.raises(ValidationError):
HealthResponse(
status="healthy",
version="1.0.0",
# missing docker_mode and drive_mounted
)

View file

@ -0,0 +1,179 @@
"""
Integration tests for Controller services API endpoints.
"""
from unittest import mock
import pytest
class TestServicesListEndpoint:
"""Tests for /api/services endpoint."""
def test_services_list_returns_200(self, test_client):
"""Services list endpoint should return 200 OK."""
response = test_client.get("/api/services")
assert response.status_code == 200
def test_services_list_returns_list_or_error(self, test_client):
"""Services list should return list of services or error object."""
response = test_client.get("/api/services")
data = response.json()
# Should be a list (when docker can manage) or error dict
assert isinstance(data, (list, dict))
class TestServicesListDockerMode:
"""Tests for /api/services in Docker mode."""
def test_returns_containers_when_docker_available(self, test_client_docker_mode):
"""Should return container status when Docker is available."""
mock_containers = [
{"name": "api", "status": "running", "container_name": "wrolpi-api-1"}
]
with mock.patch(
"controller.lib.docker_services.can_manage_containers",
return_value=True
):
with mock.patch(
"controller.lib.docker_services.get_all_containers_status",
return_value=mock_containers
):
# Also patch in the main module
with mock.patch(
"controller.main.can_manage_containers",
return_value=True
):
with mock.patch(
"controller.main.get_all_containers_status",
return_value=mock_containers
):
with mock.patch(
"controller.api.services.can_manage_containers",
return_value=True
):
with mock.patch(
"controller.api.services.get_all_containers_status",
return_value=mock_containers
):
response = test_client_docker_mode.get("/api/services")
assert response.status_code == 200
class TestServiceStatusEndpoint:
"""Tests for /api/services/{name} endpoint."""
def test_service_status_returns_status(self, test_client):
"""Should return service status."""
with mock.patch(
"controller.api.services.get_service_status",
return_value={
"name": "wrolpi-api",
"status": "running",
"systemd_name": "wrolpi-api.service",
}
):
response = test_client.get("/api/services/wrolpi-api")
assert response.status_code == 200
data = response.json()
assert data["name"] == "wrolpi-api"
def test_unknown_service_returns_404(self, test_client):
"""Should return 404 for unknown service."""
with mock.patch(
"controller.api.services.get_service_status",
return_value={"error": "Unknown service: nonexistent"}
):
response = test_client.get("/api/services/nonexistent")
assert response.status_code == 404
class TestServiceActionEndpoints:
"""Tests for service action endpoints (start, stop, restart, enable, disable)."""
@pytest.mark.parametrize("action,endpoint,mock_func", [
("start", "/api/services/wrolpi-api/start", "start_service"),
("stop", "/api/services/wrolpi-api/stop", "stop_service"),
("restart", "/api/services/wrolpi-api/restart", "restart_service"),
("enable", "/api/services/wrolpi-api/enable", "enable_service"),
("disable", "/api/services/wrolpi-api/disable", "disable_service"),
])
def test_action_success(self, test_client, action, endpoint, mock_func):
"""Should perform service action successfully."""
with mock.patch(
f"controller.api.services.{mock_func}",
return_value={"success": True, "service": "wrolpi-api", "action": action}
):
response = test_client.post(endpoint)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["action"] == action
def test_start_failure_returns_500(self, test_client):
"""Should return 500 on start failure."""
with mock.patch(
"controller.api.services.start_service",
return_value={"success": False, "error": "Failed to start"}
):
response = test_client.post("/api/services/wrolpi-api/start")
assert response.status_code == 500
@pytest.mark.parametrize("endpoint", [
"/api/services/wrolpi-api/enable",
"/api/services/wrolpi-api/disable",
])
def test_enable_disable_not_available_in_docker(self, test_client_docker_mode, endpoint):
"""Should return 501 in Docker mode for enable/disable."""
response = test_client_docker_mode.post(endpoint)
assert response.status_code == 501
class TestServiceLogsEndpoint:
"""Tests for /api/services/{name}/logs endpoint."""
def test_logs_returns_200(self, test_client):
"""Should return logs successfully."""
with mock.patch(
"controller.api.services.get_service_logs",
return_value={"service": "wrolpi-api", "lines": 100, "logs": "test logs"}
):
response = test_client.get("/api/services/wrolpi-api/logs")
assert response.status_code == 200
data = response.json()
assert "logs" in data
def test_logs_accepts_lines_parameter(self, test_client):
"""Should accept lines query parameter."""
with mock.patch(
"controller.api.services.get_service_logs",
return_value={"service": "wrolpi-api", "lines": 50, "logs": "logs"}
):
response = test_client.get("/api/services/wrolpi-api/logs?lines=50")
assert response.status_code == 200
def test_logs_accepts_since_parameter(self, test_client):
"""Should accept since query parameter."""
with mock.patch(
"controller.api.services.get_service_logs",
return_value={"service": "wrolpi-api", "lines": 100, "since": "1h", "logs": "logs"}
):
response = test_client.get("/api/services/wrolpi-api/logs?since=1h")
assert response.status_code == 200
class TestOpenAPIIncludesServicesEndpoints:
"""Tests that OpenAPI documentation includes services endpoints."""
def test_openapi_has_services_paths(self, test_client):
"""OpenAPI schema should include services paths."""
response = test_client.get("/openapi.json")
data = response.json()
assert "/api/services" in data["paths"]
assert "/api/services/{name}" in data["paths"]
assert "/api/services/{name}/start" in data["paths"]
assert "/api/services/{name}/stop" in data["paths"]
assert "/api/services/{name}/restart" in data["paths"]
assert "/api/services/{name}/enable" in data["paths"]
assert "/api/services/{name}/disable" in data["paths"]
assert "/api/services/{name}/logs" in data["paths"]

View file

@ -0,0 +1,169 @@
"""
Unit tests for controller.lib.smart module.
"""
from unittest import mock
from controller.lib.smart import (
is_smart_available,
get_all_smart_status,
_get_device_smart,
_get_temperature,
_get_attribute,
)
class TestIsSmartAvailable:
"""Tests for is_smart_available function."""
def test_returns_false_in_docker_mode(self):
"""Should return False in Docker mode."""
with mock.patch("controller.lib.smart.is_docker_mode", return_value=True):
assert is_smart_available() is False
def test_returns_false_when_pysmart_not_installed(self):
"""Should return False when pySMART not installed."""
with mock.patch("controller.lib.smart.is_docker_mode", return_value=False):
with mock.patch("controller.lib.smart.SMART_AVAILABLE", False):
assert is_smart_available() is False
def test_returns_true_when_available(self):
"""Should return True when pySMART available and not Docker."""
with mock.patch("controller.lib.smart.is_docker_mode", return_value=False):
with mock.patch("controller.lib.smart.SMART_AVAILABLE", True):
assert is_smart_available() is True
class TestGetAllSmartStatus:
"""Tests for get_all_smart_status function."""
def test_returns_empty_when_not_available(self):
"""Should return empty list when SMART not available."""
with mock.patch("controller.lib.smart.is_smart_available", return_value=False):
result = get_all_smart_status()
assert result == []
def test_returns_device_list(self):
"""Should return list of device statuses."""
mock_device = mock.Mock()
mock_device.name = "sda"
mock_device.model = "Samsung SSD"
mock_device.serial = "S1234"
mock_device.capacity = "500 GB"
mock_device.assessment = "PASS"
mock_device.smart_enabled = True
mock_device.attributes = []
mock_device_list = mock.Mock()
mock_device_list.devices = [mock_device]
with mock.patch("controller.lib.smart.is_smart_available", return_value=True):
with mock.patch("controller.lib.smart.DeviceList", return_value=mock_device_list):
result = get_all_smart_status()
assert len(result) == 1
assert result[0]["device"] == "sda"
assert result[0]["model"] == "Samsung SSD"
class TestGetDeviceSmart:
"""Tests for _get_device_smart function."""
def test_extracts_device_info(self):
"""Should extract device information."""
mock_device = mock.Mock()
mock_device.name = "sda"
mock_device.model = "Samsung SSD"
mock_device.serial = "S1234"
mock_device.capacity = "500 GB"
mock_device.assessment = "PASS"
mock_device.smart_enabled = True
mock_device.attributes = []
result = _get_device_smart(mock_device)
assert result["device"] == "sda"
assert result["path"] == "/dev/sda"
assert result["model"] == "Samsung SSD"
assert result["serial"] == "S1234"
assert result["capacity"] == "500 GB"
assert result["assessment"] == "PASS"
assert result["smart_enabled"] is True
class TestGetTemperature:
"""Tests for _get_temperature function."""
def test_returns_temperature_celsius(self):
"""Should return temperature from Temperature_Celsius attribute."""
mock_attr = mock.Mock()
mock_attr.name = "Temperature_Celsius"
mock_attr.raw = "35"
mock_device = mock.Mock()
mock_device.attributes = [mock_attr]
result = _get_temperature(mock_device)
assert result == 35
def test_falls_back_to_airflow_temperature(self):
"""Should fall back to Airflow_Temperature_Cel."""
mock_attr = mock.Mock()
mock_attr.name = "Airflow_Temperature_Cel"
mock_attr.raw = "40"
mock_device = mock.Mock()
mock_device.attributes = [mock_attr]
result = _get_temperature(mock_device)
assert result == 40
def test_returns_none_if_not_found(self):
"""Should return None if no temperature attribute."""
mock_device = mock.Mock()
mock_device.attributes = []
result = _get_temperature(mock_device)
assert result is None
class TestGetAttribute:
"""Tests for _get_attribute function."""
def test_returns_attribute_value(self):
"""Should return attribute raw value."""
mock_attr = mock.Mock()
mock_attr.name = "Power_On_Hours"
mock_attr.raw = "12345"
mock_device = mock.Mock()
mock_device.attributes = [mock_attr]
result = _get_attribute(mock_device, "Power_On_Hours")
assert result == 12345
def test_returns_none_for_missing_attribute(self):
"""Should return None if attribute not found."""
mock_device = mock.Mock()
mock_device.attributes = []
result = _get_attribute(mock_device, "Nonexistent")
assert result is None
def test_returns_none_if_no_attributes(self):
"""Should return None if device has no attributes."""
mock_device = mock.Mock()
mock_device.attributes = None
result = _get_attribute(mock_device, "Power_On_Hours")
assert result is None
def test_handles_invalid_raw_value(self):
"""Should handle non-numeric raw values."""
mock_attr = mock.Mock()
mock_attr.name = "Power_On_Hours"
mock_attr.raw = "invalid"
mock_device = mock.Mock()
mock_device.attributes = [mock_attr]
result = _get_attribute(mock_device, "Power_On_Hours")
assert result is None

View file

@ -0,0 +1,209 @@
"""
Unit tests for controller.lib.status module.
The status module now returns formats compatible with the main WROLPi API
so the React app can seamlessly use either source.
"""
from unittest import mock
from controller.lib.status import (
get_cpu_status,
get_drive_status,
get_load_status,
get_memory_status,
get_network_status,
get_power_status,
get_primary_drive_status,
)
class TestGetCpuStatus:
"""Tests for get_cpu_status function."""
def test_returns_dict(self):
"""Should return a dictionary."""
result = get_cpu_status()
assert isinstance(result, dict)
def test_has_required_keys(self):
"""Should have all required keys (main API format)."""
result = get_cpu_status()
assert "percent" in result
assert "cores" in result
assert "cur_frequency" in result
assert "max_frequency" in result
assert "min_frequency" in result
assert "temperature" in result
assert "high_temperature" in result
assert "critical_temperature" in result
def test_percent_is_numeric(self):
"""CPU percent should be a number."""
result = get_cpu_status()
assert isinstance(result["percent"], (int, float))
assert 0 <= result["percent"] <= 100
def test_cores_is_positive(self):
"""Core count should be a positive integer."""
result = get_cpu_status()
assert isinstance(result["cores"], int)
assert result["cores"] > 0
class TestGetMemoryStatus:
"""Tests for get_memory_status function."""
def test_returns_dict(self):
"""Should return a dictionary."""
result = get_memory_status()
assert isinstance(result, dict)
def test_has_required_keys(self):
"""Should have all required keys (main API format)."""
result = get_memory_status()
assert "total" in result
assert "used" in result
assert "free" in result
assert "cached" in result
def test_total_is_positive(self):
"""Memory total should be positive."""
result = get_memory_status()
assert result["total"] > 0
def test_bytes_are_non_negative(self):
"""Memory bytes should be non-negative."""
result = get_memory_status()
assert result["used"] >= 0
assert result["free"] >= 0
assert result["cached"] >= 0
class TestGetLoadStatus:
"""Tests for get_load_status function."""
def test_returns_dict(self):
"""Should return a dictionary."""
result = get_load_status()
assert isinstance(result, dict)
def test_has_required_keys(self):
"""Should have all required keys (main API format)."""
result = get_load_status()
assert "minute_1" in result
assert "minute_5" in result
assert "minute_15" in result
def test_values_are_strings(self):
"""Load values should be string representations of numbers (main API format)."""
result = get_load_status()
# Values are strings in the main API format
assert isinstance(result["minute_1"], str)
assert isinstance(result["minute_5"], str)
assert isinstance(result["minute_15"], str)
# But they should be parseable as floats
assert float(result["minute_1"]) >= 0
assert float(result["minute_5"]) >= 0
assert float(result["minute_15"]) >= 0
class TestGetDriveStatus:
"""Tests for get_drive_status function."""
def test_returns_list(self):
"""Should return a list."""
result = get_drive_status()
assert isinstance(result, list)
def test_returns_drives_or_empty(self):
"""Should return drives list (may be empty in Docker containers)."""
result = get_drive_status()
# The result should be a valid list
# May be empty in Docker containers where root fs is not visible
assert isinstance(result, list)
def test_drive_has_required_keys(self):
"""Each drive should have all required keys (main API format)."""
result = get_drive_status()
if result: # Only test if we have drives
drive = result[0]
assert "mount" in drive
assert "percent" in drive
assert "size" in drive
assert "used" in drive
class TestGetPrimaryDriveStatus:
"""Tests for get_primary_drive_status function."""
def test_returns_none_when_not_mounted(self):
"""Should return None when /media/wrolpi is not mounted."""
# In most test environments, /media/wrolpi won't be mounted
result = get_primary_drive_status()
# This could be None or a dict depending on the environment
assert result is None or isinstance(result, dict)
def test_returns_dict_when_mounted(self):
"""Should return drive info when /media/wrolpi is mounted."""
# Mock the get_drive_status to return a drive at /media/wrolpi
mock_drives = [
{
"mount": "/media/wrolpi",
"percent": 50,
"size": 1000000000,
"used": 500000000,
}
]
with mock.patch(
"controller.lib.status.get_drive_status", return_value=mock_drives
):
result = get_primary_drive_status()
assert result is not None
assert result["mount"] == "/media/wrolpi"
class TestGetNetworkStatus:
"""Tests for get_network_status function."""
def test_returns_dict(self):
"""Should return a dict (main API format: dict of interface name -> stats)."""
result = get_network_status()
assert isinstance(result, dict)
def test_excludes_loopback(self):
"""Should not include the loopback interface."""
result = get_network_status()
assert "lo" not in result
def test_interface_has_required_keys(self):
"""Each interface should have required keys (main API format)."""
result = get_network_status()
if result: # Only test if we have interfaces
iface = list(result.values())[0]
assert "name" in iface
assert "bytes_sent" in iface
assert "bytes_recv" in iface
assert "speed" in iface
assert "now" in iface
class TestGetPowerStatus:
"""Tests for get_power_status function."""
def test_returns_dict(self):
"""Should return a dictionary."""
result = get_power_status()
assert isinstance(result, dict)
def test_has_required_keys(self):
"""Should have all required keys (main API format)."""
result = get_power_status()
assert "under_voltage" in result
assert "over_current" in result
def test_values_are_bool(self):
"""All values should be boolean."""
result = get_power_status()
assert isinstance(result["under_voltage"], bool)
assert isinstance(result["over_current"], bool)

View file

@ -0,0 +1,191 @@
"""
Integration tests for Controller stats API endpoints.
The stats endpoints return system statistics (CPU, memory, etc).
Note: These are separate from the main WROLPi API's /api/status endpoint.
"""
class TestCpuEndpoint:
"""Tests for /api/stats/cpu endpoint."""
def test_cpu_returns_200(self, test_client):
"""CPU endpoint should return 200 OK."""
response = test_client.get("/api/stats/cpu")
assert response.status_code == 200
def test_cpu_returns_expected_fields(self, test_client):
"""CPU endpoint should return expected fields (main API format)."""
response = test_client.get("/api/stats/cpu")
data = response.json()
assert "percent" in data
assert "cores" in data
assert "cur_frequency" in data
assert "max_frequency" in data
assert "min_frequency" in data
assert "temperature" in data
assert "high_temperature" in data
assert "critical_temperature" in data
def test_cpu_percent_valid_range(self, test_client):
"""CPU percent should be between 0 and 100."""
response = test_client.get("/api/stats/cpu")
data = response.json()
assert 0 <= data["percent"] <= 100
class TestMemoryEndpoint:
"""Tests for /api/stats/memory endpoint."""
def test_memory_returns_200(self, test_client):
"""Memory endpoint should return 200 OK."""
response = test_client.get("/api/stats/memory")
assert response.status_code == 200
def test_memory_returns_expected_fields(self, test_client):
"""Memory endpoint should return expected fields (main API format)."""
response = test_client.get("/api/stats/memory")
data = response.json()
assert "total" in data
assert "used" in data
assert "free" in data
assert "cached" in data
def test_memory_bytes_positive(self, test_client):
"""Memory total should be positive."""
response = test_client.get("/api/stats/memory")
data = response.json()
assert data["total"] > 0
class TestLoadEndpoint:
"""Tests for /api/stats/load endpoint."""
def test_load_returns_200(self, test_client):
"""Load endpoint should return 200 OK."""
response = test_client.get("/api/stats/load")
assert response.status_code == 200
def test_load_returns_expected_fields(self, test_client):
"""Load endpoint should return expected fields (main API format)."""
response = test_client.get("/api/stats/load")
data = response.json()
assert "minute_1" in data
assert "minute_5" in data
assert "minute_15" in data
def test_load_values_non_negative(self, test_client):
"""Load values should be non-negative."""
response = test_client.get("/api/stats/load")
data = response.json()
# Values are strings in main API format
assert float(data["minute_1"]) >= 0
assert float(data["minute_5"]) >= 0
assert float(data["minute_15"]) >= 0
class TestPrimaryDriveEndpoint:
"""Tests for /api/stats/drives/primary endpoint."""
def test_primary_drive_returns_200(self, test_client):
"""Primary drive endpoint should return 200 OK."""
response = test_client.get("/api/stats/drives/primary")
assert response.status_code == 200
def test_primary_drive_returns_mounted_false_when_not_mounted(self, test_client):
"""Primary drive should return mounted=false when not present."""
response = test_client.get("/api/stats/drives/primary")
data = response.json()
# In test environment, /media/wrolpi likely not mounted
assert "mounted" in data
class TestNetworkEndpoint:
"""Tests for /api/stats/network endpoint."""
def test_network_returns_200(self, test_client):
"""Network endpoint should return 200 OK."""
response = test_client.get("/api/stats/network")
assert response.status_code == 200
def test_network_returns_dict(self, test_client):
"""Network endpoint should return a dict (main API format)."""
response = test_client.get("/api/stats/network")
data = response.json()
assert isinstance(data, dict)
def test_network_excludes_loopback(self, test_client):
"""Network should not include loopback interface."""
response = test_client.get("/api/stats/network")
data = response.json()
assert "lo" not in data
class TestPowerEndpoint:
"""Tests for /api/stats/power endpoint."""
def test_power_returns_200(self, test_client):
"""Power endpoint should return 200 OK."""
response = test_client.get("/api/stats/power")
assert response.status_code == 200
def test_power_returns_expected_fields(self, test_client):
"""Power endpoint should return expected fields (main API format)."""
response = test_client.get("/api/stats/power")
data = response.json()
assert "under_voltage" in data
assert "over_current" in data
def test_power_values_are_bool(self, test_client):
"""Power values should be booleans."""
response = test_client.get("/api/stats/power")
data = response.json()
assert isinstance(data["under_voltage"], bool)
assert isinstance(data["over_current"], bool)
class TestDashboardWithRealData:
"""Tests for dashboard rendering with real status data."""
def test_dashboard_shows_cpu_percent(self, test_client):
"""Dashboard should show CPU percentage (not placeholder)."""
response = test_client.get("/")
content = response.text
# CPU value should be a number, not "--"
# The template shows: {{ cpu.percent|default('--') }}%
# We verify the page loads and contains CPU section
assert "CPU" in content
# The dashboard should have some numeric value, not all placeholders
def test_dashboard_shows_memory_data(self, test_client):
"""Dashboard should show memory data."""
response = test_client.get("/")
content = response.text
assert "Memory" in content
def test_dashboard_shows_load_data(self, test_client):
"""Dashboard should show load data."""
response = test_client.get("/")
content = response.text
assert "Load" in content
def test_dashboard_shows_storage_data(self, test_client):
"""Dashboard should show storage data."""
response = test_client.get("/")
content = response.text
assert "Storage" in content
class TestOpenAPIIncludesStatsEndpoints:
"""Tests that OpenAPI documentation includes stats endpoints."""
def test_openapi_has_stats_paths(self, test_client):
"""OpenAPI schema should include stats paths."""
response = test_client.get("/openapi.json")
data = response.json()
assert "/api/stats/cpu" in data["paths"]
assert "/api/stats/memory" in data["paths"]
assert "/api/stats/load" in data["paths"]
assert "/api/stats/drives/primary" in data["paths"]
assert "/api/stats/network" in data["paths"]
assert "/api/stats/power" in data["paths"]

View file

@ -0,0 +1,248 @@
"""
Tests for the status worker background task and aggregated stats endpoint.
"""
from datetime import datetime
from unittest import mock
import pytest
class TestCollectAllStatus:
"""Tests for collect_all_status function."""
@pytest.mark.asyncio
async def test_collects_all_stats(self):
"""Should collect all status types."""
from controller.lib.status_worker import collect_all_status
status = await collect_all_status()
assert "cpu_stats" in status
assert "memory_stats" in status
assert "load_stats" in status
assert "drives_stats" in status
assert "nic_bandwidth_stats" in status
assert "power_stats" in status
assert "last_status" in status
@pytest.mark.asyncio
async def test_last_status_is_iso_timestamp(self):
"""Should include a valid ISO timestamp."""
from controller.lib.status_worker import collect_all_status
status = await collect_all_status()
# Should be parseable as ISO datetime
datetime.fromisoformat(status["last_status"])
@pytest.mark.asyncio
async def test_handles_cpu_failure(self):
"""Should handle failures in CPU stat collector."""
from controller.lib.status_worker import collect_all_status
with mock.patch(
"controller.lib.status_worker.get_cpu_status",
side_effect=Exception("CPU error")
):
status = await collect_all_status()
# CPU should be None but others should work
assert status["cpu_stats"] is None
assert status["memory_stats"] is not None
@pytest.mark.asyncio
async def test_handles_memory_failure(self):
"""Should handle failures in memory stat collector."""
from controller.lib.status_worker import collect_all_status
with mock.patch(
"controller.lib.status_worker.get_memory_status",
side_effect=Exception("Memory error")
):
status = await collect_all_status()
assert status["memory_stats"] is None
assert status["cpu_stats"] is not None
@pytest.mark.asyncio
async def test_drives_stats_is_list_on_failure(self):
"""Should return empty list for drives on failure."""
from controller.lib.status_worker import collect_all_status
with mock.patch(
"controller.lib.status_worker.get_drive_status",
side_effect=Exception("Drive error")
):
status = await collect_all_status()
assert status["drives_stats"] == []
@pytest.mark.asyncio
async def test_nic_stats_is_dict_on_failure(self):
"""Should return empty dict for network on failure."""
from controller.lib.status_worker import collect_all_status
with mock.patch(
"controller.lib.status_worker.get_network_status",
side_effect=Exception("Network error")
):
status = await collect_all_status()
assert status["nic_bandwidth_stats"] == {}
class TestGetAdaptiveSleep:
"""Tests for adaptive sleep calculation."""
def test_returns_base_sleep_when_load_is_low(self):
"""Should return base sleep when load is below CPU count."""
from controller.lib.status_worker import get_adaptive_sleep
with mock.patch("multiprocessing.cpu_count", return_value=4):
load_stats = {"minute_1": "0.5"}
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
assert sleep == 5.0
def test_returns_base_sleep_when_load_equals_cpu_count(self):
"""Should return base sleep when load equals CPU count."""
from controller.lib.status_worker import get_adaptive_sleep
with mock.patch("multiprocessing.cpu_count", return_value=4):
load_stats = {"minute_1": "4.0"}
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
assert sleep == 5.0
def test_increases_sleep_when_load_exceeds_cpu_count(self):
"""Should increase sleep when load exceeds CPU count."""
from controller.lib.status_worker import get_adaptive_sleep
with mock.patch("multiprocessing.cpu_count", return_value=4):
load_stats = {"minute_1": "8.0"} # 2x CPU count
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
# Expected: (5.0 * 8.0) / 4 = 10.0
assert sleep == 10.0
def test_handles_none_load_stats(self):
"""Should return base sleep when load_stats is None."""
from controller.lib.status_worker import get_adaptive_sleep
sleep = get_adaptive_sleep(None, base_sleep=5.0)
assert sleep == 5.0
def test_handles_empty_load_stats(self):
"""Should return base sleep when load_stats is empty dict."""
from controller.lib.status_worker import get_adaptive_sleep
sleep = get_adaptive_sleep({}, base_sleep=5.0)
assert sleep == 5.0
def test_handles_invalid_load_value(self):
"""Should return base sleep when load value is invalid."""
from controller.lib.status_worker import get_adaptive_sleep
load_stats = {"minute_1": "invalid"}
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
assert sleep == 5.0
def test_handles_missing_minute_1_key(self):
"""Should return base sleep when minute_1 key is missing."""
from controller.lib.status_worker import get_adaptive_sleep
load_stats = {"minute_5": "2.0"}
sleep = get_adaptive_sleep(load_stats, base_sleep=5.0)
assert sleep == 5.0
class TestAggregatedStatsEndpoint:
"""Tests for the new /api/stats aggregated endpoint."""
def test_aggregated_stats_returns_200(self, test_client):
"""Aggregated stats endpoint should return 200 OK."""
response = test_client.get("/api/stats")
assert response.status_code == 200
def test_aggregated_stats_returns_all_fields(self, test_client):
"""Aggregated stats should include all status types."""
response = test_client.get("/api/stats")
data = response.json()
assert "cpu_stats" in data
assert "memory_stats" in data
assert "load_stats" in data
assert "drives_stats" in data
assert "nic_bandwidth_stats" in data
assert "power_stats" in data
assert "last_status" in data
def test_aggregated_stats_has_valid_timestamp(self, test_client):
"""Should include a timestamp of when data was collected."""
response = test_client.get("/api/stats")
data = response.json()
assert "last_status" in data
# Should be ISO format timestamp
datetime.fromisoformat(data["last_status"])
def test_aggregated_stats_cpu_has_expected_fields(self, test_client):
"""CPU stats should have expected fields."""
response = test_client.get("/api/stats")
data = response.json()
cpu = data["cpu_stats"]
assert cpu is not None
assert "percent" in cpu
assert "cores" in cpu
def test_aggregated_stats_memory_has_expected_fields(self, test_client):
"""Memory stats should have expected fields."""
response = test_client.get("/api/stats")
data = response.json()
memory = data["memory_stats"]
assert memory is not None
assert "total" in memory
assert "used" in memory
assert "free" in memory
def test_aggregated_stats_load_has_expected_fields(self, test_client):
"""Load stats should have expected fields."""
response = test_client.get("/api/stats")
data = response.json()
load = data["load_stats"]
assert load is not None
assert "minute_1" in load
assert "minute_5" in load
assert "minute_15" in load
def test_aggregated_stats_drives_is_list(self, test_client):
"""Drives stats should be a list."""
response = test_client.get("/api/stats")
data = response.json()
assert isinstance(data["drives_stats"], list)
def test_aggregated_stats_network_is_dict(self, test_client):
"""Network stats should be a dict."""
response = test_client.get("/api/stats")
data = response.json()
assert isinstance(data["nic_bandwidth_stats"], dict)
def test_aggregated_stats_trailing_slash(self, test_client):
"""Aggregated stats should work with trailing slash."""
response = test_client.get("/api/stats/")
assert response.status_code == 200
class TestOpenAPIIncludesAggregatedStats:
"""Test that OpenAPI includes the new aggregated endpoint."""
def test_openapi_has_aggregated_stats_path(self, test_client):
"""OpenAPI schema should include /api/stats path."""
response = test_client.get("/openapi.json")
data = response.json()
# Check for either with or without trailing slash
has_path = "/api/stats" in data["paths"] or "/api/stats/" in data["paths"]
assert has_path, f"Expected /api/stats in paths, got: {list(data['paths'].keys())}"

View file

@ -0,0 +1,248 @@
"""
Unit tests for controller.lib.systemd module.
"""
from unittest import mock
import pytest
from controller.lib.systemd import (
get_managed_services,
get_service_config,
get_service_status,
get_all_services_status,
start_service,
stop_service,
restart_service,
enable_service,
disable_service,
get_service_logs,
_run_systemctl,
)
class TestGetManagedServices:
"""Tests for get_managed_services function."""
def test_returns_list(self):
"""Should return a list."""
result = get_managed_services()
assert isinstance(result, list)
def test_returns_services_from_config(self):
"""Should return services from config."""
result = get_managed_services()
# Default config has managed_services
assert len(result) > 0
def test_each_service_has_name(self):
"""Each service should have a name."""
result = get_managed_services()
for service in result:
assert "name" in service
class TestGetServiceConfig:
"""Tests for get_service_config function."""
def test_returns_config_for_known_service(self):
"""Should return config for a known service."""
# wrolpi-api is in default config
result = get_service_config("wrolpi-api")
assert result is not None
assert result["name"] == "wrolpi-api"
def test_returns_none_for_unknown_service(self):
"""Should return None for unknown service."""
result = get_service_config("nonexistent-service")
assert result is None
class TestRunSystemctl:
"""Tests for _run_systemctl function."""
def test_returns_dict(self):
"""Should return a dictionary."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
result = _run_systemctl("is-active", "test.service")
assert isinstance(result, dict)
def test_success_on_zero_returncode(self):
"""Should return success=True when returncode is 0."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="done", stderr="")
result = _run_systemctl("start", "test.service")
assert result["success"] is True
def test_failure_on_nonzero_returncode(self):
"""Should return success=False when returncode is non-zero."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=1, stdout="", stderr="failed")
result = _run_systemctl("start", "test.service")
assert result["success"] is False
assert result["error"] == "failed"
def test_handles_timeout(self):
"""Should handle subprocess timeout."""
import subprocess
with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("systemctl", 30)):
result = _run_systemctl("start", "test.service")
assert result["success"] is False
assert "timed out" in result["error"].lower()
def test_handles_file_not_found(self):
"""Should handle systemctl not found."""
with mock.patch("subprocess.run", side_effect=FileNotFoundError()):
result = _run_systemctl("start", "test.service")
assert result["success"] is False
assert "not found" in result["error"].lower()
class TestGetServiceStatus:
"""Tests for get_service_status function."""
def test_returns_error_for_unknown_service(self):
"""Should return error for unknown service."""
result = get_service_status("nonexistent-service")
assert "error" in result
def test_returns_status_dict(self):
"""Should return a status dictionary."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
result = get_service_status("wrolpi-api")
assert "name" in result
assert "status" in result
@pytest.mark.parametrize("systemd_state,expected_status", [
("active", "running"),
("activating", "running"), # oneshot services during execution
("inactive", "stopped"),
("failed", "failed"),
])
def test_maps_systemd_state_to_status(self, systemd_state, expected_status):
"""Should map systemd states to simple status values."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout=systemd_state, stderr="")
result = get_service_status("wrolpi-api")
assert result["status"] == expected_status
@pytest.mark.parametrize("service_name,expected_https", [
("wrolpi-help", True),
("wrolpi-kiwix", True),
("apache2", True),
("wrolpi-api", False),
])
def test_returns_use_https_field(self, service_name, expected_https):
"""Should return use_https field from service config."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
result = get_service_status(service_name)
assert result.get("use_https", False) == expected_https
class TestGetAllServicesStatus:
"""Tests for get_all_services_status function."""
def test_returns_list(self):
"""Should return a list."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
result = get_all_services_status()
assert isinstance(result, list)
def test_returns_status_for_each_visible_service(self):
"""Should return status for each visible managed service."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
result = get_all_services_status()
managed = get_managed_services()
# All services should be returned when they're all "running" (active)
assert len(result) == len(managed)
def test_filters_show_only_when_running_services_when_stopped(self):
"""Should exclude show_only_when_running services when they're not running."""
with mock.patch("subprocess.run") as mock_run:
# Simulate all services as "inactive" (stopped)
mock_run.return_value = mock.Mock(returncode=0, stdout="inactive", stderr="")
result = get_all_services_status()
managed = get_managed_services()
# Count services without show_only_when_running flag
always_visible = [s for s in managed if not s.get("show_only_when_running")]
assert len(result) == len(always_visible)
def test_includes_show_only_when_running_services_when_running(self):
"""Should include show_only_when_running services when they're running."""
with mock.patch("subprocess.run") as mock_run:
# Simulate all services as "active" (running)
mock_run.return_value = mock.Mock(returncode=0, stdout="active", stderr="")
result = get_all_services_status()
managed = get_managed_services()
# All services should be visible when running
assert len(result) == len(managed)
# Verify wrolpi-upgrade is in the result
service_names = [s["name"] for s in result]
assert "wrolpi-upgrade" in service_names
class TestServiceActions:
"""Tests for service action functions (start, stop, restart, enable, disable)."""
@pytest.mark.parametrize("func", [
start_service,
stop_service,
restart_service,
enable_service,
disable_service,
])
def test_returns_error_for_unknown_service(self, func):
"""Should return error for unknown service."""
result = func("nonexistent-service")
assert result["success"] is False
@pytest.mark.parametrize("func,expected_action", [
(start_service, "start"),
(stop_service, "stop"),
(restart_service, "restart"),
(enable_service, "enable"),
(disable_service, "disable"),
])
def test_calls_correct_systemctl_action(self, func, expected_action):
"""Should call systemctl with correct action."""
with mock.patch("controller.lib.systemd._run_systemctl") as mock_ctl:
mock_ctl.return_value = {"success": True, "output": "", "error": None}
result = func("wrolpi-api")
mock_ctl.assert_called_once()
assert mock_ctl.call_args[0][0] == expected_action
assert result["success"] is True
assert result["action"] == expected_action
class TestGetServiceLogs:
"""Tests for get_service_logs function."""
def test_returns_error_for_unknown_service(self):
"""Should return error for unknown service."""
result = get_service_logs("nonexistent-service")
assert "error" in result
def test_returns_logs(self):
"""Should return logs."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="log line 1\nlog line 2", stderr="")
result = get_service_logs("wrolpi-api", lines=10)
assert "logs" in result
assert result["service"] == "wrolpi-api"
assert result["lines"] == 10
def test_handles_since_parameter(self):
"""Should include since parameter in journalctl command."""
with mock.patch("subprocess.run") as mock_run:
mock_run.return_value = mock.Mock(returncode=0, stdout="logs", stderr="")
result = get_service_logs("wrolpi-api", lines=50, since="1h")
assert result["since"] == "1h"
# Verify journalctl was called with --since
call_args = mock_run.call_args[0][0]
assert "--since" in call_args
assert "1h" in call_args

View file

@ -0,0 +1,83 @@
"""
Unit tests for controller.lib.wrol_mode module.
"""
from unittest import mock
import pytest
from controller.lib.wrol_mode import (
get_wrol_mode_flag_path,
is_wrol_mode,
require_normal_mode,
)
class TestGetWrolModeFlagPath:
"""Tests for get_wrol_mode_flag_path function."""
def test_returns_path(self):
"""Should return a Path object."""
from pathlib import Path
result = get_wrol_mode_flag_path()
assert isinstance(result, Path)
def test_path_ends_with_wrol_mode(self):
"""Path should end with .wrol_mode."""
result = get_wrol_mode_flag_path()
assert result.name == ".wrol_mode"
def test_path_is_in_config_directory(self):
"""Path should be in config directory."""
result = get_wrol_mode_flag_path()
assert result.parent.name == "config"
class TestIsWrolMode:
"""Tests for is_wrol_mode function."""
def test_returns_false_when_file_does_not_exist(self, tmp_path):
"""Should return False when flag file doesn't exist."""
with mock.patch(
"controller.lib.wrol_mode.get_wrol_mode_flag_path",
return_value=tmp_path / ".wrol_mode"
):
assert is_wrol_mode() is False
def test_returns_true_when_file_exists(self, tmp_path):
"""Should return True when flag file exists."""
flag_file = tmp_path / ".wrol_mode"
flag_file.touch()
with mock.patch(
"controller.lib.wrol_mode.get_wrol_mode_flag_path",
return_value=flag_file
):
assert is_wrol_mode() is True
class TestRequireNormalMode:
"""Tests for require_normal_mode function."""
def test_does_not_raise_when_not_in_wrol_mode(self):
"""Should not raise when WROL mode is inactive."""
with mock.patch("controller.lib.wrol_mode.is_wrol_mode", return_value=False):
# Should not raise
require_normal_mode("test operation")
def test_raises_permission_error_when_in_wrol_mode(self):
"""Should raise PermissionError when WROL mode is active."""
with mock.patch("controller.lib.wrol_mode.is_wrol_mode", return_value=True):
with pytest.raises(PermissionError) as exc_info:
require_normal_mode("save configuration")
assert "save configuration" in str(exc_info.value)
assert "WROL Mode" in str(exc_info.value)
def test_error_message_includes_operation_name(self):
"""Error message should include the operation name."""
with mock.patch("controller.lib.wrol_mode.is_wrol_mode", return_value=True):
with pytest.raises(PermissionError) as exc_info:
require_normal_mode("modify settings")
assert "modify settings" in str(exc_info.value)

View file

@ -30,6 +30,15 @@ python3 -m venv /opt/wrolpi/venv
/opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt ||
/opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt
# Install Controller with separate venv.
echo "Pre-installing WROLPi Controller..."
python3 -m venv /opt/wrolpi/controller/venv
/opt/wrolpi/controller/venv/bin/pip install --upgrade pip
/opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt ||
/opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt ||
/opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt
echo "Controller pre-installed"
# Configure nginx.
cp /opt/wrolpi/etc/raspberrypios/nginx.conf /etc/nginx/nginx.conf
cp /opt/wrolpi/etc/raspberrypios/50x.html /var/www/50x.html

View file

@ -14,9 +14,40 @@ services:
volumes:
- ./pg_data:/var/lib/postgresql/data
controller: # WROLPi Controller Service
build:
context: .
dockerfile: docker/controller/Dockerfile
container_name: wrolpi-controller
restart: unless-stopped
ports:
- "8087:8087"
volumes:
# Controller code (for development hot-reload)
- ./controller:/app/controller
# Docker socket for container management
- /var/run/docker.sock:/var/run/docker.sock
# Host system info (read-only)
- /proc:/host/proc:ro
- /sys:/host/sys:ro
# Config directory
- ${MEDIA_DIRECTORY:-./test}/config:/media/wrolpi/config
environment:
- DOCKERIZED=true
- MEDIA_DIRECTORY=/media/wrolpi
# Override CMD to enable auto-reload for development
command: ["uvicorn", "controller.main:app", "--host", "0.0.0.0", "--port", "8087", "--reload", "--reload-dir", "/app/controller"]
healthcheck:
test: [ 'CMD-SHELL', 'curl -f http://127.0.0.1:8087/api/health || exit 1' ]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
api:
depends_on:
- db
- controller
build:
context: .
dockerfile: docker/api/Dockerfile

View file

@ -0,0 +1,30 @@
FROM python:3.11-slim
LABEL maintainer="WROLPi"
LABEL description="WROLPi Controller Service"
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for caching
COPY controller/requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copy Controller code
COPY controller/ /app/controller/
# Environment
ENV PYTHONUNBUFFERED=1
ENV DOCKERIZED=true
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8087/api/health || exit 1
EXPOSE 8087
CMD ["uvicorn", "controller.main:app", "--host", "0.0.0.0", "--port", "8087"]

View file

@ -13,11 +13,17 @@ location /apple-touch-icon.png {
}
location / {
# React APP
# React APP with Controller fallback
proxy_pass http://app:3000;
proxy_intercept_errors on;
error_page 502 503 504 = @react_fallback;
rewrite $1 break;
}
location @react_fallback {
return 302 /controller/?fallback=true;
}
location /ws {
proxy_pass http://app:3000;
proxy_http_version 1.1;
@ -26,7 +32,19 @@ location /ws {
}
#
# API
# Controller - system management API and UI
# All Controller endpoints are under /controller/*
#
location /controller {
rewrite ^/controller(.*)$ $1 break;
proxy_pass http://controller:8087;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
#
# Main API - handled by the API service (port 8081)
#
location /api {

View file

@ -1,210 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WROLPi - Upgrade in Progress</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
color: #333;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8f0 50%, #dde4ed 100%);
}
.container {
text-align: center;
padding: 2rem;
max-width: 500px;
width: 90%;
}
.logo {
font-size: 3rem;
margin-bottom: 1rem;
}
h1 {
color: #5829bb;
font-size: 1.8rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
.subtitle {
color: #666;
font-size: 1rem;
margin-bottom: 2rem;
}
.spinner-container {
margin: 2rem 0;
}
.spinner {
width: 50px;
height: 50px;
border: 4px solid #ddd;
border-top: 4px solid #4CAF50;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.status {
font-size: 1.1rem;
margin: 1.5rem 0;
min-height: 1.5em;
}
.elapsed {
color: #666;
font-size: 0.9rem;
margin-top: 1rem;
}
.warning {
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 8px;
padding: 1rem;
margin-top: 2rem;
font-size: 0.9rem;
color: #b38600;
}
.success {
color: #4CAF50;
}
.check-icon {
font-size: 3rem;
color: #4CAF50;
animation: fadeIn 0.5s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: scale(0.5); }
to { opacity: 1; transform: scale(1); }
}
.hidden {
display: none;
}
.help-link {
margin-top: 1.5em;
font-size: 1.2em;
}
.help-link a {
color: #5829bb;
text-decoration: none;
}
.help-link a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">WROLPi</div>
<h1>Upgrade in Progress</h1>
<p class="subtitle">Please wait while your system is being upgraded</p>
<div class="spinner-container" id="spinnerContainer">
<div class="spinner"></div>
</div>
<div class="spinner-container hidden" id="successContainer">
<div class="check-icon">&#10003;</div>
</div>
<p class="status" id="status">Checking upgrade status...</p>
<p class="elapsed" id="elapsed">Elapsed: 0:00</p>
<div class="warning">
<strong>Do not power off</strong> your device or close this window during the upgrade.
</div>
<p class="help-link">
<a id="helpLink" href="#" target="_blank">WROLPi Help</a>
</p>
</div>
<script>
const startTime = Date.now();
let pollInterval;
let elapsedInterval;
function updateElapsed() {
const elapsed = Math.floor((Date.now() - startTime) / 1000);
const minutes = Math.floor(elapsed / 60);
const seconds = elapsed % 60;
document.getElementById('elapsed').textContent =
`Elapsed: ${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function showSuccess() {
document.getElementById('spinnerContainer').classList.add('hidden');
document.getElementById('successContainer').classList.remove('hidden');
}
async function checkApiStatus() {
try {
const response = await fetch('/api/status', {
method: 'GET',
headers: { 'Accept': 'application/json' },
// Short timeout to avoid long waits
signal: AbortSignal.timeout(5000)
});
if (response.ok) {
// API is back online!
clearInterval(pollInterval);
clearInterval(elapsedInterval);
showSuccess();
document.getElementById('status').innerHTML =
'<span class="success">Upgrade complete! Redirecting...</span>';
// Redirect to home after a short delay
setTimeout(() => {
window.location.href = '/';
}, 2000);
}
} catch (e) {
// API is still down or timeout - this is expected during upgrade
document.getElementById('status').textContent = 'Upgrade in progress...';
}
}
// Update elapsed time every second
elapsedInterval = setInterval(updateElapsed, 1000);
// Check API status every 3 seconds
pollInterval = setInterval(checkApiStatus, 3000);
// Initial check
checkApiStatus();
// Set help link to correct hostname
document.getElementById('helpLink').href = `https://${window.location.hostname}:8086`;
</script>
</body>
</html>

View file

@ -0,0 +1,31 @@
[Unit]
Description=WROLPi Controller Service
Documentation=https://github.com/lrnselfreliance/wrolpi
After=network.target
Before=wrolpi-api.service wrolpi-app.service postgresql.service
[Service]
Type=simple
ExecStart=/opt/wrolpi/controller/venv/bin/uvicorn controller.main:app --host 0.0.0.0 --port 8087
WorkingDirectory=/opt/wrolpi
User=root
Group=root
Restart=always
RestartSec=5
# Higher priority than the API, but lower than Help.
Nice=2
# Environment
Environment="PYTHONUNBUFFERED=1"
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=wrolpi-controller
# Security - allow privileged operations
AmbientCapabilities=CAP_SYS_ADMIN CAP_NET_ADMIN
NoNewPrivileges=false
[Install]
WantedBy=multi-user.target

View file

@ -1,13 +1,5 @@
# Nginx config for the WROLPi services.
#
# Maintenance page for upgrades
#
location = /maintenance.html {
alias /var/www/maintenance.html;
}
#
# Error pages
#
@ -30,11 +22,17 @@ location /apple-touch-icon.png {
}
location / {
# React APP
# React APP with Controller fallback
proxy_pass http://0.0.0.0:3000;
proxy_intercept_errors on;
error_page 502 503 504 = @react_fallback;
rewrite $1 break;
}
location @react_fallback {
return 302 /controller/?fallback=true;
}
location /ws {
proxy_pass http://0.0.0.0:3000;
proxy_http_version 1.1;
@ -43,7 +41,19 @@ location /ws {
}
#
# API
# Controller - system management API and UI
# All Controller endpoints are under /controller/*
#
location /controller {
rewrite ^/controller(.*)$ $1 break;
proxy_pass http://0.0.0.0:8087;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
#
# Main API - handled by the API service (port 8081)
#
location /api {

View file

@ -1,3 +1,3 @@
[Unit]
Description=WROLPi services group
Wants=wrolpi-api.service wrolpi-app.service nginx.service postgresql.service wrolpi-kiwix.service renderd.service apache2.service postgresql@15-map.service
Wants=wrolpi-controller.service wrolpi-api.service wrolpi-app.service nginx.service postgresql.service wrolpi-kiwix.service renderd.service apache2.service postgresql@15-map.service

42
help.sh
View file

@ -113,6 +113,48 @@ else
echo "FAILED: Unable to find wrolpi database"
fi
echo
# Controller
if [ -f /opt/wrolpi/controller/venv/bin/python3 ]; then
echo "OK: Controller Python virtual environment exists"
if /opt/wrolpi/controller/venv/bin/python3 --version 2>/dev/null > /dev/null; then
echo 'OK: Controller Python can be run'
else
echo "FAILED: Controller Python could not be run"
fi
else
echo "FAILED: Controller Python virtual environment does not exist"
fi
if systemctl list-unit-files "*wrolpi-controller*" >/dev/null 2>&1; then
echo "OK: WROLPi Controller systemd exists"
else
echo "FAILED: WROLPi Controller systemd does not exist"
fi
if [ -f /etc/systemd/system/wrolpi-controller.service ]; then
if systemctl status wrolpi-controller.service >/dev/null 2>&1; then
echo "OK: WROLPi Controller service is up"
else
echo "FAILED: WROLPi Controller service is not up"
fi
fi
if curl -s http://0.0.0.0:8087/api/health --max-time 5 >/dev/null 2>&1; then
echo "OK: WROLPi Controller health check passed"
else
echo "FAILED: WROLPi Controller health check failed"
fi
# WROL Mode status
if [ -f /media/wrolpi/config/.wrol_mode ]; then
echo "Note: WROL Mode is ACTIVE"
else
echo "OK: WROL Mode is inactive"
fi
echo
# API

View file

@ -30,6 +30,15 @@ python3 -m venv /opt/wrolpi/venv
/opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt ||
/opt/wrolpi/venv/bin/pip3 install -r /opt/wrolpi/requirements.txt
# Install Controller with separate venv.
echo "Pre-installing WROLPi Controller..."
python3 -m venv /opt/wrolpi/controller/venv
/opt/wrolpi/controller/venv/bin/pip install --upgrade pip
/opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt ||
/opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt ||
/opt/wrolpi/controller/venv/bin/pip install -r /opt/wrolpi/controller/requirements.txt
echo "Controller pre-installed"
# Install webapp.
(cd /opt/wrolpi/app && npm install && npm run build)

View file

@ -18,6 +18,7 @@ set -e
set -x
# Stop services if they are running.
systemctl stop wrolpi-controller.service || :
systemctl stop wrolpi-api.service || :
systemctl stop wrolpi-app.service || :
systemctl stop wrolpi-kiwix.service || :
@ -43,7 +44,6 @@ cp /opt/wrolpi/etc/raspberrypios/nginx.conf /etc/nginx/nginx.conf
[ -f /etc/nginx/conf.d/default.conf ] && rm /etc/nginx/conf.d/default.conf
cp /opt/wrolpi/etc/raspberrypios/wrolpi.conf /etc/nginx/conf.d/wrolpi.conf
cp /opt/wrolpi/etc/raspberrypios/50x.html /var/www/50x.html
cp /opt/wrolpi/etc/raspberrypios/maintenance.html /var/www/maintenance.html
# Generate nginx certificate for HTTPS.
if [[ ! -f /etc/nginx/cert.crt || ! -f /etc/nginx/cert.key ]]; then
@ -53,6 +53,9 @@ if [[ ! -f /etc/nginx/cert.crt || ! -f /etc/nginx/cert.key ]]; then
chmod 640 /etc/nginx/cert.key /etc/nginx/cert.crt
fi
# Start nginx quickly so user can access Controller from React UI
systemctl start nginx || :
# WROLPi needs a few privileged commands.
cp /opt/wrolpi/etc/raspberrypios/90-wrolpi /etc/sudoers.d/90-wrolpi
chmod 0440 /etc/sudoers.d/90-wrolpi
@ -62,11 +65,54 @@ visudo -c -f /etc/sudoers.d/90-wrolpi
# Install the systemd services
cp /opt/wrolpi/etc/raspberrypios/wrolpi*.service /etc/systemd/system/
cp /opt/wrolpi/etc/raspberrypios/wrolpi.target /etc/systemd/system/
systemctl enable wrolpi-controller.service
systemctl enable wrolpi-api.service
systemctl enable wrolpi-app.service
systemctl enable wrolpi-kiwix.service
systemctl enable wrolpi-help.service
# Repair Controller (offline-safe - no pip install)
repair_controller() {
echo "Checking WROLPi Controller..."
# Check venv exists
if [ ! -f /opt/wrolpi/controller/venv/bin/python ]; then
echo "ERROR: Controller venv missing!"
echo "Run install.sh or upgrade.sh (requires internet) to reinstall."
return 1
fi
# Check venv is functional
if ! /opt/wrolpi/controller/venv/bin/python --version > /dev/null 2>&1; then
echo "ERROR: Controller venv is corrupted!"
echo "Run install.sh or upgrade.sh (requires internet) to reinstall."
return 1
fi
# Check systemd service is installed
if [ ! -f /etc/systemd/system/wrolpi-controller.service ]; then
echo "Controller systemd service not installed, copying..."
cp /opt/wrolpi/etc/raspberrypios/wrolpi-controller.service /etc/systemd/system/
systemctl daemon-reload
fi
# Ensure service is enabled
if ! systemctl is-enabled wrolpi-controller > /dev/null 2>&1; then
echo "Enabling Controller service..."
systemctl enable wrolpi-controller
fi
echo "Controller repair completed"
}
# Run Controller repair if venv exists (skip on fresh install)
if [ -d /opt/wrolpi/controller/venv ]; then
repair_controller || echo "Controller repair failed, continuing..."
fi
# Start Controller so user can monitor repair in UI.
systemctl start wrolpi-controller
# Copy config files necessary for map.
cp -r /opt/wrolpi/etc/raspberrypios/postgresql@15-map.service.d /etc/systemd/system/
cp /opt/wrolpi/etc/raspberrypios/renderd.conf /etc/renderd.conf

View file

@ -21,6 +21,47 @@ npm install || npm install || npm install || npm install # try install multiple
# Upgrade the WROLPi database.
(cd /opt/wrolpi && /opt/wrolpi/venv/bin/python3 /opt/wrolpi/main.py db upgrade)
# Upgrade Controller
upgrade_controller() {
echo "Upgrading WROLPi Controller..."
# Stop Controller during upgrade
systemctl stop wrolpi-controller || true
# Create Controller venv if it doesn't exist
if [ ! -d /opt/wrolpi/controller/venv ]; then
echo "Creating Controller virtual environment..."
python3 -m venv /opt/wrolpi/controller/venv
fi
# Update dependencies
echo "Updating Controller dependencies..."
/opt/wrolpi/controller/venv/bin/pip install --upgrade pip
/opt/wrolpi/controller/venv/bin/pip install --upgrade -r /opt/wrolpi/controller/requirements.txt
# Reload systemd in case service file changed
systemctl daemon-reload
# Start Controller
systemctl start wrolpi-controller
# Wait for health check (up to 30 seconds)
echo "Waiting for Controller health check..."
for i in {1..30}; do
if curl -s http://localhost:8087/api/health > /dev/null 2>&1; then
echo "Controller upgraded successfully"
return 0
fi
sleep 1
done
echo "WARNING: Controller health check failed after upgrade"
echo "Check logs: journalctl -u wrolpi-controller -n 50"
return 1
}
upgrade_controller || echo "Controller upgrade failed, continuing..."
# Upgrade WROLPi Help.
/opt/wrolpi/scripts/install_help_service.sh || echo "Help install failed."

View file

@ -1,7 +1,7 @@
import pytest
from wrolpi.collections import Collection
from wrolpi.errors import UnknownCollection
from wrolpi.collections.errors import UnknownCollection
def test_collection_get_by_id(test_session, test_directory):

View file

@ -491,9 +491,10 @@ class ConfigFile:
if file.is_file():
if not self.is_valid(file):
raise InvalidConfig(f'Config file is invalid: {str(self.get_relative_file())}')
# is_valid() already validated, just load the data
config_data = self.read_config_file(file)
config_data = {k: v for k, v in config_data.items() if k in self.default_config}
self._config.update(asdict(self.validator(**config_data)))
self._config.update(config_data)
return self
@property
@ -653,6 +654,11 @@ class ConfigFile:
def validate(self, config: dict) -> bool:
allowed_fields = {i.name for i in fields(self.validator)}
default_fields = set(self.default_config.keys())
# Check for fields in default_config that are missing from validator
missing_from_validator = default_fields - allowed_fields
if missing_from_validator:
logger.error(f'{self.file_name}: Fields in default_config but missing from validator: {missing_from_validator}')
try:
# Remove keys no longer in the config.
config_items = config.items()
@ -691,6 +697,7 @@ class ConfigFile:
@dataclass
class WROLPiConfigValidator:
archive_destination: str = None
check_for_upgrades: bool = None
download_on_startup: bool = None
download_timeout: int = None
hotspot_device: str = None

View file

@ -569,7 +569,8 @@ async def get_power_stats() -> PowerStats:
DISABLE_STATUS_WORKER = bool(PYTEST) # Do not run status worker while testing, unless explicitly needed.
@perpetual_signal(sleep=5)
# Disable status_worker in favor of status from Controller.
# @perpetual_signal(sleep=5)
async def status_worker(count: int = None, sleep_time: int = 5):
"""A background process which will gather historical data about system statistics."""
shared_status = api_app.shared_ctx.status