mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
344fe9035f
commit
b9158b60fe
66 changed files with 9920 additions and 327 deletions
32
.github/workflows/controller-tests.yml
vendored
Normal file
32
.github/workflows/controller-tests.yml
vendored
Normal 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
3
.gitignore
vendored
|
|
@ -137,3 +137,6 @@ pg_data
|
|||
.DS_Store
|
||||
app/cypress/screenshots
|
||||
app/cypress/videos
|
||||
|
||||
.claude
|
||||
*.md
|
||||
|
|
|
|||
|
|
@ -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
186
app/src/api/controller.js
Normal 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');
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
609
app/src/components/admin/ControllerPage.js
Normal file
609
app/src/components/admin/ControllerPage.js
Normal 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;
|
||||
169
app/src/components/admin/ControllerPage.test.js
Normal file
169
app/src/components/admin/ControllerPage.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
3
controller/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""WROLPi Controller - System management service."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
1
controller/api/__init__.py
Normal file
1
controller/api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Controller API routes."""
|
||||
135
controller/api/admin.py
Normal file
135
controller/api/admin.py
Normal 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
188
controller/api/disks.py
Normal 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
265
controller/api/schemas.py
Normal 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
162
controller/api/services.py
Normal 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
145
controller/api/status.py
Normal 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
106
controller/conftest.py
Normal 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
115
controller/defaults.py
Normal 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",
|
||||
},
|
||||
}
|
||||
1
controller/lib/__init__.py
Normal file
1
controller/lib/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Controller library modules."""
|
||||
480
controller/lib/admin.py
Normal file
480
controller/lib/admin.py
Normal 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
163
controller/lib/config.py
Normal 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
276
controller/lib/disks.py
Normal 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 []
|
||||
240
controller/lib/docker_services.py
Normal file
240
controller/lib/docker_services.py
Normal 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
198
controller/lib/fstab.py
Normal 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
83
controller/lib/smart.py
Normal 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
362
controller/lib/status.py
Normal 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
|
||||
129
controller/lib/status_worker.py
Normal file
129
controller/lib/status_worker.py
Normal 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
322
controller/lib/systemd.py
Normal 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"}
|
||||
45
controller/lib/wrol_mode.py
Normal file
45
controller/lib/wrol_mode.py
Normal 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
188
controller/main.py
Normal 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(),
|
||||
)
|
||||
27
controller/requirements.txt
Normal file
27
controller/requirements.txt
Normal 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
|
||||
1501
controller/templates/index.html
Normal file
1501
controller/templates/index.html
Normal file
File diff suppressed because it is too large
Load diff
1
controller/test/__init__.py
Normal file
1
controller/test/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Controller tests."""
|
||||
242
controller/test/test_admin.py
Normal file
242
controller/test/test_admin.py
Normal 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
|
||||
145
controller/test/test_admin_api.py
Normal file
145
controller/test/test_admin_api.py
Normal 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
222
controller/test/test_api.py
Normal 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
|
||||
268
controller/test/test_config.py
Normal file
268
controller/test/test_config.py
Normal 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
|
||||
130
controller/test/test_defaults.py
Normal file
130
controller/test/test_defaults.py
Normal 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"
|
||||
285
controller/test/test_disks.py
Normal file
285
controller/test/test_disks.py
Normal 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"
|
||||
|
||||
|
||||
184
controller/test/test_disks_api.py
Normal file
184
controller/test/test_disks_api.py
Normal 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"]
|
||||
297
controller/test/test_docker_services.py
Normal file
297
controller/test/test_docker_services.py
Normal 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
|
||||
154
controller/test/test_fstab.py
Normal file
154
controller/test/test_fstab.py
Normal 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 == []
|
||||
50
controller/test/test_schemas.py
Normal file
50
controller/test/test_schemas.py
Normal 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
|
||||
)
|
||||
179
controller/test/test_services_api.py
Normal file
179
controller/test/test_services_api.py
Normal 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"]
|
||||
169
controller/test/test_smart.py
Normal file
169
controller/test/test_smart.py
Normal 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
|
||||
209
controller/test/test_status.py
Normal file
209
controller/test/test_status.py
Normal 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)
|
||||
191
controller/test/test_status_api.py
Normal file
191
controller/test/test_status_api.py
Normal 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"]
|
||||
248
controller/test/test_status_worker.py
Normal file
248
controller/test/test_status_worker.py
Normal 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())}"
|
||||
248
controller/test/test_systemd.py
Normal file
248
controller/test/test_systemd.py
Normal 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
|
||||
83
controller/test/test_wrol_mode.py
Normal file
83
controller/test/test_wrol_mode.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
30
docker/controller/Dockerfile
Normal file
30
docker/controller/Dockerfile
Normal 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"]
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">✓</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>
|
||||
31
etc/raspberrypios/wrolpi-controller.service
Normal file
31
etc/raspberrypios/wrolpi-controller.service
Normal 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
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
42
help.sh
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
48
repair.sh
48
repair.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue