mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-03 15:32:41 +02:00
Suck less at settings UI (help me I'm not a designer)
This commit is contained in:
+3
-1
@@ -453,7 +453,9 @@ Do not rely on old class-only layout assumptions.
|
||||
Key conventions documented in the reference:
|
||||
|
||||
- **Text sizes** use `rem`-based Tailwind values so they scale with the user's font-size slider. Do not use hard-locked `px` values (e.g., `text-[10px]`). The canonical sizes are `text-[0.625rem]` (10px), `text-[0.6875rem]` (11px), `text-[0.8125rem]` (13px), plus standard Tailwind `text-xs`/`text-sm`/`text-base`/`text-lg`/`text-xl`.
|
||||
- **Section labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium`.
|
||||
- **Group titles** (sub-section headings within settings tabs) use `<h3 className="text-base font-semibold tracking-tight">`. These separate major groups like "Connection", "Identity", "MQTT Broker". When a group contains named sub-items (e.g. "Contact Management" → "Blocked Contacts", "Bulk Delete"), use `<h4 className="text-sm font-semibold">` for the children and nest them inside the parent group's `div` instead of separating with `<Separator />`.
|
||||
- **Helper / description text** uses `text-[0.8125rem] text-muted-foreground` (13px). This is for explanatory paragraphs under inputs or sections — not for metadata, timestamps, or alert text which stay at `text-xs`.
|
||||
- **Metadata labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium` for compact category tags like "Push-enabled conversations" or "Registered Devices".
|
||||
- **Buttons** use the shadcn `<Button>` component. Semantic color overrides (danger, warning, success) use `variant="outline"` with `className="border-{color}/50 text-{color} hover:bg-{color}/10"`.
|
||||
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
|
||||
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
|
||||
|
||||
@@ -185,7 +185,7 @@ export function SettingsDatabaseSection({
|
||||
<div className={className}>
|
||||
{/* ── Database Overview ── */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">Database Overview</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">Database Overview</h3>
|
||||
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm">Database size</span>
|
||||
@@ -212,11 +212,11 @@ export function SettingsDatabaseSection({
|
||||
|
||||
{/* ── Storage Cleanup ── */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base">Storage Cleanup</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">Storage Cleanup</h3>
|
||||
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<Label className="text-sm">Delete Undecrypted Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h3 className="text-sm font-semibold">Delete Undecrypted Packets</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Permanently deletes stored raw packets that have not yet been decrypted. These are
|
||||
retained in case you later obtain the correct key — once deleted, these messages can
|
||||
never be recovered.
|
||||
@@ -248,8 +248,8 @@ export function SettingsDatabaseSection({
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-border p-3 space-y-2">
|
||||
<Label className="text-sm">Purge Archival Raw Packets</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h3 className="text-sm font-semibold">Purge Archival Raw Packets</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Deletes the raw packet bytes behind messages that are already decrypted and visible in
|
||||
chat. This frees space but removes packet-analysis availability for those messages. It
|
||||
does not affect displayed messages or future decryption.
|
||||
@@ -269,7 +269,7 @@ export function SettingsDatabaseSection({
|
||||
|
||||
{/* ── DM Decryption ── */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">DM Decryption</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">DM Decryption</h3>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -286,7 +286,7 @@ export function SettingsDatabaseSection({
|
||||
/>
|
||||
<span className="text-sm">Auto-decrypt historical DMs when new contact advertises</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When enabled, the server will automatically try to decrypt stored DM packets when a new
|
||||
contact sends an advertisement. This may cause brief delays on large packet backlogs.
|
||||
</p>
|
||||
@@ -296,8 +296,8 @@ export function SettingsDatabaseSection({
|
||||
|
||||
{/* ── Tracked Repeater Telemetry ── */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">Tracked Repeater Telemetry</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h3 className="text-base font-semibold tracking-tight">Tracked Repeater Telemetry</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
|
||||
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
|
||||
repeaters — so fewer tracked repeaters allows shorter intervals, and more tracked
|
||||
@@ -427,145 +427,143 @@ export function SettingsDatabaseSection({
|
||||
<Separator />
|
||||
|
||||
{/* ── Contact Management ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Contact Management</Label>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-base font-semibold tracking-tight">Contact Management</h3>
|
||||
|
||||
{/* Block discovery of new node types */}
|
||||
<div className="space-y-3">
|
||||
<Label>Block Discovery of New Node Types</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Checked types will be ignored when heard via advertisement. Existing contacts of these
|
||||
types are still updated. This does not affect contacts added manually or via DM.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{(
|
||||
[
|
||||
[1, 'Block clients'],
|
||||
[2, 'Block repeaters'],
|
||||
[3, 'Block room servers'],
|
||||
[4, 'Block sensors'],
|
||||
] as const
|
||||
).map(([typeCode, label]) => {
|
||||
const checked = discoveryBlockedTypes.includes(typeCode);
|
||||
return (
|
||||
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const prev = discoveryBlockedTypes;
|
||||
const next = checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode];
|
||||
setDiscoveryBlockedTypes(next);
|
||||
void persistAppSettings({ discovery_blocked_types: next }, () =>
|
||||
setDiscoveryBlockedTypes(prev)
|
||||
);
|
||||
}}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{discoveryBlockedTypes.length > 0 && (
|
||||
<p className="text-xs text-warning">
|
||||
New{' '}
|
||||
{discoveryBlockedTypes
|
||||
.map((t) =>
|
||||
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
|
||||
)
|
||||
.join(', ')}{' '}
|
||||
heard via advertisement will not be added to your contact list.
|
||||
{/* Block discovery of new node types */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Block Discovery of New Node Types</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Checked types will be ignored when heard via advertisement. Existing contacts of these
|
||||
types are still updated. This does not affect contacts added manually or via DM.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Blocked contacts list */}
|
||||
<div className="space-y-3">
|
||||
<Label>Blocked Contacts</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||
reappear if unblocked.
|
||||
</p>
|
||||
|
||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No blocked contacts. Block contacts from their info pane, viewed by clicking their
|
||||
avatar in any channel, or their name within the top status bar with the conversation
|
||||
open.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockedKeys.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedKeys.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono truncate flex-1">{key}</span>
|
||||
{onToggleBlockedKey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedKey(key)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{blockedNames.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedNames.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate flex-1">{name}</span>
|
||||
{onToggleBlockedName && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedName(name)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
{(
|
||||
[
|
||||
[1, 'Block clients'],
|
||||
[2, 'Block repeaters'],
|
||||
[3, 'Block room servers'],
|
||||
[4, 'Block sensors'],
|
||||
] as const
|
||||
).map(([typeCode, label]) => {
|
||||
const checked = discoveryBlockedTypes.includes(typeCode);
|
||||
return (
|
||||
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => {
|
||||
const prev = discoveryBlockedTypes;
|
||||
const next = checked
|
||||
? prev.filter((t) => t !== typeCode)
|
||||
: [...prev, typeCode];
|
||||
setDiscoveryBlockedTypes(next);
|
||||
void persistAppSettings({ discovery_blocked_types: next }, () =>
|
||||
setDiscoveryBlockedTypes(prev)
|
||||
);
|
||||
}}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{discoveryBlockedTypes.length > 0 && (
|
||||
<p className="text-xs text-warning">
|
||||
New{' '}
|
||||
{discoveryBlockedTypes
|
||||
.map((t) =>
|
||||
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
|
||||
)
|
||||
.join(', ')}{' '}
|
||||
heard via advertisement will not be added to your contact list.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
{/* Blocked contacts list */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Blocked Contacts</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI —
|
||||
MQTT forwarding and bot responses are not affected. Messages are still stored and will
|
||||
reappear if unblocked.
|
||||
</p>
|
||||
|
||||
{/* Bulk delete */}
|
||||
<div className="space-y-3">
|
||||
<Label>Bulk Delete Contacts</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
|
||||
nodes. Message history will be preserved.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
|
||||
Open Bulk Delete
|
||||
</Button>
|
||||
<BulkDeleteContactsModal
|
||||
open={bulkDeleteOpen}
|
||||
onClose={() => setBulkDeleteOpen(false)}
|
||||
contacts={contacts}
|
||||
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
|
||||
/>
|
||||
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
No blocked contacts. Block contacts from their info pane, viewed by clicking their
|
||||
avatar in any channel, or their name within the top status bar with the conversation
|
||||
open.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{blockedKeys.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedKeys.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-mono truncate flex-1">{key}</span>
|
||||
{onToggleBlockedKey && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedKey(key)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{blockedNames.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
|
||||
<div className="mt-1 space-y-1">
|
||||
{blockedNames.map((name) => (
|
||||
<div key={name} className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm truncate flex-1">{name}</span>
|
||||
{onToggleBlockedName && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleBlockedName(name)}
|
||||
className="h-7 text-xs flex-shrink-0"
|
||||
>
|
||||
Unblock
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk delete */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Bulk Delete Contacts</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
|
||||
nodes. Message history will be preserved.
|
||||
</p>
|
||||
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
|
||||
Open Bulk Delete
|
||||
</Button>
|
||||
<BulkDeleteContactsModal
|
||||
open={bulkDeleteOpen}
|
||||
onClose={() => setBulkDeleteOpen(false)}
|
||||
contacts={contacts}
|
||||
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -729,7 +729,7 @@ function MqttPrivateConfigEditor({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
|
||||
</p>
|
||||
|
||||
@@ -1000,7 +1000,7 @@ function MqttHaConfigEditor({
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3 rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold text-foreground">Home Assistant MQTT Discovery</h3>
|
||||
<h3 className="text-base font-semibold tracking-tight">Home Assistant MQTT Discovery</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Publish discovery configs and MeshCore state to your MQTT broker so Home Assistant
|
||||
creates native devices, sensors, GPS trackers, and message events automatically.
|
||||
@@ -1010,27 +1010,27 @@ function MqttHaConfigEditor({
|
||||
<div className="grid gap-2 md:grid-cols-3">
|
||||
<div className="rounded-md border border-border/70 bg-background/80 p-3">
|
||||
<div className="text-sm font-medium text-foreground">1. Same broker</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
|
||||
Home Assistant's built-in MQTT integration must point at the same broker
|
||||
configured below.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-background/80 p-3">
|
||||
<div className="text-sm font-medium text-foreground">2. Pick what to expose</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
|
||||
Choose repeaters for telemetry sensors and contacts for GPS tracker entities.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md border border-border/70 bg-background/80 p-3">
|
||||
<div className="text-sm font-medium text-foreground">3. Automate in HA</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
<p className="mt-1 text-[0.8125rem] text-muted-foreground">
|
||||
Radio health and message events publish continuously; repeater and contact data update
|
||||
when new data is heard or collected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Uses{' '}
|
||||
<span
|
||||
role="link"
|
||||
@@ -1246,7 +1246,7 @@ function MqttHaConfigEditor({
|
||||
|
||||
<Separator />
|
||||
|
||||
<p className="text-sm font-semibold text-foreground">MQTT Broker</p>
|
||||
<h3 className="text-base font-semibold tracking-tight">MQTT Broker</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
@@ -1337,8 +1337,8 @@ function MqttHaConfigEditor({
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-foreground">GPS Tracked Contacts</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h3 className="text-base font-semibold tracking-tight">GPS Tracked Contacts</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Each selected contact becomes a <code className="text-[0.6875rem]">device_tracker</code>{' '}
|
||||
in HA, updated whenever an advertisement with GPS coordinates is heard. Useful for
|
||||
tracking mobile nodes on an HA map dashboard.
|
||||
@@ -1366,7 +1366,7 @@ function MqttHaConfigEditor({
|
||||
)}
|
||||
|
||||
{contactOptions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">No contacts available.</p>
|
||||
<p className="text-[0.8125rem] text-muted-foreground italic">No contacts available.</p>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
@@ -1378,7 +1378,7 @@ function MqttHaConfigEditor({
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 rounded border border-border p-2">
|
||||
{filteredContacts.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic py-1">
|
||||
<p className="text-[0.8125rem] text-muted-foreground italic py-1">
|
||||
No contacts match “{contactSearch}”
|
||||
</p>
|
||||
) : (
|
||||
@@ -1408,8 +1408,8 @@ function MqttHaConfigEditor({
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-foreground">Telemetry Tracked Repeaters</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h3 className="text-base font-semibold tracking-tight">Telemetry Tracked Repeaters</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Each selected repeater becomes an HA device with sensors for battery voltage, RSSI, SNR,
|
||||
noise floor, packet counts, and uptime. Data updates whenever telemetry is collected
|
||||
(auto-collect runs every ~8 hours, or on manual dashboard fetch). Only repeaters already
|
||||
@@ -1417,13 +1417,13 @@ function MqttHaConfigEditor({
|
||||
repeater and opting in at the bottom of the page).
|
||||
</p>
|
||||
{trackedRepeaters.length === 0 ? (
|
||||
<div className="rounded-md border border-muted bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="rounded-md border border-muted bg-muted/30 px-3 py-2 text-[0.8125rem] text-muted-foreground">
|
||||
No repeaters are being auto-tracked for telemetry. Add repeaters to the auto-telemetry
|
||||
tracking list in the Radio section first, then return here to select which ones to
|
||||
expose to HA.
|
||||
</div>
|
||||
) : repeaterOptions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
<p className="text-[0.8125rem] text-muted-foreground italic">
|
||||
Auto-tracked repeaters not found in contact list.
|
||||
</p>
|
||||
) : (
|
||||
@@ -1449,8 +1449,8 @@ function MqttHaConfigEditor({
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold text-foreground">Message Events</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h3 className="text-base font-semibold tracking-tight">Message Events</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Matching messages fire an{' '}
|
||||
<code className="text-[0.6875rem]">{`event.meshcore_${localRadioNodeId}_messages`}</code>{' '}
|
||||
entity in HA with sender, text, channel, and direction attributes. Use HA automations to
|
||||
@@ -1473,7 +1473,7 @@ function MqttCommunityConfigEditor({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Advanced community MQTT editor. Use this for manual meshcoretomqtt-compatible setups or for
|
||||
modifying a saved preset after creation. Only raw RF packets are shared — never
|
||||
decrypted messages.
|
||||
@@ -1536,7 +1536,7 @@ function MqttCommunityConfigEditor({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
LetsMesh uses <code>token</code> auth. MeshRank uses <code>none</code>.
|
||||
</p>
|
||||
|
||||
@@ -1551,7 +1551,9 @@ function MqttCommunityConfigEditor({
|
||||
value={(config.token_audience as string | undefined) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, token_audience: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Defaults to the broker host when blank</p>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Defaults to the broker host when blank
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-comm-email">Owner Email (optional)</Label>
|
||||
@@ -1562,7 +1564,7 @@ function MqttCommunityConfigEditor({
|
||||
value={(config.email as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, email: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Used to claim your node on the community aggregator
|
||||
</p>
|
||||
</div>
|
||||
@@ -1626,7 +1628,7 @@ function MqttCommunityConfigEditor({
|
||||
onChange={(e) => onChange({ ...config, iata: e.target.value.toUpperCase() })}
|
||||
className="w-32"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Your nearest airport's IATA code (required)
|
||||
</p>
|
||||
</div>
|
||||
@@ -1640,7 +1642,7 @@ function MqttCommunityConfigEditor({
|
||||
value={(config.topic_template as string | undefined) ?? ''}
|
||||
onChange={(e) => onChange({ ...config, topic_template: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Use <code>{'{IATA}'}</code> and <code>{'{PUBLIC_KEY}'}</code>. Default:{' '}
|
||||
<code>{DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE}</code>
|
||||
</p>
|
||||
@@ -1658,7 +1660,7 @@ function MeshRankConfigEditor({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Pre-filled MeshRank setup. This saves as a regular Community MQTT integration once created,
|
||||
but only asks for the MeshRank packet topic you were given.
|
||||
</p>
|
||||
@@ -1685,7 +1687,7 @@ function MeshRankConfigEditor({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Paste the full topic template from your MeshRank config, for example{' '}
|
||||
<code>meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{'{PUBLIC_KEY}'}/packets</code>.
|
||||
</p>
|
||||
@@ -1705,7 +1707,7 @@ function LetsMeshConfigEditor({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Pre-filled LetsMesh setup. This saves as a regular Community MQTT integration once created,
|
||||
but only asks for the values LetsMesh expects from you.
|
||||
</p>
|
||||
@@ -1786,7 +1788,7 @@ function BotConfigEditor({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Define a <code className="bg-muted px-1 rounded">bot()</code> function that receives
|
||||
message data and optionally returns a reply.
|
||||
</p>
|
||||
@@ -1810,7 +1812,7 @@ function BotConfigEditor({
|
||||
<BotCodeEditor value={code} onChange={(c) => onChange({ ...config, code: c })} />
|
||||
</Suspense>
|
||||
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="text-[0.8125rem] text-muted-foreground space-y-1">
|
||||
<p>
|
||||
<strong>Available:</strong> Standard Python libraries and any modules installed in the
|
||||
server environment.
|
||||
@@ -1858,7 +1860,7 @@ function MapUploadConfigEditor({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Automatically upload heard repeater and room server advertisements to{' '}
|
||||
<a
|
||||
href="https://map.meshcore.io"
|
||||
@@ -1889,7 +1891,7 @@ function MapUploadConfigEditor({
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Dry Run (log only, no uploads)</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When enabled, upload payloads are logged at INFO level but not sent. Disable once you
|
||||
have confirmed the logged output looks correct.
|
||||
</p>
|
||||
@@ -1907,7 +1909,7 @@ function MapUploadConfigEditor({
|
||||
value={(config.api_url as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, api_url: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Leave blank to use the default <code>map.meshcore.io</code> endpoint.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1923,7 +1925,7 @@ function MapUploadConfigEditor({
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Enable Geofence</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Only upload nodes whose location falls within the configured radius of your radio's
|
||||
own position. Helps exclude nodes with false or spoofed coordinates. Uses the
|
||||
latitude/longitude set in Radio Settings.
|
||||
@@ -1941,7 +1943,7 @@ function MapUploadConfigEditor({
|
||||
</div>
|
||||
)}
|
||||
{radioLatLonConfigured && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Using radio position{' '}
|
||||
<code>
|
||||
{radioLat?.toFixed(5)}, {radioLon?.toFixed(5)}
|
||||
@@ -1965,7 +1967,7 @@ function MapUploadConfigEditor({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Nodes further than this distance from your radio's position will not be uploaded.
|
||||
</p>
|
||||
</div>
|
||||
@@ -2120,7 +2122,7 @@ function PillsSearchList({
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic">{emptyItemsMessage}</p>
|
||||
<p className="text-[0.8125rem] text-muted-foreground italic">{emptyItemsMessage}</p>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
@@ -2132,7 +2134,7 @@ function PillsSearchList({
|
||||
/>
|
||||
<div className="max-h-48 overflow-y-auto space-y-1 rounded border border-border p-2">
|
||||
{filtered.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground italic py-1">
|
||||
<p className="text-[0.8125rem] text-muted-foreground italic py-1">
|
||||
No {label.toLowerCase()} match “{search}”
|
||||
</p>
|
||||
) : (
|
||||
@@ -2282,7 +2284,7 @@ function ScopeSelector({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label>Message Scope</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">Message Scope</h3>
|
||||
|
||||
{showRawPackets && (
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
@@ -2319,7 +2321,7 @@ function ScopeSelector({
|
||||
|
||||
{isListMode && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">{listHint}</p>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">{listHint}</p>
|
||||
|
||||
{channels.length > 0 && (
|
||||
<PillsSearchList
|
||||
@@ -2387,7 +2389,7 @@ function AppriseConfigEditor({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Send push notifications via{' '}
|
||||
<a
|
||||
href="https://github.com/caronc/apprise"
|
||||
@@ -2421,7 +2423,7 @@ function AppriseConfigEditor({
|
||||
onChange={(e) => onChange({ ...config, urls: e.target.value })}
|
||||
rows={4}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
One URL per line. All URLs receive every matched notification. For Matrix room version 12
|
||||
(servername-less room IDs), append <code>?hsreq=no</code> to the URL.
|
||||
</p>
|
||||
@@ -2436,7 +2438,7 @@ function AppriseConfigEditor({
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm">Preserve identity on Discord</span>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When enabled, Discord webhooks will use their configured name/avatar instead of
|
||||
overriding with MeshCore sender info.
|
||||
</p>
|
||||
@@ -2492,7 +2494,7 @@ function WebhookConfigEditor({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Send message data as JSON to an HTTP endpoint when messages are received.
|
||||
</p>
|
||||
|
||||
@@ -2526,8 +2528,8 @@ function WebhookConfigEditor({
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>HMAC Signing</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h3 className="text-base font-semibold tracking-tight">HMAC Signing</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When a secret is set, each request includes an HMAC-SHA256 signature of the JSON body in
|
||||
the specified header (e.g. <code className="bg-muted px-1 rounded">sha256=ab12cd...</code>
|
||||
).
|
||||
@@ -2590,7 +2592,7 @@ function SqsConfigEditor({
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Send matched mesh events to an Amazon SQS queue for durable processing by workers, Lambdas,
|
||||
or downstream automation.
|
||||
</p>
|
||||
@@ -2631,15 +2633,17 @@ function SqsConfigEditor({
|
||||
value={(config.endpoint_url as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, endpoint_url: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Useful for LocalStack or custom endpoints</p>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Useful for LocalStack or custom endpoints
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Static Credentials (optional)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<h3 className="text-base font-semibold tracking-tight">Static Credentials (optional)</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Leave blank to use the server's normal AWS credential chain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePush } from '../../contexts/PushSubscriptionContext';
|
||||
import type { Channel, Contact } from '../../types';
|
||||
import { getContactDisplayName } from '../../utils/pubkey';
|
||||
import { Button } from '../ui/button';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Separator } from '../ui/separator';
|
||||
@@ -92,8 +93,8 @@ function PushDeviceManagement({
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label>Web Push Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h3 className="text-base font-semibold tracking-tight">Web Push Notifications</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
{window.isSecureContext
|
||||
? 'Push notifications are not supported by this browser.'
|
||||
: 'Web Push requires HTTPS. Access RemoteTerm over HTTPS (self-signed certificates work) to enable push notifications.'}
|
||||
@@ -105,13 +106,13 @@ function PushDeviceManagement({
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label>Web Push Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<h3 className="text-base font-semibold tracking-tight">Web Push Notifications</h3>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Receive notifications even when the browser is closed. Use the bell icon in any
|
||||
conversation header to enable push for that contact or channel, or subscribe this browser
|
||||
to receive notifications for all push-enabled conversations.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
The set of channels or DMs that trigger push notifications are global per-install (i.e.
|
||||
all devices that register for Web Push will have the same set of channels/DMs that trigger
|
||||
notifications). Subscribing or unsubscribing a particular browser only controls whether
|
||||
@@ -265,12 +266,12 @@ export function SettingsLocalSection({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
These settings apply only to this device/browser.
|
||||
</p>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Color Scheme</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">Color Scheme</h3>
|
||||
<ThemeSelector />
|
||||
<ThemePreview className="mt-6" />
|
||||
</div>
|
||||
@@ -278,7 +279,7 @@ export function SettingsLocalSection({
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Local Label</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">Local Label</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={localLabelText}
|
||||
@@ -305,7 +306,7 @@ export function SettingsLocalSection({
|
||||
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Display a colored banner at the top of the page to identify this instance.
|
||||
</p>
|
||||
</div>
|
||||
@@ -330,7 +331,7 @@ export function SettingsLocalSection({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Controls how distances are shown throughout the app.
|
||||
</p>
|
||||
</div>
|
||||
@@ -338,86 +339,107 @@ export function SettingsLocalSection({
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>UI Tweaks</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">UI Tweaks</h3>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopenLastConversation}
|
||||
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="reopen-last"
|
||||
checked={reopenLastConversation}
|
||||
onCheckedChange={(checked) => handleToggleReopenLastConversation(checked === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="reopen-last">Reopen Last Conversation</Label>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Automatically reopen to the last-open channel or contact when the app loads to the
|
||||
bare URL.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoFocusInput}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setAutoFocusInput(v);
|
||||
setAutoFocusInputEnabled(v);
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
|
||||
</label>
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="auto-focus-input"
|
||||
checked={autoFocusInput}
|
||||
onCheckedChange={(checked) => {
|
||||
const v = checked === true;
|
||||
setAutoFocusInput(v);
|
||||
setAutoFocusInputEnabled(v);
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="auto-focus-input">Auto-Focus Message Input</Label>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Place the cursor in the message input when switching conversations. Desktop only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batteryPercent}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setBatteryPercent(v);
|
||||
saveBatteryPercent(v);
|
||||
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Show battery percentage in status bar</span>
|
||||
</label>
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="battery-percent"
|
||||
checked={batteryPercent}
|
||||
onCheckedChange={(checked) => {
|
||||
const v = checked === true;
|
||||
setBatteryPercent(v);
|
||||
saveBatteryPercent(v);
|
||||
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="battery-percent">Show Battery Percentage</Label>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Display the radio's battery percentage in the status bar. Data updates every 60
|
||||
seconds and may take up to a minute to appear after connecting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batteryVoltage}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setBatteryVoltage(v);
|
||||
saveBatteryVoltage(v);
|
||||
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Show battery voltage in status bar</span>
|
||||
</label>
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="battery-voltage"
|
||||
checked={batteryVoltage}
|
||||
onCheckedChange={(checked) => {
|
||||
const v = checked === true;
|
||||
setBatteryVoltage(v);
|
||||
saveBatteryVoltage(v);
|
||||
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="battery-voltage">Show Battery Voltage</Label>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Display the radio's battery voltage in the status bar (in mV). Data updates
|
||||
every 60 seconds and may take up to a minute to appear after connecting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(batteryPercent || batteryVoltage) && (
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Battery data updates every 60 seconds and may take up to a minute to appear after
|
||||
connecting.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={statusDotPulse}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setStatusDotPulse(v);
|
||||
saveStatusDotPulse(v);
|
||||
window.dispatchEvent(new Event(STATUS_DOT_PULSE_CHANGE_EVENT));
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">
|
||||
Glitter status dot as packets arrive (blue = channel, purple = DM, cyan = advert, dark
|
||||
green = other)
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="status-dot-pulse"
|
||||
checked={statusDotPulse}
|
||||
onCheckedChange={(checked) => {
|
||||
const v = checked === true;
|
||||
setStatusDotPulse(v);
|
||||
saveStatusDotPulse(v);
|
||||
window.dispatchEvent(new Event(STATUS_DOT_PULSE_CHANGE_EVENT));
|
||||
}}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="status-dot-pulse">Status Dot Glitters</Label>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Flash the connection status dot in color as packets arrive: blue for channel, purple
|
||||
for DM, cyan for advert, dark green for other.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||
@@ -490,7 +512,7 @@ export function SettingsLocalSection({
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Scales the app's typography for this browser only. The slider moves in 5% steps;
|
||||
the number field accepts any value from 25% to 400%.
|
||||
</p>
|
||||
@@ -603,15 +625,15 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
desc="Sheet / dialog title"
|
||||
/>
|
||||
<PreviewTextRow
|
||||
classes="text-base font-semibold"
|
||||
label="text-base font-semibold"
|
||||
desc="Section title"
|
||||
classes="text-base font-semibold tracking-tight"
|
||||
label="text-base font-semibold tracking-tight"
|
||||
desc="Section / group title"
|
||||
/>
|
||||
<PreviewTextRow classes="text-sm" label="text-sm" desc="Body text, form labels" />
|
||||
<PreviewTextRow
|
||||
classes="text-xs text-muted-foreground"
|
||||
label="text-xs text-muted-foreground"
|
||||
desc="Helper text"
|
||||
classes="text-[0.8125rem] text-muted-foreground"
|
||||
label="text-[0.8125rem] text-muted-foreground"
|
||||
desc="Helper / description text"
|
||||
/>
|
||||
<PreviewTextRow
|
||||
classes="text-[0.6875rem] text-muted-foreground"
|
||||
@@ -620,7 +642,7 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
/>
|
||||
<div>
|
||||
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Section Label
|
||||
Metadata Label
|
||||
</p>
|
||||
<p className="text-[0.625rem] text-muted-foreground/60 mt-0.5">
|
||||
text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium
|
||||
|
||||
@@ -392,7 +392,7 @@ export function SettingsRadioSection({
|
||||
<div className={className}>
|
||||
{/* ── Connection ── */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base">Connection</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">Connection</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
@@ -423,7 +423,7 @@ export function SettingsRadioSection({
|
||||
>
|
||||
{connectionBusy ? `${connectionActionLabel}...` : connectionActionLabel}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Disconnect pauses automatic reconnect attempts so another device can use the radio.
|
||||
</p>
|
||||
</div>
|
||||
@@ -432,7 +432,7 @@ export function SettingsRadioSection({
|
||||
|
||||
{/* ── Identity ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Identity</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">Identity</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -477,7 +477,7 @@ export function SettingsRadioSection({
|
||||
|
||||
{/* ── Radio Parameters ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Radio Parameters</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">Radio Parameters</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -590,7 +590,7 @@ export function SettingsRadioSection({
|
||||
{/* ── Location ── */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base">Location</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">Location</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@@ -645,7 +645,7 @@ export function SettingsRadioSection({
|
||||
<option value="off">Off</option>
|
||||
<option value="current">Include Node Location</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Companion-radio firmware does not distinguish between saved coordinates and live GPS
|
||||
here. When enabled, adverts include the node's current location state. That may be
|
||||
the last coordinates you set from RemoteTerm or live GPS coordinates if the node itself
|
||||
@@ -674,7 +674,7 @@ export function SettingsRadioSection({
|
||||
{rebooting ? 'Rebooting...' : 'Save & Reboot'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Some settings may require a reboot to take effect on some radios.
|
||||
</p>
|
||||
|
||||
@@ -682,7 +682,7 @@ export function SettingsRadioSection({
|
||||
|
||||
{/* ── Messaging ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Messaging</Label>
|
||||
<h3 className="text-base font-semibold tracking-tight">Messaging</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -695,7 +695,7 @@ export function SettingsRadioSection({
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When enabled, the radio sends one extra direct ACK transmission before the normal ACK
|
||||
for received direct messages. This is a firmware-level receive behavior, not a
|
||||
RemoteTerm retry setting.
|
||||
@@ -714,7 +714,7 @@ export function SettingsRadioSection({
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="auto-resend-channel">Auto-Resend Unheard Channel Messages</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
When enabled, outgoing channel messages that receive no echo within 2 seconds are
|
||||
automatically resent once (byte-perfect, within the 30-second dedup window). Repeaters
|
||||
that already heard the original will ignore the duplicate. This functionality will NOT
|
||||
@@ -732,7 +732,7 @@ export function SettingsRadioSection({
|
||||
onChange={(e) => setFloodScope(e.target.value)}
|
||||
placeholder="MyRegion"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
|
||||
that region can forward the traffic, while repeaters configured to deny other regions may
|
||||
drop it. Leave empty to disable.
|
||||
@@ -749,7 +749,7 @@ export function SettingsRadioSection({
|
||||
value={maxRadioContacts}
|
||||
onChange={(e) => setMaxRadioContacts(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Configured radio contact capacity. Favorites reload first, then background maintenance
|
||||
refills to about 80% of this value and offloads once occupancy reaches about 95%.
|
||||
</p>
|
||||
@@ -775,134 +775,137 @@ export function SettingsRadioSection({
|
||||
<Separator />
|
||||
|
||||
{/* ── Advertising & Discovery ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Advertising & Discovery</Label>
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<h3 className="text-base font-semibold tracking-tight">Advertising & Discovery</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="advert-interval"
|
||||
type="number"
|
||||
min="0"
|
||||
value={advertIntervalHours}
|
||||
onChange={(e) => setAdvertIntervalHours(e.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="advert-interval"
|
||||
type="number"
|
||||
min="0"
|
||||
value={advertIntervalHours}
|
||||
onChange={(e) => setAdvertIntervalHours(e.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
|
||||
</div>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||
Recommended: 24 hours or higher.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
|
||||
Recommended: 24 hours or higher.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Send Advertisement</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less
|
||||
airtime.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
onClick={() => handleAdvertise('flood')}
|
||||
disabled={advertisingMode !== null || !health?.radio_connected}
|
||||
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
|
||||
>
|
||||
{advertisingMode === 'flood' ? 'Sending...' : 'Send Flood Advertisement'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleAdvertise('zero_hop')}
|
||||
disabled={advertisingMode !== null || !health?.radio_connected}
|
||||
className="w-full"
|
||||
>
|
||||
{advertisingMode === 'zero_hop' ? 'Sending...' : 'Send Zero-Hop Advertisement'}
|
||||
</Button>
|
||||
</div>
|
||||
{!health?.radio_connected && (
|
||||
<p className="text-sm text-destructive">Radio not connected</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Mesh Discovery</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Discover nearby node types that currently respond to mesh discovery requests: repeaters
|
||||
and sensors.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
{ target: 'repeaters', label: 'Discover Repeaters' },
|
||||
{ target: 'sensors', label: 'Discover Sensors' },
|
||||
{ target: 'all', label: 'Discover Both' },
|
||||
].map(({ target, label }) => (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-semibold">Send Advertisement</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less
|
||||
airtime.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
key={target}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleDiscover(target as RadioDiscoveryTarget)}
|
||||
disabled={meshDiscoveryLoadingTarget !== null || !health?.radio_connected}
|
||||
onClick={() => handleAdvertise('flood')}
|
||||
disabled={advertisingMode !== null || !health?.radio_connected}
|
||||
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
|
||||
>
|
||||
{advertisingMode === 'flood' ? 'Sending...' : 'Send Flood Advertisement'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleAdvertise('zero_hop')}
|
||||
disabled={advertisingMode !== null || !health?.radio_connected}
|
||||
className="w-full"
|
||||
>
|
||||
{meshDiscoveryLoadingTarget === target ? 'Listening...' : label}
|
||||
{advertisingMode === 'zero_hop' ? 'Sending...' : 'Send Zero-Hop Advertisement'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{!health?.radio_connected && (
|
||||
<p className="text-sm text-destructive">Radio not connected</p>
|
||||
)}
|
||||
{discoverError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{discoverError}
|
||||
</p>
|
||||
)}
|
||||
{meshDiscovery && (
|
||||
<div className="space-y-2 rounded-md border border-input bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium">
|
||||
Last sweep: {meshDiscovery.results.length} node
|
||||
{meshDiscovery.results.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{meshDiscovery.duration_seconds.toFixed(0)}s listen window
|
||||
</p>
|
||||
</div>
|
||||
{meshDiscovery.results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No supported nodes responded during the last discovery sweep.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{meshDiscovery.results.map((result) => (
|
||||
<div
|
||||
key={result.public_key}
|
||||
className="rounded-md border border-input bg-background px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
{result.name ?? <span className="capitalize">{result.node_type}</span>}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{result.name && (
|
||||
<p className="text-xs capitalize text-muted-foreground">{result.node_type}</p>
|
||||
)}
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
||||
{result.public_key}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Heard here: {result.local_snr ?? 'n/a'} dB SNR / {result.local_rssi ?? 'n/a'}{' '}
|
||||
dBm RSSI. Remote heard us: {result.remote_snr ?? 'n/a'} dB SNR.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!health?.radio_connected && (
|
||||
<p className="text-sm text-destructive">Radio not connected</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-semibold">Mesh Discovery</h4>
|
||||
<p className="text-[0.8125rem] text-muted-foreground">
|
||||
Discover nearby node types that currently respond to mesh discovery requests: repeaters
|
||||
and sensors.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
{ target: 'repeaters', label: 'Discover Repeaters' },
|
||||
{ target: 'sensors', label: 'Discover Sensors' },
|
||||
{ target: 'all', label: 'Discover Both' },
|
||||
].map(({ target, label }) => (
|
||||
<Button
|
||||
key={target}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleDiscover(target as RadioDiscoveryTarget)}
|
||||
disabled={meshDiscoveryLoadingTarget !== null || !health?.radio_connected}
|
||||
className="w-full"
|
||||
>
|
||||
{meshDiscoveryLoadingTarget === target ? 'Listening...' : label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{!health?.radio_connected && (
|
||||
<p className="text-sm text-destructive">Radio not connected</p>
|
||||
)}
|
||||
{discoverError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{discoverError}
|
||||
</p>
|
||||
)}
|
||||
{meshDiscovery && (
|
||||
<div className="space-y-2 rounded-md border border-input bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium">
|
||||
Last sweep: {meshDiscovery.results.length} node
|
||||
{meshDiscovery.results.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{meshDiscovery.duration_seconds.toFixed(0)}s listen window
|
||||
</p>
|
||||
</div>
|
||||
{meshDiscovery.results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No supported nodes responded during the last discovery sweep.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{meshDiscovery.results.map((result) => (
|
||||
<div
|
||||
key={result.public_key}
|
||||
className="rounded-md border border-input bg-background px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
{result.name ?? <span className="capitalize">{result.node_type}</span>}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
{result.name && (
|
||||
<p className="text-xs capitalize text-muted-foreground">
|
||||
{result.node_type}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
||||
{result.public_key}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Heard here: {result.local_snr ?? 'n/a'} dB SNR /{' '}
|
||||
{result.local_rssi ?? 'n/a'} dBm RSSI. Remote heard us:{' '}
|
||||
{result.remote_snr ?? 'n/a'} dB SNR.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -226,7 +226,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
<div className="space-y-6">
|
||||
{/* Network */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Network</h4>
|
||||
<h3 className="text-base font-semibold tracking-tight mb-2">Network</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.contact_count}</div>
|
||||
@@ -247,7 +247,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
|
||||
{/* Messages */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Messages</h4>
|
||||
<h3 className="text-base font-semibold tracking-tight mb-2">Messages</h3>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="text-center p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-2xl font-bold">{stats.total_dms}</div>
|
||||
@@ -268,7 +268,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
|
||||
{/* Activity */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Activity</h4>
|
||||
<h3 className="text-base font-semibold tracking-tight mb-2">Activity</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-muted-foreground">
|
||||
@@ -305,7 +305,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
|
||||
{/* Packets */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets</h4>
|
||||
<h3 className="text-base font-semibold tracking-tight mb-2">Packets</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-muted-foreground">Total stored</span>
|
||||
@@ -327,7 +327,9 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Packets per Hour (72h)</h4>
|
||||
<h3 className="text-base font-semibold tracking-tight mb-2">
|
||||
Packets per Hour (72h)
|
||||
</h3>
|
||||
<PacketsPerHourChart buckets={stats.packets_per_hour_72h} />
|
||||
</div>
|
||||
</>
|
||||
@@ -337,7 +339,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
|
||||
{/* Path Hash Width */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
|
||||
<h3 className="text-base font-semibold tracking-tight mb-2">Path Hash Width (24h)</h3>
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
Parsed stored raw packets from the last 24 hours:{' '}
|
||||
{stats.path_hash_width_24h.total_packets}
|
||||
@@ -407,7 +409,9 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
|
||||
<h3 className="text-base font-semibold tracking-tight mb-2">
|
||||
Busiest Channels (24h)
|
||||
</h3>
|
||||
<ResponsiveContainer
|
||||
width="100%"
|
||||
height={stats.busiest_channels_24h.length * 28 + 8}
|
||||
@@ -451,7 +455,7 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium mb-2">Noise Floor (24h)</h4>
|
||||
<h3 className="text-base font-semibold tracking-tight mb-2">Noise Floor (24h)</h3>
|
||||
{stats.noise_floor_24h.latest_noise_floor_dbm != null && (
|
||||
<div className="mb-2 text-xs text-muted-foreground">
|
||||
Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm
|
||||
|
||||
@@ -566,7 +566,7 @@ describe('SettingsModal', () => {
|
||||
renderModal();
|
||||
openLocalSection();
|
||||
|
||||
const checkbox = screen.getByLabelText('Reopen to last viewed channel/conversation');
|
||||
const checkbox = screen.getByLabelText('Reopen Last Conversation');
|
||||
expect(checkbox).not.toBeChecked();
|
||||
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
Reference in New Issue
Block a user