Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions PLAN_3770.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Plan — Issue #3770: Improve MeshCore Region/Scope Management

> STATUS: IMPLEMENTED. All 4 capabilities delivered. New global table
> `meshcore_saved_regions` (migration 108) + repository + routes + 4 UI
> touchpoints. Full vitest suite green (excluding 4 pre-existing
> environmental spiderfier suites that fail on the base tree too).


## Goal
A user-maintained list of saved MeshCore regions, plus four UI touchpoints to use it:
1. **Save** a region reported by a repeater (from the discover-regions UI).
2. **Add / Delete** entries manually in a management list.
3. **Channel settings**: pick a saved region to set the channel scope (instead of typing).
4. **Per-message**: pick a saved region to override the scope for one message.

## Data model

### New table: `meshcore_saved_regions` — GLOBAL (no `sourceId`)
Rationale: a scope is a transport code derived purely from a region *name*
(`sha256("#region")[:16]`). It is not tied to a source/node — the same physical
mesh region applies across every MeshCore source. This mirrors `channel_database`
and `automations`, which are global-by-design. The saved list is a convenience
catalog of names; uniqueness is on the (normalized) name.

Columns (all 3 backends, following `automations` style):
| column | type | notes |
|---|---|---|
| `id` | INTEGER PK autoincrement (SQLite/PG); MySQL `int` auto_increment | |
| `name` | TEXT / varchar(64) | region name, stored WITHOUT leading `#`, normalized (lowercase letters/digits/hyphen) |
| `note` | TEXT nullable | optional user note / where it came from |
| `createdAt` | INTEGER / bigint | |
| `updatedAt` | INTEGER / bigint | |

Unique index on `name` (case-insensitive handled in repo by normalizing before insert/lookup).

### Migration `108_meshcore_saved_regions`
- `src/server/migrations/108_meshcore_saved_regions.ts`: `CREATE TABLE IF NOT EXISTS` for all three backends (idempotent), unique index on name.
- Register in `src/db/migrations.ts` (number 108, name `meshcore_saved_regions`, settingsKey `migration_108_meshcore_saved_regions`).
- Bump `src/db/migrations.test.ts`: count 107 → 108, last name `meshcore_saved_regions`.

### Schema wiring
- `src/db/schema/savedRegions.ts` — `savedRegionsSqlite/Postgres/Mysql` + inferred types.
- Export from `src/db/schema/index.ts`.
- Register in `src/db/activeSchema.ts` (import, add to active select map under key `meshcoreSavedRegions`, add to `ActiveSchema` type).

### Repository
- `src/db/repositories/savedRegions.ts` — `SavedRegionsRepository extends BaseRepository`:
- `getAllAsync(): Promise<DbSavedRegion[]>` (ordered by name)
- `getByNameAsync(name)`
- `addAsync(name, note?): Promise<DbSavedRegion>` — normalize name, idempotent upsert (return existing if name already saved)
- `deleteAsync(id): Promise<void>`
- Name normalization helper: strip leading `#`, lowercase, keep `[a-z0-9-]`, reject empty.
- Wire into `DatabaseService`: import, `public savedRegionsRepo`, `get savedRegions()` getter, init in `initialize()`.

## Backend API routes (`src/server/routes/meshcoreRoutes.ts`)
These are catalog-management routes; they touch the DB only (no node IO), but live
under the meshcore router for cohesion. Region management is global, so they are
NOT under `/sources/:id` — use a top-level meshcore regions router OR mount on the
existing meshcore router with permission `requirePermission('settings','write')`
(read = `'settings','read'`). Confirm existing top-level mounting; if the meshcore
router is always source-scoped, add routes under `/sources/:id/meshcore/saved-regions`
operating on the global table (sourceId ignored) to reuse existing auth wiring.

- `GET .../saved-regions` → list (`{ success, regions: DbSavedRegion[] }`)
- `POST .../saved-regions` → body `{ name, note? }` → add → `{ success, region }`
- `DELETE .../saved-regions/:id` → delete → `{ success }`

## Frontend

### API client (`useMeshCore.ts`)
Add to the hook: `savedRegions: DbSavedRegion[]`, `fetchSavedRegions()`,
`addSavedRegion(name, note?)`, `deleteSavedRegion(id)`. Use `csrfFetch` against
`${mcPrefix}/saved-regions`. Fetch on mount alongside other initial loads.

### Touchpoint 1 — Save from repeater discovery (`MeshCoreSettingsView.tsx`)
The discovered-regions chips (~L272-291) already render per region. Add a small
"+ Save" affordance per chip (or a save icon) that calls `addSavedRegion(region)`.
Disable/checkmark when already saved (cross-ref `savedRegions`).

### Touchpoint 2 — Manage list (add/delete)
New section in `MeshCoreSettingsView.tsx` (region/scope area): list of saved
regions, each with a delete button; an input + Add button to add manually.
Reuse the normalization/validation already used for scope input.

### Touchpoint 3 — Channel settings scope dropdown
Where the channel scope is set. Per the investigation, channel scope is set via
`updateChannelScope` repo + the settings default-scope field; confirm the channel
edit UI (`MeshCoreChannelsConfigSection.tsx` / channel edit modal). Replace/augment
the manual scope text input with a `<datalist>` or `<select>` populated from
`savedRegions` (free-typing still allowed for new names). Minimal change: add
`list=` + `<datalist>` of saved regions to the existing scope input, matching the
per-message override pattern already in `MeshCoreChannelsView.tsx`.

### Touchpoint 4 — Per-message scope override dropdown
`MeshCoreChannelsView.tsx` (~L516-563) already has a scope-override `<input>` with
a `<datalist id="mc-scope-region-suggestions">` populated from `discoveredRegions`.
Extend that datalist (or add the saved regions) so saved regions appear as
suggestions — union of `discoveredRegions` + `savedRegions`, de-duplicated.

## Tests
- `src/db/repositories/savedRegions.test.ts` — add/normalize/dedupe/delete/list, name validation, global (no sourceId).
- `src/db/migrations.test.ts` — count + last name update.
- Route tests in the existing meshcore routes test file (or new) — list/add/delete happy path + auth + validation (mock `DatabaseService.savedRegions.*Async`).
- UI: light test for the union/dedup of suggestions if non-trivial; otherwise rely on existing component tests compiling.

## Verification
- Full vitest suite (0 failures), `tsc`/build, lint. No `system-test` label (no device-comms change).

## Scope notes / cut lines
- This feature is DB + routes + UI only; no protocol/native-backend change → low risk.
- If the channel-edit scope UI proves to not exist as a discrete field, deliver the
datalist on the per-message override + the manage list + save-from-discovery, and
note the channel-settings dropdown as a follow-up. All four are targeted though.
2 changes: 2 additions & 0 deletions src/cli/migrationTables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export const TABLE_ORDER = [
'automation_runs',
'automation_variables',
'automation_variable_values',
// 3770: global MeshCore saved-regions catalog. No sourceId / no FK.
'meshcore_saved_regions',
];

// Tables in the 4.0 schema that carry a `sourceId` column. When the source
Expand Down
37 changes: 31 additions & 6 deletions src/components/MeshCore/MeshCoreChannelsConfigSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,32 @@ vi.mock('../ToastContainer', () => ({
useToast: () => ({ showToast: vi.fn() }),
}));

const csrfFetchMock = vi.fn();
// A single vi.fn mock. The saved-regions catalog fetch (#3770) fires on mount on
// its own effect; we special-case it inside the implementation so it returns an
// empty list WITHOUT consuming a queued once-value meant for the channel CRUD
// calls. Channel calls fall through to a per-test FIFO queue populated by
// `queueResponse(...)` (aliased to `mockResolvedValueOnce` so the existing tests
// read naturally). Assertions on call URLs use `channelCalls`, which excludes
// the saved-regions noise.
let responseQueue: Response[] = [];
let channelCalls: any[][] = [];
const csrfFetchMock = vi.fn((url: string, ...rest: any[]) => {
if (typeof url === 'string' && url.includes('/saved-regions')) {
return Promise.resolve(
new Response(JSON.stringify({ success: true, regions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
channelCalls.push([url, ...rest]);
const next = responseQueue.shift();
return Promise.resolve(next ?? new Response('null', { status: 200, headers: { 'content-type': 'application/json' } }));
});
// Shim the queue-driving + reset API the existing tests call on the mock.
(csrfFetchMock as any).mockResolvedValueOnce = (v: Response) => { responseQueue.push(v); return csrfFetchMock; };
(csrfFetchMock as any).mockReset = () => { responseQueue = []; channelCalls = []; csrfFetchMock.mockClear(); return csrfFetchMock; };

vi.mock('../../hooks/useCsrfFetch', () => ({
useCsrfFetch: () => csrfFetchMock,
}));
Expand Down Expand Up @@ -80,7 +105,7 @@ describe('MeshCoreChannelsConfigSection — list rendering', () => {
expect(screen.getByText('# Channel 2')).toBeTruthy();
});

const calledUrl = csrfFetchMock.mock.calls[0][0] as string;
const calledUrl = channelCalls[0][0] as string;
expect(calledUrl).toContain('/api/channels/all?sourceId=src-a');
});

Expand Down Expand Up @@ -161,7 +186,7 @@ describe('MeshCoreChannelsConfigSection — add channel', () => {
});

// Find the PUT call.
const putCall = csrfFetchMock.mock.calls.find(
const putCall = channelCalls.find(
c => typeof c[1]?.method === 'string' && c[1].method === 'PUT',
);
expect(putCall).toBeDefined();
Expand Down Expand Up @@ -255,7 +280,7 @@ describe('MeshCoreChannelsConfigSection — hashtag channels', () => {

let putCall: any;
await waitFor(() => {
putCall = csrfFetchMock.mock.calls.find(
putCall = channelCalls.find(
c => typeof c[1]?.method === 'string' && c[1].method === 'PUT',
);
expect(putCall).toBeDefined();
Expand Down Expand Up @@ -300,7 +325,7 @@ describe('MeshCoreChannelsConfigSection — hashtag channels', () => {

let putCall: any;
await waitFor(() => {
putCall = csrfFetchMock.mock.calls.find(
putCall = channelCalls.find(
c => typeof c[1]?.method === 'string' && c[1].method === 'PUT',
);
expect(putCall).toBeDefined();
Expand Down Expand Up @@ -344,7 +369,7 @@ describe('MeshCoreChannelsConfigSection — delete + secret-visibility', () => {
fireEvent.click(deleteButtons[1]);
});

const deleteCall = csrfFetchMock.mock.calls.find(
const deleteCall = channelCalls.find(
c => typeof c[1]?.method === 'string' && c[1].method === 'DELETE',
);
expect(deleteCall).toBeDefined();
Expand Down
36 changes: 36 additions & 0 deletions src/components/MeshCore/MeshCoreChannelsConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,34 @@ export const MeshCoreChannelsConfigSection: React.FC<MeshCoreChannelsConfigSecti
const [showSecret, setShowSecret] = useState(false);
const [saving, setSaving] = useState(false);
const [reloadTick, setReloadTick] = useState(0);
// Saved-regions catalog (#3770) — offered as scope-field suggestions so the
// operator can pick a known region instead of typing it.
const [savedRegions, setSavedRegions] = useState<string[]>([]);
const { showToast } = useToast();

const reload = useCallback(() => setReloadTick(v => v + 1), []);

// Load the global saved-regions catalog for the scope-field datalist (#3770).
// Cheap local DB read, independent of device connection.
useEffect(() => {
if (!sourceId) return;
let cancelled = false;
(async () => {
try {
const url = `${baseUrl}/api/sources/${encodeURIComponent(sourceId)}/meshcore/saved-regions`;
const response = await csrfFetch(url);
if (!response.ok) return;
const data = await response.json();
if (!cancelled && data?.success && Array.isArray(data.regions)) {
setSavedRegions(data.regions.map((r: any) => String(r.name)).filter(Boolean));
}
} catch {
/* non-fatal — suggestions are optional */
}
})();
return () => { cancelled = true; };
}, [baseUrl, sourceId, csrfFetch]);

useEffect(() => {
if (!sourceId) return;
let cancelled = false;
Expand Down Expand Up @@ -379,6 +403,7 @@ export const MeshCoreChannelsConfigSection: React.FC<MeshCoreChannelsConfigSecti
onSecretChange={setEditSecretHex}
scope={editScope}
onScopeChange={setEditScope}
regionSuggestions={savedRegions}
showSecret={showSecret}
onToggleShowSecret={() => setShowSecret(v => !v)}
onRegenerate={handleRegenerate}
Expand Down Expand Up @@ -417,6 +442,7 @@ export const MeshCoreChannelsConfigSection: React.FC<MeshCoreChannelsConfigSecti
onSecretChange={setEditSecretHex}
scope={editScope}
onScopeChange={setEditScope}
regionSuggestions={savedRegions}
showSecret={showSecret}
onToggleShowSecret={() => setShowSecret(v => !v)}
onRegenerate={handleRegenerate}
Expand Down Expand Up @@ -454,6 +480,9 @@ interface ChannelEditorProps {
* inherit the source default scope / unscoped. */
scope: string;
onScopeChange: (v: string) => void;
/** Saved region names (#3770) offered as datalist suggestions on the scope
* field so the operator can pick a known region instead of typing it. */
regionSuggestions: string[];
showSecret: boolean;
onToggleShowSecret: () => void;
onRegenerate: () => void;
Expand All @@ -474,6 +503,7 @@ const ChannelEditor: React.FC<ChannelEditorProps> = ({
onSecretChange,
scope,
onScopeChange,
regionSuggestions,
showSecret,
onToggleShowSecret,
onRegenerate,
Expand Down Expand Up @@ -560,6 +590,7 @@ const ChannelEditor: React.FC<ChannelEditorProps> = ({
<input
id={`mc-ch-scope-${idx}`}
type="text"
list={`mc-ch-scope-regions-${idx}`}
value={scope}
onChange={e => onScopeChange(e.target.value)}
placeholder={t('meshcore.channels.scope_placeholder', 'e.g. muenchen — leave blank to inherit default')}
Expand All @@ -569,6 +600,11 @@ const ChannelEditor: React.FC<ChannelEditorProps> = ({
maxLength={63}
style={{ width: '100%' }}
/>
{regionSuggestions.length > 0 && (
<datalist id={`mc-ch-scope-regions-${idx}`}>
{regionSuggestions.map(r => <option key={r} value={r} />)}
</datalist>
)}
<p className="hint" style={{ fontSize: '0.8rem', marginTop: '0.25rem' }}>
{t(
'meshcore.channels.scope_hint',
Expand Down
33 changes: 32 additions & 1 deletion src/components/MeshCore/MeshCoreChannelsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,9 @@ export const MeshCoreChannelsView: React.FC<MeshCoreChannelsViewProps> = ({
// Region names served by nearby repeaters (#3667 phase 3) for the datalist
// suggestions on the override input.
const [discoveredRegions, setDiscoveredRegions] = useState<string[]>([]);
// User-saved regions catalog (#3770) — also offered as scope-override
// suggestions so the operator can pick a known region without typing it.
const [savedRegions, setSavedRegions] = useState<string[]>([]);
// Guard so region discovery — which emits active radio traffic — runs at most
// once per mount, and only after the operator signals intent by opening the
// scope-override control. We must NOT re-discover on every reconnect (#3704
Expand Down Expand Up @@ -301,6 +304,34 @@ export const MeshCoreChannelsView: React.FC<MeshCoreChannelsViewProps> = ({
return () => { cancelled = true; };
}, [status?.connected, actions]);

// Load the global saved-regions catalog (#3770) for the override suggestions.
// This is a cheap local DB read (no radio traffic), so it's safe to run on
// mount / source change regardless of connection state.
useEffect(() => {
let cancelled = false;
(async () => {
try {
const rows = await actions.fetchSavedRegions();
if (!cancelled && rows) setSavedRegions(rows.map(r => r.name));
} catch {
/* non-fatal — suggestions are optional */
}
})();
return () => { cancelled = true; };
}, [actions, sourceId]);

// Union of saved + discovered regions, de-duplicated, for the override
// datalist. Saved regions come first (operator-curated), then any extra
// freshly-discovered ones.
const scopeSuggestions = useMemo(() => {
const seen = new Set<string>();
const out: string[] = [];
for (const r of [...savedRegions, ...discoveredRegions]) {
if (r && !seen.has(r)) { seen.add(r); out.push(r); }
}
return out;
}, [savedRegions, discoveredRegions]);

// Lazily discover regions for the suggestion datalist ONLY once the operator
// opens the scope-override control (explicit intent), and at most once per
// mount. discoverRegions() emits active radio traffic, so we must never tie it
Expand Down Expand Up @@ -534,7 +565,7 @@ export const MeshCoreChannelsView: React.FC<MeshCoreChannelsViewProps> = ({
autoCorrect="off"
/>
<datalist id="mc-scope-region-suggestions">
{discoveredRegions.map(r => <option key={r} value={r} />)}
{scopeSuggestions.map(r => <option key={r} value={r} />)}
</datalist>
<button
type="button"
Expand Down
Loading
Loading