Compare commits

..

12 Commits

Author SHA1 Message Date
Jack Kingsman 8843836bdf Use nginx docke 2026-03-31 21:37:44 -07:00
Jack Kingsman e631f9b0cc DHCP notes 2026-03-31 21:33:50 -07:00
Jack Kingsman b52431616e Be more sane with by-id aliases 2026-03-31 20:47:10 -07:00
Jack Kingsman 8446d99df1 Be more resistent for colons in the device ID 2026-03-31 20:23:07 -07:00
Jack Kingsman 8e1e913fcd Make all scripts executable 2026-03-31 18:26:04 -07:00
Jack Kingsman b74137dc72 Add trace clear and much better layout. Closes #139. 2026-03-31 16:55:21 -07:00
Jack Kingsman c83f9b0005 Rename Best RSSI to Strongest Neighbor. Closes #136. 2026-03-31 13:10:02 -07:00
Jack Kingsman 9f4737d350 Add hashtag link detection. Closes #134. 2026-03-31 12:55:52 -07:00
Jack Kingsman 29e9a5f701 Be more resilient in noise floor gathering 2026-03-31 12:35:35 -07:00
Jack Kingsman f0f06671cc Make new message button clearer 2026-03-31 12:28:47 -07:00
Jack Kingsman b1595e479c Use the image's full government name 2026-03-30 23:11:37 -07:00
Jack Kingsman 25df69bfbc Add snakeoil certs to docker setup 2026-03-30 22:13:09 -07:00
29 changed files with 744 additions and 108 deletions
+2
View File
@@ -29,3 +29,5 @@ references/
# local Docker compose files
docker-compose.yml
docker-compose.yaml
.docker-certs/
.docker-nginx/
+8 -6
View File
@@ -107,6 +107,8 @@ Source checkouts expect a normal frontend build in `frontend/dist`.
Local Docker builds are architecture-native by default. On Apple Silicon Macs and ARM64 Linux hosts such as Raspberry Pi, `docker compose build` / `docker compose up --build` will produce an ARM64 image unless you override the platform.
For serial-device passthrough, use rootful Docker. In practice that usually means starting the stack with `sudo docker compose ...` unless your Docker daemon is already configured for rootful access via your user/group. Rootless Docker has been observed to fail on serial-device mappings even when the compose file itself is correct.
Create a local `docker-compose.yml` in one of two ways:
1. Copy the example file and edit it by hand:
@@ -128,7 +130,7 @@ The guided Docker flow can collect BLE settings, but BLE access from Docker stil
Then customize the local compose file for your transport and launch:
```bash
docker compose up # -d for background once you validate it's working
sudo docker compose up # add -d for background once you validate it's working
```
The database is stored in `./data/` (bind-mounted), so the container shares the same database as the native app.
@@ -136,14 +138,14 @@ The database is stored in `./data/` (bind-mounted), so the container shares the
To rebuild after pulling updates:
```bash
docker compose pull
docker compose up -d
sudo docker compose pull
sudo docker compose up -d
```
The example file and setup script default to the published Docker Hub image. To build locally from your checkout instead, replace:
```yaml
image: jkingsman/remoteterm-meshcore:latest
image: docker.io/jkingsman/remoteterm-meshcore:latest
```
with:
@@ -155,7 +157,7 @@ build: .
Then run:
```bash
docker compose up -d --build
sudo docker compose up -d --build
```
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yml` to keep ownership aligned with your host user.
@@ -163,7 +165,7 @@ The container runs as root by default for maximum serial passthrough compatibili
To stop:
```bash
docker compose down
sudo docker compose down
```
## Standard Environment Variables
+11 -2
View File
@@ -60,8 +60,17 @@ async def sample_noise_floor_once(*, blocking: bool = False) -> None:
async def _noise_floor_sampling_loop() -> None:
while True:
await sample_noise_floor_once()
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
try:
await sample_noise_floor_once()
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Noise floor sampling loop crashed during sample")
try:
await asyncio.sleep(NOISE_FLOOR_SAMPLE_INTERVAL_SECONDS)
except asyncio.CancelledError:
raise
async def start_noise_floor_sampling() -> None:
+7 -4
View File
@@ -1,7 +1,7 @@
services:
remoteterm:
# build: .
image: jkingsman/remoteterm-meshcore:latest
image: docker.io/jkingsman/remoteterm-meshcore:latest
# Optional on Linux: run container as your host user to avoid root-owned files in ./data
# This is less reliable for serial-device access than running as root and may require
@@ -12,9 +12,12 @@ services:
volumes:
- ./data:/app/data
################################################
# Map your radio by stable device ID if available. #
################################################
#####################################################################
# Map your radio by stable device ID if available. #
# If your by-id path contains ':' characters, Docker Compose cannot #
# represent it here directly; use a colon-free host alias instead. #
# (e.g. /dev/ttyUSB0) #
#####################################################################
devices:
- /dev/serial/by-id/your-meshcore-radio:/dev/meshcore-radio
+40 -2
View File
@@ -31,6 +31,12 @@ interface ChannelUnreadMarker {
lastReadAt: number | null;
}
interface NewMessagePrefillRequest {
tab: 'hashtag';
hashtagName: string;
nonce: number;
}
interface UnreadBoundaryBackfillParams {
activeConversation: Conversation | null;
unreadMarker: ChannelUnreadMarker | null;
@@ -77,6 +83,8 @@ export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
useState<NewMessagePrefillRequest | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
@@ -103,8 +111,8 @@ export function App() {
setDistanceUnit,
handleCloseSettingsView,
handleToggleSettingsView,
handleOpenNewMessage,
handleCloseNewMessage,
handleOpenNewMessage: openNewMessageModal,
handleCloseNewMessage: closeNewMessageModal,
handleToggleCracker,
} = useAppShell();
@@ -413,6 +421,34 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const handleOpenNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null);
openNewMessageModal();
}, [openNewMessageModal]);
const handleCloseNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null);
closeNewMessageModal();
}, [closeNewMessageModal]);
const handleChannelReferenceClick = useCallback(
(channelName: string) => {
const existingChannel = channels.find((channel) => channel.name === channelName);
if (existingChannel) {
handleNavigateToChannel(existingChannel.key);
return;
}
setNewMessagePrefillRequest((previous) => ({
tab: 'hashtag',
hashtagName: channelName.slice(1),
nonce: (previous?.nonce ?? 0) + 1,
}));
openNewMessageModal();
},
[channels, handleNavigateToChannel, openNewMessageModal]
);
const statusProps = {
health,
config,
@@ -468,6 +504,7 @@ export function App() {
onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick,
onChannelReferenceClick: handleChannelReferenceClick,
onLoadOlder: fetchOlderMessages,
onResendChannelMessage: handleResendChannelMessage,
onTargetReached: () => setTargetMessageId(null),
@@ -526,6 +563,7 @@ export function App() {
};
const newMessageModalProps = {
undecryptedCount,
prefillRequest: newMessagePrefillRequest,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
@@ -65,6 +65,7 @@ interface ConversationPaneProps {
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
onOpenChannelInfo: (channelKey: string) => void;
onSenderClick: (sender: string) => void;
onChannelReferenceClick?: (channelName: string) => void;
onLoadOlder: () => Promise<void>;
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
onTargetReached: () => void;
@@ -131,6 +132,7 @@ export function ConversationPane({
onOpenContactInfo,
onOpenChannelInfo,
onSenderClick,
onChannelReferenceClick,
onLoadOlder,
onResendChannelMessage,
onTargetReached,
@@ -284,6 +286,7 @@ export function ConversationPane({
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onChannelReferenceClick={onChannelReferenceClick}
onLoadOlder={onLoadOlder}
onResendChannelMessage={
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
+96 -10
View File
@@ -11,7 +11,11 @@ import {
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
import { api } from '../api';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import {
findLinkedChannelReferences,
formatTime,
parseSenderFromText,
} from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
@@ -33,6 +37,7 @@ interface MessageListProps {
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
onChannelReferenceClick?: (channelName: string) => void;
radioName?: string;
config?: RadioConfig | null;
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
@@ -48,8 +53,64 @@ interface MessageListProps {
const URL_PATTERN =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
// Helper to convert URLs in a plain text string into clickable links
function linkifyText(text: string, keyPrefix: string): ReactNode[] {
function renderChannelReferences(
text: string,
keyPrefix: string,
onChannelReferenceClick?: (channelName: string) => void
): ReactNode[] {
const references = findLinkedChannelReferences(text);
if (references.length === 0) {
return [text];
}
const parts: ReactNode[] = [];
let lastIndex = 0;
references.forEach((reference, index) => {
if (reference.start > lastIndex) {
parts.push(text.slice(lastIndex, reference.start));
}
const className =
'rounded px-0.5 font-medium text-primary underline underline-offset-2 transition-colors';
if (onChannelReferenceClick) {
parts.push(
<button
key={`${keyPrefix}-channel-${index}`}
type="button"
className={cn(
className,
'inline border-0 bg-transparent p-0 align-baseline hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
)}
onClick={() => onChannelReferenceClick(reference.label)}
>
{reference.label}
</button>
);
} else {
parts.push(
<span key={`${keyPrefix}-channel-${index}`} className={className}>
{reference.label}
</span>
);
}
lastIndex = reference.end;
});
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
// Helper to convert URLs and channel references in a plain text string into rich content
function linkifyText(
text: string,
keyPrefix: string,
onChannelReferenceClick?: (channelName: string) => void
): ReactNode[] {
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
@@ -58,7 +119,13 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
URL_PATTERN.lastIndex = 0;
while ((match = URL_PATTERN.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
parts.push(
...renderChannelReferences(
text.slice(lastIndex, match.index),
`${keyPrefix}-text-${keyIndex}`,
onChannelReferenceClick
)
);
}
parts.push(
<a
@@ -74,15 +141,27 @@ function linkifyText(text: string, keyPrefix: string): ReactNode[] {
lastIndex = match.index + match[0].length;
}
if (lastIndex === 0) return [text];
if (lastIndex === 0) {
return renderChannelReferences(text, keyPrefix, onChannelReferenceClick);
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
parts.push(
...renderChannelReferences(
text.slice(lastIndex),
`${keyPrefix}-tail`,
onChannelReferenceClick
)
);
}
return parts;
}
// Helper to render text with highlighted @[Name] mentions and clickable URLs
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
function renderTextWithMentions(
text: string,
radioName?: string,
onChannelReferenceClick?: (channelName: string) => void
): ReactNode {
const mentionPattern = /@\[([^\]]+)\]/g;
const parts: ReactNode[] = [];
let lastIndex = 0;
@@ -92,7 +171,13 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
while ((match = mentionPattern.exec(text)) !== null) {
// Add text before the match (with linkification)
if (match.index > lastIndex) {
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
parts.push(
...linkifyText(
text.slice(lastIndex, match.index),
`pre-${keyIndex}`,
onChannelReferenceClick
)
);
}
const mentionedName = match[1];
@@ -115,7 +200,7 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
// Add remaining text after last match (with linkification)
if (lastIndex < text.length) {
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`, onChannelReferenceClick));
}
return parts.length > 0 ? parts : text;
@@ -188,6 +273,7 @@ export function MessageList({
onSenderClick,
onLoadOlder,
onResendChannelMessage,
onChannelReferenceClick,
radioName,
config,
onOpenContactInfo,
@@ -911,7 +997,7 @@ export function MessageList({
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{renderTextWithMentions(line, radioName, onChannelReferenceClick)}
{i < arr.length - 1 && <br />}
</span>
))}
+25 -1
View File
@@ -1,4 +1,4 @@
import { useState, useRef } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Dice5 } from 'lucide-react';
import {
Dialog,
@@ -20,6 +20,11 @@ type Tab = 'new-contact' | 'new-channel' | 'hashtag';
interface NewMessageModalProps {
open: boolean;
undecryptedCount: number;
prefillRequest?: {
tab: 'hashtag';
hashtagName: string;
nonce: number;
} | null;
onClose: () => void;
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
@@ -29,6 +34,7 @@ interface NewMessageModalProps {
export function NewMessageModal({
open,
undecryptedCount,
prefillRequest = null,
onClose,
onCreateContact,
onCreateChannel,
@@ -53,6 +59,24 @@ export function NewMessageModal({
setError('');
};
useEffect(() => {
if (!open || !prefillRequest) {
return;
}
setTab(prefillRequest.tab);
setName(prefillRequest.hashtagName);
setContactKey('');
setChannelKey('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
hashtagInputRef.current?.focus();
});
}, [open, prefillRequest]);
const handleCreate = async () => {
setError('');
setLoading(true);
+15 -17
View File
@@ -171,24 +171,17 @@ function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]):
return resolveContact(item.key, contacts) !== null;
}
function formatStrongestPacketDetail(
function formatStrongestNeighborDetail(
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
contacts: Contact[]
): string | undefined {
if (!stats.strongestPacketPayloadType) {
const strongestNeighbor = stats.strongestNeighbors[0];
if (!strongestNeighbor || strongestNeighbor.bestRssi === null) {
return undefined;
}
const resolvedLabel =
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
stats.strongestPacketSourceLabel;
if (resolvedLabel) {
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
}
if (stats.strongestPacketPayloadType === 'GroupText') {
return '<unknown sender> · GroupText';
}
return stats.strongestPacketPayloadType;
const resolvedNeighbor = resolveNeighbor(strongestNeighbor, contacts);
return `${formatRssi(resolvedNeighbor.bestRssi)} best heard`;
}
function getCoverageMessage(
@@ -450,8 +443,13 @@ export function RawPacketFeedView({
[nowSec, rawPacketStatsSession, selectedWindow]
);
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
const strongestPacketDetail = useMemo(
() => formatStrongestPacketDetail(stats, contacts),
const strongestNeighbor = useMemo(() => {
const topNeighbor = stats.strongestNeighbors[0];
return topNeighbor ? resolveNeighbor(topNeighbor, contacts) : null;
}, [contacts, stats]);
const strongestNeighborDetail = useMemo(
() => formatStrongestNeighborDetail(stats, contacts),
[contacts, stats]
);
const strongestNeighbors = useMemo(
@@ -578,9 +576,9 @@ export function RawPacketFeedView({
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
/>
<StatTile
label="Best RSSI"
value={formatRssi(stats.bestRssi)}
detail={strongestPacketDetail ?? 'No signal sample in window'}
label="Strongest Neighbor"
value={strongestNeighbor?.label ?? '-'}
detail={strongestNeighborDetail ?? 'No neighbor RSSI sample in window'}
/>
<StatTile
label="Median RSSI"
+29 -25
View File
@@ -853,41 +853,45 @@ export function Sidebar({
aria-label="Conversations"
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<div className="relative min-w-0 flex-1">
<Input
type="text"
placeholder="Search channels/contacts..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
/>
{searchQuery && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={() => setSearchQuery('')}
title="Clear search"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<div className="px-3 py-2 border-b border-border">
<Button
variant="ghost"
variant="outline"
size="sm"
onClick={onNewMessage}
title="New Message"
aria-label="New message"
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
title="Add channel or contact"
aria-label="Add channel or contact"
className="h-8 w-full justify-start gap-2 px-3 text-[13px]"
>
<SquarePen className="h-4 w-4" />
<span>Add Channel/Contact</span>
</Button>
</div>
{/* List */}
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
<div className="px-3 py-2 border-b border-border/60">
<div className="relative min-w-0">
<Input
type="text"
placeholder="Search channels/contacts..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
/>
{searchQuery && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={() => setSearchQuery('')}
title="Clear search"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Tools */}
{toolRows.length > 0 && (
<>
+43 -13
View File
@@ -318,8 +318,8 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
: [];
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto">
<div className="border-b border-border px-4 py-3">
<div className="flex h-full min-h-0 flex-col overflow-y-auto lg:overflow-hidden">
<div className="shrink-0 border-b border-border px-4 py-3">
<h2 className="text-base font-semibold">Trace</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
Build a repeater loop and trace it back to the local radio. The selectable hop list only
@@ -329,7 +329,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
<div className="flex flex-1 flex-col gap-4 p-4 lg:min-h-0 lg:flex-row lg:overflow-hidden">
<section className="flex w-full flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-w-[24rem]">
<div className="border-b border-border p-4">
<div className="shrink-0 border-b border-border p-4">
<h3 className="text-sm font-semibold">Repeater Hops</h3>
<p className="mt-1 text-xs text-muted-foreground">
Search by name or key, then add repeaters in the order you want to traverse them.
@@ -446,14 +446,30 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
</section>
<section className="flex flex-1 flex-col gap-4 lg:min-h-0 lg:overflow-hidden">
<div className="rounded-lg border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">Trace Path</h3>
<p className="mt-1 text-xs text-muted-foreground">
The first node is display-only. The terminal node is the local radio.
</p>
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-h-[50%]">
<div className="shrink-0 flex items-start justify-between gap-3 border-b border-border px-4 py-3">
<div>
<h3 className="text-sm font-semibold">Trace Path</h3>
<p className="mt-1 text-xs text-muted-foreground">
The first node is display-only. The terminal node is the local radio.
</p>
</div>
{draftHops.length > 0 ? (
<Button
type="button"
size="sm"
variant="ghost"
className="shrink-0 text-muted-foreground"
onClick={() => {
setDraftHops([]);
clearPendingResult();
}}
>
Clear
</Button>
) : null}
</div>
<div className="max-h-[42vh] space-y-2 overflow-y-auto p-4 lg:max-h-none lg:overflow-y-visible">
<div className="space-y-2 p-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
<TraceNodeRow
title={localRadioName}
subtitle={getShortKey(localRadioKey)}
@@ -542,7 +558,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
compact
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
<div className="shrink-0 flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
<div className="text-xs text-muted-foreground">
{draftHops.length === 0
? 'No hops selected'
@@ -555,12 +571,26 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
</div>
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:flex-1">
<div className="border-b border-border px-4 py-3">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">
Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
</h3>
{result || error ? (
<Button
type="button"
size="sm"
variant="ghost"
className="shrink-0 text-muted-foreground"
onClick={() => {
setResult(null);
setError(null);
}}
>
Clear
</Button>
) : null}
</div>
<div className="max-h-[42vh] min-h-0 flex-1 space-y-3 overflow-y-auto p-4 lg:max-h-none">
<div className="min-h-0 flex-1 space-y-3 p-4 lg:overflow-y-auto">
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
+53
View File
@@ -140,6 +140,59 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
});
it('renders valid channel references as clickable links and ignores invalid ones', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
render(
<MessageList
messages={[
createMessage({
text: 'Alice: Join #mesh-room now skip #bad--room and visit https://example.com/#also-skip',
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
const linkedChannel = screen.getByRole('button', { name: '#mesh-room' });
expect(linkedChannel).toBeInTheDocument();
expect(screen.queryByRole('button', { name: '#bad--room' })).not.toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'https://example.com/#also-skip' })
).toBeInTheDocument();
await user.click(linkedChannel);
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
});
it('links valid channel references in direct messages too', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
render(
<MessageList
messages={[
createMessage({
type: 'PRIV',
text: 'check #ops-room',
conversation_key: 'ab'.repeat(32),
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
await user.click(screen.getByRole('button', { name: '#ops-room' }));
expect(onChannelReferenceClick).toHaveBeenCalledWith('#ops-room');
});
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
const user = userEvent.setup();
const messages = [
+36 -1
View File
@@ -6,7 +6,12 @@
*/
import { describe, it, expect } from 'vitest';
import { parseSenderFromText, formatTime } from '../utils/messageParser';
import {
findLinkedChannelReferences,
formatTime,
isValidLinkedChannelName,
parseSenderFromText,
} from '../utils/messageParser';
describe('parseSenderFromText', () => {
it('extracts sender and content from "sender: message" format', () => {
@@ -95,3 +100,33 @@ describe('formatTime', () => {
expect(result).toMatch(/\d{1,2}:\d{2}/); // time portion
});
});
describe('linked channel references', () => {
it('accepts lowercase alphanumeric names with single dashes', () => {
expect(isValidLinkedChannelName('ops')).toBe(true);
expect(isValidLinkedChannelName('ops-1')).toBe(true);
expect(isValidLinkedChannelName('1-2-3')).toBe(true);
});
it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => {
expect(isValidLinkedChannelName('Ops')).toBe(false);
expect(isValidLinkedChannelName('-ops')).toBe(false);
expect(isValidLinkedChannelName('ops-')).toBe(false);
expect(isValidLinkedChannelName('ops--room')).toBe(false);
});
it('finds standalone linked channel references in message text', () => {
expect(findLinkedChannelReferences('Join #mesh-room then say hi in #ops2')).toEqual([
{ label: '#mesh-room', start: 5, end: 15 },
{ label: '#ops2', start: 31, end: 36 },
]);
});
it('ignores invalid or embedded channel-like text', () => {
expect(
findLinkedChannelReferences(
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
)
).toEqual([]);
});
});
+25 -1
View File
@@ -32,7 +32,10 @@ describe('NewMessageModal form reset', () => {
vi.clearAllMocks();
});
function renderModal(open = true) {
function renderModal(
open = true,
overrides: Partial<Parameters<typeof NewMessageModal>[0]> = {}
) {
return render(
<NewMessageModal
open={open}
@@ -41,6 +44,7 @@ describe('NewMessageModal form reset', () => {
onCreateContact={onCreateContact}
onCreateChannel={onCreateChannel}
onCreateHashtagChannel={onCreateHashtagChannel}
{...overrides}
/>
);
}
@@ -50,6 +54,26 @@ describe('NewMessageModal form reset', () => {
}
describe('hashtag tab', () => {
it('prefills the hashtag tab from a linked channel request', async () => {
renderModal(true, {
prefillRequest: {
tab: 'hashtag',
hashtagName: 'mesh-room',
nonce: 1,
},
});
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Hashtag Channel' })).toHaveAttribute(
'data-state',
'active'
);
});
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe(
'mesh-room'
);
});
it('clears name after successful Create', async () => {
const user = userEvent.setup();
const { unmount } = renderModal();
@@ -283,6 +283,8 @@ describe('RawPacketFeedView', () => {
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
expect(screen.getByText('Strongest Neighbor')).toBeInTheDocument();
expect(screen.getByText('-70 dBm best heard')).toBeInTheDocument();
});
it('marks unresolved neighbor identities explicitly', () => {
+40
View File
@@ -122,6 +122,46 @@ describe('Sidebar section summaries', () => {
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
});
it('renders a full add channel/contact button above search and calls onNewMessage', () => {
const onNewMessage = vi.fn();
render(
<Sidebar
contacts={[]}
channels={[makeChannel(PUBLIC_CHANNEL_KEY, 'Public')]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={onNewMessage}
lastMessageTimes={{}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
const addButton = screen.getByRole('button', { name: 'Add channel or contact' });
const search = screen.getByLabelText('Search conversations');
const nav = screen.getByRole('navigation', { name: 'Conversations' });
const toolsButton = screen.getByRole('button', { name: 'Tools' });
expect(addButton).toHaveTextContent('Add Channel/Contact');
expect(
addButton.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(nav.compareDocumentPosition(search) & Node.DOCUMENT_POSITION_CONTAINED_BY).toBeTruthy();
expect(
search.compareDocumentPosition(toolsButton) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
fireEvent.click(addButton);
expect(onNewMessage).toHaveBeenCalledTimes(1);
});
it('turns favorites and channels rollups red when they contain a mention', () => {
renderSidebar({
mentions: {
+32
View File
@@ -2,6 +2,9 @@
* Parse sender from channel message text.
* Channel messages have format "sender: message".
*/
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
const colonIndex = text.indexOf(': ');
if (colonIndex > 0 && colonIndex < 50) {
@@ -17,6 +20,35 @@ export function parseSenderFromText(text: string): { sender: string | null; cont
return { sender: null, content: text };
}
export interface HashtagChannelReference {
label: string;
start: number;
end: number;
}
export function isValidLinkedChannelName(name: string): boolean {
return HASHTAG_CHANNEL_NAME_PATTERN.test(name);
}
export function findLinkedChannelReferences(text: string): HashtagChannelReference[] {
const references: HashtagChannelReference[] = [];
let match: RegExpExecArray | null;
HASHTAG_CHANNEL_REFERENCE_PATTERN.lastIndex = 0;
while ((match = HASHTAG_CHANNEL_REFERENCE_PATTERN.exec(text)) !== null) {
const prefix = match[1];
const label = match[2];
const start = match.index + prefix.length;
references.push({
label,
start,
end: start + label.length,
});
}
return references;
}
/**
* Format a Unix timestamp to a time string.
* Shows date for messages not from today.
-12
View File
@@ -106,9 +106,6 @@ export interface RawPacketStatsSnapshot {
medianRssi: number | null;
bestRssi: number | null;
rssiBuckets: RankedPacketStat[];
strongestPacketSourceKey: string | null;
strongestPacketSourceLabel: string | null;
strongestPacketPayloadType: string | null;
coverageSeconds: number;
windowFullyCovered: boolean;
oldestStoredTimestamp: number | null;
@@ -377,8 +374,6 @@ export function buildRawPacketStatsSnapshot(
['Weak (<-85 dBm)', 0],
]);
let strongestPacket: RawPacketStatsObservation | null = null;
for (const packet of packets) {
payloadCounts.set(packet.payloadType, (payloadCounts.get(packet.payloadType) ?? 0) + 1);
routeCounts.set(packet.routeType, (routeCounts.get(packet.routeType) ?? 0) + 1);
@@ -436,10 +431,6 @@ export function buildRawPacketStatsSnapshot(
} else {
rssiBucketCounts.set('Weak (<-85 dBm)', (rssiBucketCounts.get('Weak (<-85 dBm)') ?? 0) + 1);
}
if (!strongestPacket || strongestPacket.rssi === null || packet.rssi > strongestPacket.rssi) {
strongestPacket = packet;
}
}
}
@@ -527,9 +518,6 @@ export function buildRawPacketStatsSnapshot(
medianRssi,
bestRssi,
rssiBuckets: rankedBreakdown(rssiBucketCounts, rssiValues.length),
strongestPacketSourceKey: strongestPacket?.sourceKey ?? null,
strongestPacketSourceLabel: strongestPacket?.sourceLabel ?? null,
strongestPacketPayloadType: strongestPacket?.payloadType ?? null,
coverageSeconds,
windowFullyCovered,
oldestStoredTimestamp,
View File
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
Regular → Executable
View File
View File
View File
Regular → Executable
+240 -14
View File
@@ -21,10 +21,21 @@ NC='\033[0m'
REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
COMPOSE_FILE="$REPO_DIR/docker-compose.yml"
EXAMPLE_FILE="$REPO_DIR/docker-compose.example.yml"
SNAKEOIL_CERT_DIR="$REPO_DIR/.docker-certs"
NGINX_CONFIG_DIR="$REPO_DIR/.docker-nginx"
NGINX_CONFIG_BASENAME="remoteterm.conf"
NGINX_CONFIG_HOST_PATH="$NGINX_CONFIG_DIR/$NGINX_CONFIG_BASENAME"
SNAKEOIL_CERT_BASENAME="remoteterm-snakeoil.crt"
SNAKEOIL_KEY_BASENAME="remoteterm-snakeoil.key"
SNAKEOIL_CERT_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_CERT_BASENAME"
SNAKEOIL_KEY_HOST_PATH="$SNAKEOIL_CERT_DIR/$SNAKEOIL_KEY_BASENAME"
SNAKEOIL_CERT_CONTAINER_PATH="/etc/nginx/certs/$SNAKEOIL_CERT_BASENAME"
SNAKEOIL_KEY_CONTAINER_PATH="/etc/nginx/certs/$SNAKEOIL_KEY_BASENAME"
IMAGE_MODE="image"
TRANSPORT_MODE="serial"
SERIAL_HOST_PATH="/dev/ttyACM0"
SERIAL_COMPOSE_HOST_PATH="/dev/ttyACM0"
SERIAL_CONTAINER_PATH="/dev/meshcore-radio"
TCP_HOST=""
TCP_PORT="4000"
@@ -35,7 +46,9 @@ ENABLE_AUTH="N"
AUTH_USERNAME=""
AUTH_PASSWORD=""
RUN_AS_HOST_USER="N"
ENABLE_SNAKEOIL_TLS="Y"
BLE_MANUAL_WARNING=false
LOCAL_ACCESS_IP=""
SERIAL_FOUND_HOST_PATHS=()
SERIAL_FOUND_LABELS=()
SERIAL_FOUND_DISPLAYS=()
@@ -89,6 +102,161 @@ yaml_quote() {
printf "'%s'" "$value"
}
normalize_serial_host_path_for_compose() {
local selected_path="$1"
local resolved_path=""
if [[ "$selected_path" != *:* ]]; then
SERIAL_COMPOSE_HOST_PATH="$selected_path"
return 0
fi
resolved_path="$(readlink -f "$selected_path" 2>/dev/null || true)"
if [ -z "$resolved_path" ]; then
echo -e "${RED}Error:${NC} the selected serial path contains ':' and could not be resolved to a raw /dev/tty-style device path."
echo "Selected path: $selected_path"
echo "Please enter the raw serial device path instead (for example /dev/ttyACM0)."
exit 1
fi
if [[ "$resolved_path" == *:* ]]; then
echo -e "${RED}Error:${NC} the selected serial path still resolves to a path containing ':', which Docker Compose cannot use here."
echo "Selected path: $selected_path"
echo "Resolved path: $resolved_path"
echo "Please enter the raw serial device path instead (for example /dev/ttyACM0)."
exit 1
fi
echo -e "${YELLOW}Note:${NC} the selected serial path contains ':', so Docker Compose will use the resolved raw device path instead: ${resolved_path}"
SERIAL_COMPOSE_HOST_PATH="$resolved_path"
}
detect_primary_local_ip() {
local ip=""
local iface=""
if command -v hostname &>/dev/null; then
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
fi
if [ -z "$ip" ] && command -v ip &>/dev/null; then
ip="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for (i = 1; i <= NF; i++) if ($i == "src") {print $(i + 1); exit}}')"
fi
if [ -z "$ip" ] && command -v route &>/dev/null && command -v ipconfig &>/dev/null; then
iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')"
if [ -n "$iface" ]; then
ip="$(ipconfig getifaddr "$iface" 2>/dev/null || true)"
fi
fi
if [ -z "$ip" ]; then
ip="127.0.0.1"
fi
printf '%s' "$ip"
}
ensure_snakeoil_requirements() {
local dep
for dep in openssl mktemp; do
if ! command -v "$dep" &>/dev/null; then
echo -e "${RED}Error: ${dep} is required to generate the snakeoil TLS certificate.${NC}"
exit 1
fi
done
}
generate_snakeoil_certificate() {
local san_ip="$1"
local tmp_config=""
mkdir -p "$SNAKEOIL_CERT_DIR"
tmp_config="$(mktemp)"
cat >"$tmp_config" <<EOF
[req]
default_bits = 2048
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
CN = RemoteTerm Snakeoil
O = RemoteTerm for MeshCore
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = localhost
IP.1 = 127.0.0.1
EOF
if [ -n "$san_ip" ] && [ "$san_ip" != "127.0.0.1" ]; then
printf 'IP.2 = %s\n' "$san_ip" >>"$tmp_config"
fi
openssl req \
-x509 \
-nodes \
-newkey rsa:2048 \
-days 3650 \
-keyout "$SNAKEOIL_KEY_HOST_PATH" \
-out "$SNAKEOIL_CERT_HOST_PATH" \
-config "$tmp_config" \
-extensions v3_req >/dev/null 2>&1
rm -f "$tmp_config"
chmod 600 "$SNAKEOIL_KEY_HOST_PATH"
chmod 644 "$SNAKEOIL_CERT_HOST_PATH"
}
generate_nginx_tls_config() {
mkdir -p "$NGINX_CONFIG_DIR"
cat >"$NGINX_CONFIG_HOST_PATH" <<EOF
server {
listen 80;
server_name _;
return 308 https://\$host:8000\$request_uri;
}
server {
listen 443 ssl;
server_name _;
ssl_certificate $SNAKEOIL_CERT_CONTAINER_PATH;
ssl_certificate_key $SNAKEOIL_KEY_CONTAINER_PATH;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
location /api/ws {
proxy_pass http://remoteterm:8000/api/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 8000;
}
location / {
proxy_pass http://remoteterm:8000;
proxy_http_version 1.1;
proxy_set_header Host \$host;
proxy_set_header X-Forwarded-Host \$host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 8000;
proxy_read_timeout 300s;
}
}
EOF
}
echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}"
echo
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
@@ -183,7 +351,8 @@ case "$TRANSPORT_CHOICE" in
fi
fi
echo -e "${GREEN}Serial passthrough: ${SERIAL_HOST_PATH} -> ${SERIAL_CONTAINER_PATH}${NC}"
normalize_serial_host_path_for_compose "$SERIAL_HOST_PATH"
echo -e "${GREEN}Serial passthrough: ${SERIAL_COMPOSE_HOST_PATH} -> ${SERIAL_CONTAINER_PATH}${NC}"
;;
2)
TRANSPORT_MODE="tcp"
@@ -266,6 +435,26 @@ else
fi
echo
echo -e "${BOLD}─── HTTPS / Snakeoil TLS ────────────────────────────────────────────${NC}"
echo "Generating a local self-signed certificate enables HTTPS-only browser features"
echo "such as the channel key finder and, in some browsers, notifications."
echo "Browsers will still warn that the certificate is untrusted."
echo
read -r -p "Generate and enable a snakeoil TLS certificate? [Y/n]: " ENABLE_SNAKEOIL_TLS
ENABLE_SNAKEOIL_TLS="${ENABLE_SNAKEOIL_TLS:-Y}"
LOCAL_ACCESS_IP="$(detect_primary_local_ip)"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
ensure_snakeoil_requirements
generate_snakeoil_certificate "$LOCAL_ACCESS_IP"
generate_nginx_tls_config
echo -e "${GREEN}Generated snakeoil TLS certificate in ${SNAKEOIL_CERT_DIR}.${NC}"
echo -e "${GREEN}Generated nginx TLS proxy config in ${NGINX_CONFIG_DIR}.${NC}"
echo -e "${YELLOW}Browsers will show an untrusted/self-signed certificate warning.${NC}"
else
echo -e "${GREEN}Skipping snakeoil TLS generation. The container will serve plain HTTP.${NC}"
fi
echo
if [ "$(uname -s)" = "Linux" ]; then
echo -e "${BOLD}─── Container User ──────────────────────────────────────────────────${NC}"
echo "The container runs as root by default for maximum serial compatibility."
@@ -295,18 +484,23 @@ mkdir -p "$REPO_DIR/data"
if [ "$IMAGE_MODE" = "build" ]; then
echo " build: ."
else
echo " image: jkingsman/remoteterm-meshcore:latest"
echo " image: docker.io/jkingsman/remoteterm-meshcore:latest"
fi
if [[ "$RUN_AS_HOST_USER" =~ ^[Yy]$ ]]; then
echo " user: \"$(id -u):$(id -g)\""
fi
echo " ports:"
echo " - \"8000:8000\""
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " expose:"
echo " - \"8000\""
else
echo " ports:"
echo " - \"8000:8000\""
fi
echo " volumes:"
echo " - ./data:/app/data"
if [ "$TRANSPORT_MODE" = "serial" ]; then
echo " devices:"
echo " - ${SERIAL_HOST_PATH}:${SERIAL_CONTAINER_PATH}"
echo " - ${SERIAL_COMPOSE_HOST_PATH}:${SERIAL_CONTAINER_PATH}"
fi
echo " environment:"
echo " MESHCORE_DATABASE_PATH: $(yaml_quote "data/meshcore.db")"
@@ -327,21 +521,42 @@ mkdir -p "$REPO_DIR/data"
echo " MESHCORE_BASIC_AUTH_PASSWORD: $(yaml_quote "$AUTH_PASSWORD")"
fi
echo " restart: unless-stopped"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo " nginx:"
echo " image: nginx:alpine"
echo " depends_on:"
echo " - remoteterm"
echo " ports:"
echo " - \"80:80\""
echo " - \"8000:443\""
echo " volumes:"
echo " - ./.docker-certs:/etc/nginx/certs:ro"
echo " - ./.docker-nginx/$NGINX_CONFIG_BASENAME:/etc/nginx/conf.d/default.conf:ro"
echo " restart: unless-stopped"
fi
} >"$COMPOSE_FILE"
echo -e "${GREEN}Generated ${COMPOSE_FILE}.${NC}"
echo
echo -e "${BOLD}Docker commands${NC}"
if [ "$IMAGE_MODE" = "build" ]; then
echo " docker compose up -d --build # build the local image and start RemoteTerm in the background"
echo " sudo docker compose up -d --build # build the local image and start RemoteTerm in the background"
else
echo " docker compose up -d # start RemoteTerm in the background"
echo " sudo docker compose up -d # start RemoteTerm in the background"
fi
echo " docker compose logs -f # follow the container logs live"
echo " sudo docker compose logs -f # follow the container logs live"
echo
echo " docker compose down # stop and remove the running container"
echo " docker compose restart # restart the container without changing the image"
echo " docker compose pull && docker compose up -d # upgrade to the latest published image and restart"
echo " sudo docker compose down # stop and remove the running container"
echo " sudo docker compose restart # restart the container without changing the image"
echo " sudo docker compose pull && sudo docker compose up -d # upgrade to the latest published image and restart"
echo
echo -e "${YELLOW}Note:${NC} serial passthrough generally needs ${BOLD}rootful Docker${NC}."
echo "If Docker is running rootless on this host, serial-device mappings may fail even with a valid compose file."
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo
echo -e "${GREEN}HTTPS will be handled by an nginx sidecar.${NC}"
echo "Host port 80 will redirect to HTTPS on port 8000."
fi
if [ "$TRANSPORT_MODE" = "ble" ] || [ "$BLE_MANUAL_WARNING" = true ]; then
echo
echo -e "${RED}BLE requires more than the generated env vars.${NC}"
@@ -351,6 +566,17 @@ echo
echo -e "${GREEN}Your new docker file is ready at ${COMPOSE_FILE}.${NC}"
echo -e "${GREEN}Feel free to edit it by hand as desired, or:${NC}"
echo
echo -e "${PURPLE}┌──────────────────────────────────────────────┐${NC}"
echo -e "${PURPLE} Run ${GREEN}${BOLD}docker compose up -d${NC}${PURPLE} to get started. ${NC}"
echo -e "${PURPLE}└──────────────────────────────────────────────┘${NC}"
echo -e "${PURPLE}┌──────────────────────────────────────────────${NC}"
echo -e "${PURPLE}│ Run ${GREEN}${BOLD}sudo docker compose up -d${NC}${PURPLE} to get started. │${NC}"
echo -e "${PURPLE}└──────────────────────────────────────────────${NC}"
if [[ "$ENABLE_SNAKEOIL_TLS" =~ ^[Yy]$ ]]; then
echo
echo -e "After the container starts, open ${CYAN}https://${LOCAL_ACCESS_IP}:8000${NC}. Note that this address may change if you use DHCP/have not configured a static IP for your host via your router."
echo -e "Plain HTTP on ${CYAN}http://${LOCAL_ACCESS_IP}${NC} will redirect there automatically."
echo -e "${YELLOW}Expect an untrusted/self-signed certificate warning the first time you connect.${NC}"
else
echo
echo -e "After the container starts, open ${CYAN}http://${LOCAL_ACCESS_IP}:8000${NC}. Note that this address may change if you use DHCP/have not configured a static IP for your host via your router."
fi
echo "If the interface does not appear, follow the logs to view errors with:"
echo " sudo docker compose logs -f"
Regular → Executable
View File
+37
View File
@@ -0,0 +1,37 @@
import asyncio
from unittest.mock import patch
import pytest
from app.services import radio_noise_floor
class TestNoiseFloorSamplingLoop:
@pytest.mark.asyncio
async def test_logs_and_continues_after_unexpected_sample_exception(self):
sample_calls = 0
sleep_calls = 0
async def fake_sample() -> None:
nonlocal sample_calls
sample_calls += 1
if sample_calls == 1:
raise RuntimeError("boom")
async def fake_sleep(_seconds: int) -> None:
nonlocal sleep_calls
sleep_calls += 1
if sleep_calls >= 2:
raise asyncio.CancelledError()
with (
patch.object(radio_noise_floor, "sample_noise_floor_once", side_effect=fake_sample),
patch.object(radio_noise_floor.asyncio, "sleep", side_effect=fake_sleep),
patch.object(radio_noise_floor.logger, "exception") as mock_exception,
):
with pytest.raises(asyncio.CancelledError):
await radio_noise_floor._noise_floor_sampling_loop()
assert sample_calls == 2
assert sleep_calls == 2
mock_exception.assert_called_once()