diff --git a/app/src/components/admin/ControllerPage.js b/app/src/components/admin/ControllerPage.js
index 01a18f51..9f52b8c1 100644
--- a/app/src/components/admin/ControllerPage.js
+++ b/app/src/components/admin/ControllerPage.js
@@ -1,6 +1,23 @@
import React from 'react';
-import {Checkbox, Container, Dropdown, Form, Icon, Input} from "semantic-ui-react";
-import {Button, Confirm, Header, Loader, Modal, Segment, Table} from "../Theme";
+import {IconInfoCircle, IconPlayerEject} from '@tabler/icons-react';
+import {
+ Button,
+ Checkbox,
+ Confirm,
+ Group,
+ Header,
+ Icon,
+ IconButton,
+ Loading,
+ Message,
+ Modal,
+ Panel,
+ Select,
+ Status,
+ Table,
+ TextInput,
+ toast,
+} from "../ui";
import {APIButton, BluetoothToggle, DesktopToggle, DirectorySearch, HandPointMessage, HotspotToggle, InfoMessage, ThrottleToggle, Toggle, VncToggle,} from "../Common";
import {useDockerized, useMediaDirectory} from "../../hooks/customHooks";
import {Media} from "../../contexts/contexts";
@@ -29,18 +46,18 @@ import {
updateHotspotSettings,
} from "../../api/controller";
import QRCode from "react-qr-code";
-import {toast} from "../ui";
import {RestartButton, ShutdownButton} from "./Settings";
-import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {CONTROLLER_URI} from "../Vars";
-// Service status color mapping
-const statusColors = {
- running: 'green',
- stopped: 'red',
- failed: 'red',
- unknown: 'yellow',
+// Service status kind mapping. Status only knows complete/active/pending/failed,
+// so "stopped" and "unknown" both land on pending -- the Start/Stop button and
+// service name still tell them apart, this dot only carries running vs. failed.
+const statusKinds = {
+ running: 'complete',
+ stopped: 'pending',
+ failed: 'failed',
+ unknown: 'pending',
};
// Sort order within a group: problems first so they stay visible.
@@ -69,11 +86,11 @@ export const groupServices = (services) => {
const linesOptions = [
- {key: 100, text: '100 lines', value: 100},
- {key: 250, text: '250 lines', value: 250},
- {key: 500, text: '500 lines', value: 500},
- {key: 1000, text: '1000 lines', value: 1000},
- {key: 5000, text: '5000 lines', value: 5000},
+ {value: '100', label: '100 lines'},
+ {value: '250', label: '250 lines'},
+ {value: '500', label: '500 lines'},
+ {value: '1000', label: '1000 lines'},
+ {value: '5000', label: '5000 lines'},
];
// Custom hook for service row state and handlers
@@ -157,9 +174,10 @@ function useServiceRow(service, onAction) {
fetchLogs(linesCount, true); // Show loading on initial open
};
- const handleLinesChange = (e, {value}) => {
- setLinesCount(value);
- fetchLogs(value);
+ const handleLinesChange = (value) => {
+ const lines = parseInt(value, 10);
+ setLinesCount(lines);
+ fetchLogs(lines);
};
const handleDownloadLogs = () => {
@@ -175,7 +193,7 @@ function useServiceRow(service, onAction) {
};
const isRunning = service.status === 'running';
- const statusColor = statusColors[service.status] || 'grey';
+ const statusKind = statusKinds[service.status] || 'pending';
// Build view URL if service is viewable
let viewUrl = null;
@@ -191,7 +209,7 @@ function useServiceRow(service, onAction) {
return {
loading, logsOpen, setLogsOpen, logs, logsLoading, linesCount, countdown, logsRef,
handleAction, fetchLogs, handleViewLogs, handleLinesChange, handleDownloadLogs,
- isRunning, statusColor, viewUrl
+ isRunning, statusKind, viewUrl
};
}
@@ -205,7 +223,6 @@ function ServiceLogsModal({
logsRef,
linesCount,
countdown,
- handleViewLogs,
handleLinesChange,
handleDownloadLogs,
fetchLogs
@@ -214,24 +231,22 @@ function ServiceLogsModal({
setLogsOpen(false)}
- onOpen={handleViewLogs}
- trigger={ }
size='fullscreen'
>
Logs: {service.name}
{logsLoading ? (
-
+
) : (
{logs}
@@ -239,17 +254,16 @@ function ServiceLogsModal({
)}
- {countdown}s
- {countdown}s
+
- fetchLogs(linesCount)} color='blue' icon='refresh' content='Refresh'/>
-
- setLogsOpen(false)}>Close
+ fetchLogs(linesCount)} role='primary' icon='refresh'>Refresh
+ Download
+ setLogsOpen(false)}>Close
);
@@ -260,44 +274,51 @@ function MobileServiceRow({service, onAction}) {
const {
loading, logsOpen, setLogsOpen, logs, logsLoading, linesCount, countdown, logsRef,
handleAction, fetchLogs, handleViewLogs, handleLinesChange, handleDownloadLogs,
- isRunning, statusColor, viewUrl
+ isRunning, statusKind, viewUrl
} = useServiceRow(service, onAction);
return (
-
+
-
+
{service.name}
- {service.description && {service.description}
}
+ {service.description &&
+ {service.description}
}
-
+
{isRunning ? (
- handleAction('Stop', stopService)}
/>
) : (
- handleAction('Start', startService)}
/>
)}
- handleAction('Restart', restartService)}
/>
+
{viewUrl && (
-
)}
@@ -333,45 +353,52 @@ function DesktopServiceRow({service, onAction, dockerized}) {
const {
loading, logsOpen, setLogsOpen, logs, logsLoading, linesCount, countdown, logsRef,
handleAction, fetchLogs, handleViewLogs, handleLinesChange, handleDownloadLogs,
- isRunning, statusColor, viewUrl
+ isRunning, statusKind, viewUrl
} = useServiceRow(service, onAction);
return (
-
+
-
+
{service.name}
- {service.description && {service.description}
}
+ {service.description &&
+ {service.description}
}
{service.port || '-'}
{isRunning ? (
- handleAction('Stop', stopService)}
/>
) : (
- handleAction('Start', startService)}
/>
)}
- handleAction('Restart', restartService)}
/>
+
{viewUrl && (
-
)}
@@ -472,18 +498,18 @@ function ServicesSection() {
}, []);
if (loading && services.length === 0) {
- return
+ return
-
- ;
+
+ ;
}
if (error) {
- return
+ return
- Error loading services: {error}
- Retry
- ;
+ Error loading services: {error}
+ Retry
+ ;
}
const restartButton = (colSpan) => (
@@ -491,13 +517,13 @@ function ServicesSection() {
-
{restarting ? 'Restarting...' : 'Restart All Services'}
@@ -536,14 +562,11 @@ function ServicesSection() {
};
return (
-
-
+
+
{/* Mobile table - 2 columns */}
-
+
Service
@@ -558,7 +581,7 @@ function ServicesSection() {
{/* Desktop table - full columns */}
-
+
Service
@@ -576,19 +599,19 @@ function ServicesSection() {
{services.length === 0 && (
No services found. Services may not be available in this environment.
)}
-
+
);
}
// Helper to get health status color from SMART assessment
const getHealthColor = (assessment) => {
- if (!assessment) return 'grey';
+ if (!assessment) return 'var(--muted)';
const upper = assessment.toUpperCase();
- if (upper === 'PASS') return 'green';
- if (upper === 'WARN') return 'yellow';
- if (upper === 'FAIL') return 'red';
- return 'grey';
+ if (upper === 'PASS') return 'var(--green)';
+ if (upper === 'WARN') return 'var(--amber)';
+ if (upper === 'FAIL') return 'var(--red)';
+ return 'var(--muted)';
};
// Helper to format power-on hours
@@ -610,11 +633,10 @@ function SmartDetailsModal({drive, open, onClose}) {
return (
-
- SMART Details: {drive.device}
+ SMART Details: {drive.device}
-
+
Model
@@ -631,7 +653,7 @@ function SmartDetailsModal({drive, open, onClose}) {
Assessment
-
+ ● {' '}
{drive.assessment || drive.health || 'Unknown'}
{drive.smart_limited
? ' (limited — USB enclosure reports health only, no attributes)'
@@ -652,7 +674,7 @@ function SmartDetailsModal({drive, open, onClose}) {
Reallocated Sectors
- 0 ? 'orange' : 'inherit'}}>
+ 0 ? 'var(--amber)' : 'inherit'}}>
{drive.reallocated_sectors !== null && drive.reallocated_sectors !== undefined
? drive.reallocated_sectors
: '-'}
@@ -660,7 +682,7 @@ function SmartDetailsModal({drive, open, onClose}) {
Pending Sectors
- 0 ? 'orange' : 'inherit'}}>
+ 0 ? 'var(--amber)' : 'inherit'}}>
{drive.pending_sectors !== null && drive.pending_sectors !== undefined
? drive.pending_sectors
: '-'}
@@ -668,7 +690,7 @@ function SmartDetailsModal({drive, open, onClose}) {
Uncorrectable Sectors
- 0 ? 'orange' : 'inherit'}}>
+ 0 ? 'var(--amber)' : 'inherit'}}>
{drive.offline_uncorrectable !== null && drive.offline_uncorrectable !== undefined
? drive.offline_uncorrectable
: '-'}
@@ -682,7 +704,7 @@ function SmartDetailsModal({drive, open, onClose}) {
- Close
+ Close
);
@@ -797,8 +819,7 @@ function MountModal({disk, open, onClose, onMount}) {
return (
-
- Existing Files Detected
+ Existing Files Detected
@@ -808,7 +829,7 @@ function MountModal({disk, open, onClose, onMount}) {
Found {entries} ({formatBytes(shadowed.size_bytes)}) at the mount target.
-
+
Those files will continue to consume space on the underlying filesystem
(typically the SD card on a Raspberry Pi) and can fill it up.
@@ -817,9 +838,9 @@ function MountModal({disk, open, onClose, onMount}) {
- Cancel
-
-
+ Cancel
+
Mount Anyway
@@ -830,41 +851,34 @@ function MountModal({disk, open, onClose, onMount}) {
return (
-
- Mount Disk: {disk.name}
+ Mount Disk: {disk.name}
-
- Mount Point
- setMountPoint(e.target.value)}
- placeholder="/media/..."
- fluid
- />
-
-
- setPersist(checked)}
- />
-
- {disk.fstype && (
-
- Filesystem: {disk.fstype}
- {disk.label && ` | Label: ${disk.label}`}
- {disk.size && ` | Size: ${formatSize(disk.size)}`}
-
- )}
-
+ setMountPoint(e.target.value)}
+ placeholder="/media/..."
+ />
+
+ setPersist(e.currentTarget.checked)}
+ />
+
+ {disk.fstype && (
+
+ Filesystem: {disk.fstype}
+ {disk.label && ` | Label: ${disk.label}`}
+ {disk.size && ` | Size: ${formatSize(disk.size)}`}
+
+ )}
- Cancel
-
-
+ Cancel
+
Mount
@@ -1009,35 +1023,29 @@ function DiskSection() {
if (dockerized) {
return (
-
-
+
+
Disk management is not available in Docker environments.
-
+
);
}
if (loading) {
- return
+ return
-
- ;
+
+ ;
}
return (
-
-
+
+
- {error && Some disk information unavailable: {error}
}
+ {error && Some disk information unavailable: {error} }
{disks.length > 0 ? (
-
+
Name
@@ -1067,7 +1075,7 @@ function DiskSection() {
{isMounted ? (
{disk.mountpoint}
) : (
- -
+ -
)}
@@ -1078,28 +1086,30 @@ function DiskSection() {
label={persistent ? 'Enabled' : 'Disabled'}
/>
) : (
- -
+ -
)}
{isMounted && isSystemMount ? (
- System
+ System
) : isMounted ? (
handleUnmountClick(disk.mountpoint)}
- />
+ >
+ Unmount
+
) : (
handleOpenMountModal(disk)}
- />
+ >
+ Mount
+
)}
@@ -1113,9 +1123,10 @@ function DiskSection() {
{!smartAvailable ? (
- SMART monitoring not available (pySMART not installed or not supported)
+ SMART monitoring not available (pySMART not installed or not
+ supported)
) : smartDrives.length > 0 ? (
-
+
Device
@@ -1129,10 +1140,9 @@ function DiskSection() {
{drive.device}
-
+
+ ●
+ {' '}
{drive.health || drive.assessment || 'Unknown'}
{drive.smart_limited ? ' (limited)' : ''}
@@ -1143,11 +1153,13 @@ function DiskSection() {
handleShowDetails(drive)}
- />
+ >
+ Details
+
))}
@@ -1172,12 +1184,19 @@ function DiskSection() {
+ title='Unmount Disk'
+ destructive
+ confirmLabel='Unmount'
+ onCancel={() => {
+ setUnmountConfirmOpen(false);
+ setUnmountTarget(null);
+ }}
+ onConfirm={handleUnmountConfirm}
+ >
+ {unmountTarget === PRIMARY_MOUNT ? (
+
Are you sure you want to unmount {unmountTarget}?
-
This is the primary WROLPi drive. The WROLPi API service will
be stopped before unmounting, and your media will be
unavailable until a drive is mounted at {PRIMARY_MOUNT} and
@@ -1185,19 +1204,12 @@ function DiskSection() {
) : `Are you sure you want to unmount ${unmountTarget}?`}
- onCancel={() => {
- setUnmountConfirmOpen(false);
- setUnmountTarget(null);
- }}
- onConfirm={handleUnmountConfirm}
- confirmButton='Unmount'
- />
+
-
-
+
Refresh
-
+
);
}
@@ -1271,35 +1283,29 @@ function SambaSection() {
};
if (loading) {
- return ;
+ return ;
}
if (!status || !status.available) {
if (dockerized) return null;
return (
-
-
-
- Network Shares (Samba)
-
- Samba is not available on this system.
-
+
+
+ Samba is not available on this system.
+
);
}
const shares = status.shares || [];
return (
-
-
-
- Network Shares (Samba)
-
+
+
Configure directories to share over the local network. Start the smbd service to activate sharing.
{shares.length > 0 && (
-
+
Name
@@ -1317,10 +1323,11 @@ function SambaSection() {
{share.read_only ? 'Yes' : 'No'}
{share.comment}
- setRemoveConfirmName(share.name)}
/>
@@ -1333,80 +1340,83 @@ function SambaSection() {
setRemoveConfirmName(null)}
onConfirm={() => handleRemoveShare(removeConfirmName)}
- />
+ >
+ {`Remove the share "${removeConfirmName}"?`}
+
setAddOpen(true)}
disabled={actionLoading}
- />
+ >
+ Add Share
+
- { setAddOpen(false); setShareAll(true); }} size='small'>
+ {
+ setAddOpen(false);
+ setShareAll(true);
+ }} size='small'>
Add Samba Share
-
- Share Name
- setNewName(value)}
+ setNewName(e.target.value)}
+ />
+
+ setShareAll(e.currentTarget.checked)}
+ />
+
+ {!shareAll && (
+
+ Path
+
-
-
- setShareAll(checked)}
- />
-
- {!shareAll && (
-
- Path
-
-
- )}
-
- setNewReadOnly(checked)}
- />
-
-
- Comment (optional)
- setNewComment(value)}
- />
-
-
+
+ )}
+
+ setNewReadOnly(e.currentTarget.checked)}
+ />
+
+ setNewComment(e.target.value)}
+ />
- setAddOpen(false)}>Cancel
+ setAddOpen(false)}>Cancel
+ >
+ Add Share
+
-
+
);
}
@@ -1493,99 +1503,93 @@ function HotspotSettingsForm() {
};
if (loading) {
- return
-
-
- ;
+ return
+
+
+ ;
}
// Keep the saved device selectable even if it is not currently present
// (e.g. a USB WiFi dongle is unplugged).
const deviceOptions = [...new Set([...devices, form.device].filter(Boolean))]
- .map(device => ({key: device, value: device, text: device}));
+ .map(device => ({value: device, label: device}));
// Keep the saved protocol selectable even if the device does not report it.
const protocolOptions = [...new Set([...protocols, form.protocol].filter(Boolean))]
- .map(protocol => ({key: protocol, value: protocol, text: hotspotProtocolLabels[protocol] || protocol}));
+ .map(protocol => ({value: protocol, label: hotspotProtocolLabels[protocol] || protocol}));
// Special string which allows a mobile device to connect to a specific Wi-Fi.
// The WPA QR format requires backslash-escaping of \ ; , " in the SSID and password.
const escapeWifi = (s) => s.replace(/([\\;,"])/g, '\\$1');
const qrCodeValue = `WIFI:S:${escapeWifi(form.ssid)};T:WPA;P:${escapeWifi(form.password)};;`;
- return
-
-
- setForm({...form, ssid: value})}
- />
- setForm({...form, password: value})}
- />
- setForm({...form, device: value})}
- />
- setForm({...form, protocol: value})}
- />
-
+ return
+
+
+ setForm({...form, ssid: e.target.value})}
+ />
+ setForm({...form, password: e.target.value})}
+ />
+ setForm({...form, device: value})}
+ />
+ setForm({...form, protocol: value})}
+ />
+
+
Save
-
setQrOpen(false)}
- onOpen={() => setQrOpen(true)}
- open={qrOpen}
- trigger={ }
- >
+ setQrOpen(true)}
+ />
+ setQrOpen(false)} closeIcon>
Scan this code to join the hotspot
-
+ {/* `media`: filtered as a unit by night mode; a QR code needs real black-on-white
+ contrast to scan, so it is exempted the same way DonatePage's and Share's are. */}
+
-
- ;
+
+ ;
}
function AdminControlsSection() {
const dockerized = useDockerized();
return (
-
-
+
+
@@ -1601,18 +1605,18 @@ function AdminControlsSection() {
)}
-
+
);
}
export function ControllerPage() {
return (
-
+
);
}
diff --git a/app/src/components/admin/ControllerPage.test.js b/app/src/components/admin/ControllerPage.test.js
index 9a1f789a..62ddad71 100644
--- a/app/src/components/admin/ControllerPage.test.js
+++ b/app/src/components/admin/ControllerPage.test.js
@@ -1,7 +1,9 @@
import React from 'react';
-import {fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {BrowserRouter} from 'react-router';
+import {MantineProvider} from '@mantine/core';
import {ThemeContext, SettingsContext} from '../../contexts/contexts';
+import {cssVariablesResolver, mantineTheme} from '../../themes/mantine';
import {ControllerPage} from './ControllerPage';
// Mock the controller API
@@ -134,11 +136,13 @@ const defaultSettings = {
const renderControllerPage = () => {
return render(
-
-
-
-
-
+
+
+
+
+
+
+
);
};
@@ -279,9 +283,11 @@ describe('DiskSection primary unmount', () => {
expect(screen.getByText(/API service will/)).toBeInTheDocument();
});
- // Semantic's Confirm renders confirmButton='Unmount' as the last match.
- const buttons = screen.getAllByText('Unmount');
- fireEvent.click(buttons[buttons.length - 1]);
+ // The Confirm modal is portaled to the end of , so it renders
+ // *before* the page content in DOM order -- scope the query to the
+ // dialog rather than assuming which match comes last.
+ const dialog = screen.getByRole('dialog');
+ fireEvent.click(within(dialog).getByText('Unmount'));
await waitFor(() => {
expect(controllerApi.unmountDisk).toHaveBeenCalledWith('/media/wrolpi');
diff --git a/app/src/semantic-baseline.json b/app/src/semantic-baseline.json
index 8f52ca63..d6769cfd 100644
--- a/app/src/semantic-baseline.json
+++ b/app/src/semantic-baseline.json
@@ -12,6 +12,5 @@
"src/components/Upload.js",
"src/components/VideoPlayer.js",
"src/components/Zim.js",
- "src/components/admin/ControllerPage.js",
"src/components/admin/Downloads.js"
]