Files
Remote-Terminal-for-MeshCore/frontend/src/api.ts
2026-02-27 14:20:52 -08:00

281 lines
9.4 KiB
TypeScript

import type {
AppSettings,
AppSettingsUpdate,
Channel,
CommandResponse,
Contact,
ContactAdvertPath,
ContactAdvertPathSummary,
ContactDetail,
Favorite,
HealthStatus,
MaintenanceResult,
Message,
MigratePreferencesRequest,
MigratePreferencesResponse,
RadioConfig,
RadioConfigUpdate,
RepeaterAclResponse,
RepeaterAdvertIntervalsResponse,
RepeaterLoginResponse,
RepeaterLppTelemetryResponse,
RepeaterNeighborsResponse,
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
StatisticsResponse,
TraceResponse,
UnreadCounts,
} from './types';
const API_BASE = '/api';
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const hasBody = options?.body !== undefined;
const res = await fetch(`${API_BASE}${url}`, {
...options,
headers: {
...(hasBody && { 'Content-Type': 'application/json' }),
...options?.headers,
},
});
if (!res.ok) {
const errorText = await res.text();
// FastAPI returns errors as {"detail": "message"}, extract the message
let errorMessage = errorText || res.statusText;
try {
const errorJson = JSON.parse(errorText);
if (errorJson.detail) {
errorMessage = errorJson.detail;
}
} catch {
// Not JSON, use raw text
}
throw new Error(errorMessage);
}
return res.json();
}
/** Check if an error is an AbortError (request was cancelled) */
export function isAbortError(err: unknown): boolean {
// DOMException is thrown by fetch when aborted, and it's not an Error subclass
if (err instanceof DOMException && err.name === 'AbortError') {
return true;
}
// Also check for Error with AbortError name (for compatibility)
return err instanceof Error && err.name === 'AbortError';
}
interface DecryptResult {
started: boolean;
total_packets: number;
message: string;
}
export const api = {
// Health
getHealth: () => fetchJson<HealthStatus>('/health'),
// Radio config
getRadioConfig: () => fetchJson<RadioConfig>('/radio/config'),
updateRadioConfig: (config: RadioConfigUpdate) =>
fetchJson<RadioConfig>('/radio/config', {
method: 'PATCH',
body: JSON.stringify(config),
}),
setPrivateKey: (privateKey: string) =>
fetchJson<{ status: string }>('/radio/private-key', {
method: 'PUT',
body: JSON.stringify({ private_key: privateKey }),
}),
sendAdvertisement: () =>
fetchJson<{ status: string }>('/radio/advertise', {
method: 'POST',
}),
rebootRadio: () =>
fetchJson<{ status: string; message: string }>('/radio/reboot', {
method: 'POST',
}),
reconnectRadio: () =>
fetchJson<{ status: string; message: string; connected: boolean }>('/radio/reconnect', {
method: 'POST',
}),
// Contacts
getContacts: (limit = 100, offset = 0) =>
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
getRepeaterAdvertPaths: (limitPerRepeater = 10) =>
fetchJson<ContactAdvertPathSummary[]>(
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
),
getContactAdvertPaths: (publicKey: string, limit = 10) =>
fetchJson<ContactAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
getContactDetail: (publicKey: string) =>
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
deleteContact: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
}),
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
fetchJson<Contact>('/contacts', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey, name, try_historical: tryHistorical }),
}),
markContactRead: (publicKey: string) =>
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/mark-read`, {
method: 'POST',
}),
sendRepeaterCommand: (publicKey: string, command: string) =>
fetchJson<CommandResponse>(`/contacts/${publicKey}/command`, {
method: 'POST',
body: JSON.stringify({ command }),
}),
requestTrace: (publicKey: string) =>
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
method: 'POST',
}),
// Channels
getChannels: () => fetchJson<Channel[]>('/channels'),
createChannel: (name: string, key?: string) =>
fetchJson<Channel>('/channels', {
method: 'POST',
body: JSON.stringify({ name, key }),
}),
deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
markChannelRead: (key: string) =>
fetchJson<{ status: string; key: string }>(`/channels/${key}/mark-read`, {
method: 'POST',
}),
// Messages
getMessages: (
params?: {
limit?: number;
offset?: number;
type?: 'PRIV' | 'CHAN';
conversation_key?: string;
before?: number;
before_id?: number;
},
signal?: AbortSignal
) => {
const searchParams = new URLSearchParams();
if (params?.limit !== undefined) searchParams.set('limit', params.limit.toString());
if (params?.offset !== undefined) searchParams.set('offset', params.offset.toString());
if (params?.type) searchParams.set('type', params.type);
if (params?.conversation_key) searchParams.set('conversation_key', params.conversation_key);
if (params?.before !== undefined) searchParams.set('before', params.before.toString());
if (params?.before_id !== undefined) searchParams.set('before_id', params.before_id.toString());
const query = searchParams.toString();
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`, { signal });
},
sendDirectMessage: (destination: string, text: string) =>
fetchJson<Message>('/messages/direct', {
method: 'POST',
body: JSON.stringify({ destination, text }),
}),
sendChannelMessage: (channelKey: string, text: string) =>
fetchJson<Message>('/messages/channel', {
method: 'POST',
body: JSON.stringify({ channel_key: channelKey, text }),
}),
resendChannelMessage: (messageId: number, newTimestamp?: boolean) =>
fetchJson<{ status: string; message_id: number }>(
`/messages/channel/${messageId}/resend${newTimestamp ? '?new_timestamp=true' : ''}`,
{ method: 'POST' }
),
// Packets
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
decryptHistoricalPackets: (params: {
key_type: 'channel' | 'contact';
channel_key?: string;
channel_name?: string;
}) =>
fetchJson<DecryptResult>('/packets/decrypt/historical', {
method: 'POST',
body: JSON.stringify(params),
}),
runMaintenance: (options: { pruneUndecryptedDays?: number; purgeLinkedRawPackets?: boolean }) =>
fetchJson<MaintenanceResult>('/packets/maintenance', {
method: 'POST',
body: JSON.stringify({
...(options.pruneUndecryptedDays !== undefined && {
prune_undecrypted_days: options.pruneUndecryptedDays,
}),
...(options.purgeLinkedRawPackets !== undefined && {
purge_linked_raw_packets: options.purgeLinkedRawPackets,
}),
}),
}),
// Read State
getUnreads: () => fetchJson<UnreadCounts>('/read-state/unreads'),
markAllRead: () =>
fetchJson<{ status: string; timestamp: number }>('/read-state/mark-all-read', {
method: 'POST',
}),
// App Settings
getSettings: () => fetchJson<AppSettings>('/settings'),
updateSettings: (settings: AppSettingsUpdate) =>
fetchJson<AppSettings>('/settings', {
method: 'PATCH',
body: JSON.stringify(settings),
}),
// Favorites
toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites/toggle', {
method: 'POST',
body: JSON.stringify({ type, id }),
}),
// Preferences migration (one-time, from localStorage to database)
migratePreferences: (request: MigratePreferencesRequest) =>
fetchJson<MigratePreferencesResponse>('/settings/migrate', {
method: 'POST',
body: JSON.stringify(request),
}),
// Statistics
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
// Granular repeater endpoints
repeaterLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/repeater/login`, {
method: 'POST',
body: JSON.stringify({ password }),
}),
repeaterStatus: (publicKey: string) =>
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
method: 'POST',
}),
repeaterNeighbors: (publicKey: string) =>
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
method: 'POST',
}),
repeaterAcl: (publicKey: string) =>
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/repeater/acl`, {
method: 'POST',
}),
repeaterRadioSettings: (publicKey: string) =>
fetchJson<RepeaterRadioSettingsResponse>(`/contacts/${publicKey}/repeater/radio-settings`, {
method: 'POST',
}),
repeaterAdvertIntervals: (publicKey: string) =>
fetchJson<RepeaterAdvertIntervalsResponse>(`/contacts/${publicKey}/repeater/advert-intervals`, {
method: 'POST',
}),
repeaterOwnerInfo: (publicKey: string) =>
fetchJson<RepeaterOwnerInfoResponse>(`/contacts/${publicKey}/repeater/owner-info`, {
method: 'POST',
}),
repeaterLppTelemetry: (publicKey: string) =>
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
}),
};