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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ For per-source permission tests, mock `getUserPermissionSetAsync(userId, sourceI
### Migration Registry
Migrations use a centralized registry in `src/db/migrations.ts`. Each migration has functions for all three backends.

**Current migration count:** 110 (latest: `110_add_meshcore_position_history`).
**Current migration count:** 112 (latest: `112_add_notes_to_nodes`).

For the full "adding a migration" recipe see [Migration recipe](#migration-recipe) below.

Expand Down
14 changes: 13 additions & 1 deletion src/components/MessagesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2158,7 +2158,19 @@ const MessagesTab: React.FC<MessagesTabProps> = ({
)}
</div>

{selectedNode && <NodeDetailsBlock node={selectedNode} timeFormat={timeFormat} dateFormat={dateFormat} />}
{selectedNode && (
<NodeDetailsBlock
node={selectedNode}
timeFormat={timeFormat}
dateFormat={dateFormat}
canEditNotes={hasPermission('nodes', 'write')}
onSaveNotes={async (notes) => {
if (!selectedNode.user?.id) throw new Error('Node has no ID');
await apiService.setNodeNotes(selectedNode.user.id, notes, sourceId);
showToast(t('node_details.notes_saved', 'Notes saved'), 'success');
}}
/>
)}

{/* Security Details Section */}
{selectedNode &&
Expand Down
62 changes: 62 additions & 0 deletions src/components/NodeDetailsBlock.css
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,68 @@
grid-column: span 2;
}

/* Notes (#3921) — editable free-text per-node annotation */
.node-detail-notes-input {
width: 100%;
box-sizing: border-box;
margin-top: 4px;
padding: 8px 10px;
background-color: var(--ctp-base);
color: var(--ctp-text);
border: 1px solid var(--ctp-surface1);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 0.95rem;
line-height: 1.4;
resize: vertical;
}

.node-detail-notes-input:focus {
outline: none;
border-color: var(--ctp-blue);
}

.node-detail-notes-input:disabled {
opacity: 0.6;
}

.node-detail-notes-readonly {
font-size: 0.95rem;
font-weight: 400;
white-space: pre-wrap;
word-break: break-word;
}

.node-detail-notes-error {
display: block;
color: var(--ctp-red);
font-size: 0.85rem;
margin-top: 4px;
}

.node-detail-notes-actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}

.node-detail-notes-save {
background-color: var(--ctp-blue);
color: var(--ctp-base);
border: none;
border-radius: var(--radius-sm);
padding: 6px 14px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
}

.node-detail-notes-save:disabled {
opacity: 0.5;
cursor: not-allowed;
}

/* Public key styling */
.node-detail-public-key {
font-family: var(--font-mono);
Expand Down
80 changes: 79 additions & 1 deletion src/components/NodeDetailsBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,19 @@ interface NodeDetailsBlockProps {
node: DeviceInfo | null;
timeFormat?: TimeFormat;
dateFormat?: DateFormat;
/**
* When true (and `onSaveNotes` is provided), the free-text notes field (#3921)
* is rendered as an editable textarea. Otherwise any existing note is shown
* read-only.
*/
canEditNotes?: boolean;
/** Persist the edited note. Resolves on success, rejects on failure. */
onSaveNotes?: (notes: string) => Promise<void>;
}

const NodeDetailsBlock: React.FC<NodeDetailsBlockProps> = ({ node, timeFormat = '24', dateFormat = 'MM/DD/YYYY' }) => {
const MAX_NODE_NOTES_LENGTH = 2000;

const NodeDetailsBlock: React.FC<NodeDetailsBlockProps> = ({ node, timeFormat = '24', dateFormat = 'MM/DD/YYYY', canEditNotes = false, onSaveNotes }) => {
const { t } = useTranslation();
const { channels } = useChannels();
const { currentNodeId } = useDeviceConfig();
Expand All @@ -29,14 +39,50 @@ const NodeDetailsBlock: React.FC<NodeDetailsBlockProps> = ({ node, timeFormat =
return stored === 'true';
});

// #3921: editable free-text notes. Draft is kept in local state and only
// persisted on Save; it re-syncs whenever the selected node (or its stored
// note) changes.
const nodeNum = node?.nodeNum;
const storedNotes = node?.notes ?? '';
const [notesDraft, setNotesDraft] = useState<string>(storedNotes);
// Baseline the draft is compared against for the "dirty" check. It tracks the
// last-persisted value locally so the Save button settles immediately after a
// successful save, even before the polled `nodes` prop catches up.
const [notesBaseline, setNotesBaseline] = useState<string>(storedNotes);
const [notesSaving, setNotesSaving] = useState(false);
const [notesError, setNotesError] = useState<string | null>(null);

useEffect(() => {
localStorage.setItem('nodeDetailsCollapsed', isCollapsed.toString());
}, [isCollapsed]);

// Re-sync draft/baseline whenever the selected node or its stored note changes.
useEffect(() => {
setNotesDraft(storedNotes);
setNotesBaseline(storedNotes);
setNotesError(null);
}, [nodeNum, storedNotes]);

if (!node) {
return null;
}

const notesDirty = notesDraft !== notesBaseline;

const handleSaveNotes = async () => {
if (!onSaveNotes || notesSaving || !notesDirty) return;
setNotesSaving(true);
setNotesError(null);
try {
await onSaveNotes(notesDraft);
setNotesBaseline(notesDraft);
} catch (err) {
setNotesError(err instanceof Error ? err.message : t('node_details.notes_error', 'Failed to save notes'));
} finally {
setNotesSaving(false);
}
};

/**
* Get battery level indicator class based on percentage
*/
Expand Down Expand Up @@ -439,6 +485,38 @@ const NodeDetailsBlock: React.FC<NodeDetailsBlockProps> = ({ node, timeFormat =
</div>
</div>
)}

{/* Notes (#3921) — editable when permitted, otherwise read-only */}
{(canEditNotes && onSaveNotes) ? (
<div className="node-detail-card node-detail-card-2col node-detail-notes">
<div className="node-detail-label">{t('node_details.notes', 'Notes')}</div>
<textarea
className="node-detail-notes-input"
value={notesDraft}
maxLength={MAX_NODE_NOTES_LENGTH}
rows={3}
placeholder={t('node_details.notes_placeholder', 'Add a private note about this node…')}
onChange={e => setNotesDraft(e.target.value)}
disabled={notesSaving}
/>
{notesError && <span className="node-detail-notes-error">{notesError}</span>}
<div className="node-detail-notes-actions">
<button
type="button"
className="node-detail-notes-save"
onClick={handleSaveNotes}
disabled={notesSaving || !notesDirty}
>
{notesSaving ? t('common.saving', 'Saving…') : t('common.save', 'Save')}
</button>
</div>
</div>
) : storedNotes ? (
<div className="node-detail-card node-detail-card-2col node-detail-notes">
<div className="node-detail-label">{t('node_details.notes', 'Notes')}</div>
<div className="node-detail-value node-detail-notes-readonly">{storedNotes}</div>
</div>
) : null}
</div>
)}
</div>
Expand Down
12 changes: 6 additions & 6 deletions src/db/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
import { registry } from './migrations.js';

describe('migrations registry', () => {
it('has all 111 migrations registered', () => {
expect(registry.count()).toBe(111);
it('has all 112 migrations registered', () => {
expect(registry.count()).toBe(112);
});

// Bumping these counts: when adding a new migration, increment to <N>+1 and
Expand All @@ -15,14 +15,14 @@ describe('migrations registry', () => {
expect(all[0].name).toContain('v37_baseline');
});

it('last migration is meshcore_node_position_source', () => {
it('last migration is add_notes_to_nodes', () => {
const all = registry.getAll();
const last = all[all.length - 1];
expect(last.number).toBe(111);
expect(last.name).toContain('meshcore_node_position_source');
expect(last.number).toBe(112);
expect(last.name).toContain('add_notes_to_nodes');
});

it('migrations are sequentially numbered from 1 to 111', () => {
it('migrations are sequentially numbered from 1 to 112', () => {
const all = registry.getAll();
for (let i = 0; i < all.length; i++) {
expect(all[i].number).toBe(i + 1);
Expand Down
17 changes: 17 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ import { migration as meshcoreSavedRegionsMigration, runMigration108Postgres as
import { migration as clampFutureTracerouteMigration, runMigration109Postgres as runClampFutureTraceroutePostgres, runMigration109Mysql as runClampFutureTracerouteMysql } from '../server/migrations/109_clamp_future_traceroute_timestamps.js';
import { migration as meshcorePositionHistoryMigration, runMigration110Postgres as runMeshcorePositionHistoryPostgres, runMigration110Mysql as runMeshcorePositionHistoryMysql } from '../server/migrations/110_add_meshcore_position_history.js';
import { migration as meshcoreNodePositionSourceMigration, runMigration111Postgres as runMeshcoreNodePositionSourcePostgres, runMigration111Mysql as runMeshcoreNodePositionSourceMysql } from '../server/migrations/111_meshcore_node_position_source.js';
import { migration as nodeNotesMigration, runMigration112Postgres as runNodeNotesPostgres, runMigration112Mysql as runNodeNotesMysql } from '../server/migrations/112_add_notes_to_nodes.js';

// ============================================================================
// Registry
Expand Down Expand Up @@ -1765,3 +1766,19 @@ registry.register({
postgres: (client) => runMeshcoreNodePositionSourcePostgres(client),
mysql: (pool) => runMeshcoreNodePositionSourceMysql(pool),
});

// ---------------------------------------------------------------------------
// Migration 112: free-text `notes` on `nodes` (#3921)
// Per-node MeshMonitor-local annotation editable from the node detail view,
// mirroring the official mobile clients' local notes field. Never synced to
// the mesh. Nullable; existing rows keep NULL.
// ---------------------------------------------------------------------------

registry.register({
number: 112,
name: 'add_notes_to_nodes',
settingsKey: 'migration_112_add_notes_to_nodes',
sqlite: (db) => nodeNotesMigration.up(db),
postgres: (client) => runNodeNotesPostgres(client),
mysql: (pool) => runNodeNotesMysql(pool),
});
64 changes: 64 additions & 0 deletions src/db/repositories/nodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const POSTGRES_CREATE = `
"altitudeOverride" REAL,
"positionOverrideIsPrivate" BOOLEAN DEFAULT FALSE,
"hideFromMap" BOOLEAN DEFAULT FALSE,
"notes" TEXT,
"isUnmessagable" BOOLEAN DEFAULT FALSE,
"isLicensed" BOOLEAN DEFAULT FALSE,
"hasRemoteAdmin" BOOLEAN DEFAULT FALSE,
Expand Down Expand Up @@ -149,6 +150,7 @@ const MYSQL_CREATE = `
altitudeOverride DOUBLE,
positionOverrideIsPrivate BOOLEAN DEFAULT FALSE,
hideFromMap BOOLEAN DEFAULT FALSE,
notes VARCHAR(2000),
isUnmessagable BOOLEAN DEFAULT FALSE,
isLicensed BOOLEAN DEFAULT FALSE,
hasRemoteAdmin BOOLEAN DEFAULT FALSE,
Expand Down Expand Up @@ -746,6 +748,68 @@ function runNodesTests(getBackend: () => TestBackend) {
expect(Boolean(inB!.hideFromMap)).toBe(false);
});

// --- notes (#3921) ---

it('setNodeNotes - sets and clears the free-text note', async () => {
const backend = getBackend();
if (!backend.available) {
console.log(`⚠ Skipped: ${backend.skipReason}`);
return;
}

await repo.upsertNode(makeNode(820), 'default');

await repo.setNodeNotes(820, 'Solar repeater on the hill', 'default');
let node = await repo.getNode(820) as any;
expect(node!.notes).toBe('Solar repeater on the hill');

// Empty string clears the note
await repo.setNodeNotes(820, '', 'default');
node = await repo.getNode(820) as any;
expect(node!.notes).toBe('');
});

it('setNodeNotes - is per-source isolated (#3921)', async () => {
const backend = getBackend();
if (!backend.available) {
console.log(`⚠ Skipped: ${backend.skipReason}`);
return;
}

await repo.upsertNode(makeNode(821), 'source-a');
await repo.upsertNode(makeNode(821), 'source-b');

await repo.setNodeNotes(821, 'note for A', 'source-a');

const inA = await repo.getNode(821, 'source-a') as any;
const inB = await repo.getNode(821, 'source-b') as any;
expect(inA!.notes).toBe('note for A');
// Source B must be untouched (null / no note)
expect(inB!.notes ?? null).toBeNull();
});

it('upsertNode - preserves notes across packet-driven updates (#3921)', async () => {
const backend = getBackend();
if (!backend.available) {
console.log(`⚠ Skipped: ${backend.skipReason}`);
return;
}

await repo.upsertNode(makeNode(822), 'default');
await repo.setNodeNotes(822, 'keep me', 'default');

// A later mesh packet updates position but carries no notes field —
// the user's note must survive the packet-driven upsert.
await repo.upsertNode({
...makeNode(822),
latitude: 1.0,
longitude: 2.0,
}, 'default');

const node = await repo.getNode(822) as any;
expect(node!.notes).toBe('keep me');
});

// --- setNodeIgnored ---

it('setNodeIgnored - toggles ignored status', async () => {
Expand Down
Loading
Loading