diff --git a/package-lock.json b/package-lock.json
index 6f6909d..a6f3811 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-pick-color": "^2.0.0",
"react-router": "^7.3.0",
"react-router-dom": "^7.3.0",
"react-select": "^5.10.1",
@@ -9105,6 +9106,18 @@
"react": "^19.0.0"
}
},
+ "node_modules/react-pick-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/react-pick-color/-/react-pick-color-2.0.0.tgz",
+ "integrity": "sha512-GLYyUN1k60cxkrizqRDqfmCBNP6vJZDam5TfCMMxgxPjNul9zmunAZAJ8x9wy1yMb1NqMa/MI2np7oDQLCEbDg==",
+ "dependencies": {
+ "tinycolor2": "^1.4.1"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
"node_modules/react-router": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.3.0.tgz",
@@ -10414,6 +10427,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/tinycolor2": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
+ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
+ },
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -16862,6 +16880,14 @@
"scheduler": "^0.25.0"
}
},
+ "react-pick-color": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/react-pick-color/-/react-pick-color-2.0.0.tgz",
+ "integrity": "sha512-GLYyUN1k60cxkrizqRDqfmCBNP6vJZDam5TfCMMxgxPjNul9zmunAZAJ8x9wy1yMb1NqMa/MI2np7oDQLCEbDg==",
+ "requires": {
+ "tinycolor2": "^1.4.1"
+ }
+ },
"react-router": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.3.0.tgz",
@@ -17727,6 +17753,11 @@
"convert-hrtime": "^5.0.0"
}
},
+ "tinycolor2": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
+ "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
+ },
"to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
diff --git a/package.json b/package.json
index e2f889a..7c0db0b 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,7 @@
"qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
+ "react-pick-color": "^2.0.0",
"react-router": "^7.3.0",
"react-router-dom": "^7.3.0",
"react-select": "^5.10.1",
diff --git a/src/ListItems.tsx b/src/ListItems.tsx
index 3458980..1fce4d0 100644
--- a/src/ListItems.tsx
+++ b/src/ListItems.tsx
@@ -7,6 +7,7 @@ import ManageAccountsIcon from '@mui/icons-material/ManageAccounts';
import SyncIcon from '@mui/icons-material/Sync';
import HomeIcon from '@mui/icons-material/Home';
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
+import EditIcon from '@mui/icons-material/Edit';
import { ListItemButton } from '@mui/material';
export const mainListItems = (
@@ -18,7 +19,7 @@ export const mainListItems = (
- window.open('/admin/edit-site', '_self')}>
+ window.open('/admin/list-sites', '_self')}>
diff --git a/src/admin/AdminBody.tsx b/src/admin/AdminBody.tsx
index 730ce4b..068dbbb 100644
--- a/src/admin/AdminBody.tsx
+++ b/src/admin/AdminBody.tsx
@@ -1,6 +1,8 @@
import UserPage from './UserPage';
import EditSite from './EditSite';
import EditData from './EditData';
+import ListSites from './ListSites';
+import CreateEditSite from './CreateEditSite';
interface AdminBodyProps {
page: AdminPage;
@@ -14,6 +16,12 @@ export default function AdminBody(props: AdminBodyProps) {
return ;
case 'edit-data':
return ;
+ case 'list-sites':
+ return ;
+ case 'create-site':
+ return ;
+ case 'new-edit-site':
+ return ;
default:
return Error
;
}
diff --git a/src/admin/CreateEditSite.tsx b/src/admin/CreateEditSite.tsx
new file mode 100644
index 0000000..4e19d43
--- /dev/null
+++ b/src/admin/CreateEditSite.tsx
@@ -0,0 +1,487 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Box,
+ Container,
+ Paper,
+ Typography,
+ TextField,
+ Button,
+ IconButton,
+ Select,
+ MenuItem,
+ FormControl,
+ InputLabel,
+ Checkbox,
+ FormControlLabel,
+ Divider,
+} from '@mui/material';
+import {
+ ArrowBack as ArrowBackIcon,
+ Add as AddIcon,
+ Delete as DeleteIcon,
+} from '@mui/icons-material';
+import ColorPicker from 'react-pick-color';
+import { apiClient } from '@/utils/fetch';
+import { siteToSchema, siteToNewSiteRequest } from '@/utils/siteUtils';
+
+interface CellEntry {
+ id: string;
+ cellId: string;
+}
+
+interface BoundaryPoint {
+ id: string;
+ lat: string;
+ lng: string;
+}
+
+interface CreateEditSiteProps {
+ mode: 'create' | 'edit';
+}
+
+export default function CreateEditSite({ mode }: CreateEditSiteProps) {
+ const [identity, setIdentity] = useState('');
+ const [name, setName] = useState('');
+ const [longitude, setLongitude] = useState('');
+ const [latitude, setLatitude] = useState('');
+ const [status, setStatus] = useState('');
+ const [address, setAddress] = useState('');
+ const [cells, setCells] = useState([]);
+ const [colorEnabled, setColorEnabled] = useState(true);
+ const [colorValue, setColorValue] = useState('#fff');
+ const [boundaryEnabled, setBoundaryEnabled] = useState(true);
+ const [boundaryPoints, setBoundaryPoints] = useState([]);
+
+ const editSite = (site: Site) => {
+ return apiClient
+ .PUT('/secure/edit-sites', {
+ body: siteToSchema(site),
+ })
+ .then(res => {
+ const { data, error } = res;
+ if (error) {
+ console.error(`Failed to edit site: ${error}`);
+ return Promise.reject(error);
+ }
+ console.log(`Successfully edited site: ${site.name}`);
+ return data;
+ })
+ .catch(err => {
+ console.error(`Error editing site: ${err}`);
+ return Promise.reject(err);
+ });
+ };
+
+ const createSite = (site: Site) => {
+ return apiClient
+ .POST('/secure/edit-sites', {
+ body: siteToNewSiteRequest(site),
+ })
+ .then(res => {
+ const { data, error } = res;
+ if (error) {
+ console.error(`Failed to create site: ${error}`);
+ return Promise.reject(error);
+ }
+ console.log(`Successfully created site: ${site.name}`);
+ return data;
+ })
+ .catch(err => {
+ console.error(`Error creating site: ${err}`);
+ return Promise.reject(err);
+ });
+ };
+
+ const handleBack = () => {
+ console.log('Navigate back');
+ window.open('/admin/list-sites', '_self');
+ };
+
+ const handleSave = () => {
+ console.log('Save site');
+ if (validateSite()) {
+ const site: Site = {
+ identity,
+ name,
+ latitude: parseFloat(latitude),
+ longitude: parseFloat(longitude),
+ status: status as SiteStatus,
+ address,
+ cell_ids: cells.map(cell => cell.cellId),
+ color: colorEnabled ? colorValue : undefined,
+ boundary: boundaryEnabled
+ ? boundaryPoints.map(point => [
+ parseFloat(point.lat),
+ parseFloat(point.lng),
+ ])
+ : undefined,
+ };
+ const savePromise = mode === 'edit' ? editSite(site) : createSite(site);
+
+ savePromise.then(() => {
+ handleBack();
+ });
+ }
+ };
+
+ const addCell = () => {
+ const newCell: CellEntry = {
+ id: Date.now().toString(),
+ cellId: '',
+ };
+ setCells([...cells, newCell]);
+ };
+
+ const deleteCell = (id: string) => {
+ setCells(cells.filter(cell => cell.id !== id));
+ };
+
+ const updateCellId = (id: string, cellId: string) => {
+ setCells(cells.map(cell => (cell.id === id ? { ...cell, cellId } : cell)));
+ };
+
+ const addBoundaryPoint = () => {
+ const newPoint: BoundaryPoint = {
+ id: Date.now().toString(),
+ lat: '',
+ lng: '',
+ };
+ setBoundaryPoints([...boundaryPoints, newPoint]);
+ };
+
+ const deleteBoundaryPoint = (id: string) => {
+ setBoundaryPoints(boundaryPoints.filter(point => point.id !== id));
+ };
+
+ const updateBoundaryPoint = (
+ id: string,
+ field: 'lat' | 'lng',
+ value: string,
+ ) => {
+ setBoundaryPoints(
+ boundaryPoints.map(point =>
+ point.id === id ? { ...point, [field]: value } : point,
+ ),
+ );
+ };
+
+ const validateSite = (): boolean => {
+ if (name === '') {
+ alert('Name is required');
+ return false;
+ }
+ if (
+ longitude === '' ||
+ isNaN(Number(longitude)) ||
+ Number(longitude) < -180 ||
+ Number(longitude) > 180
+ ) {
+ alert('Valid Longitude is required');
+ return false;
+ }
+ if (
+ latitude === '' ||
+ isNaN(Number(latitude)) ||
+ Number(latitude) < -90 ||
+ Number(latitude) > 90
+ ) {
+ alert('Valid Latitude is required');
+ return false;
+ }
+ if (status === '') {
+ alert('Status is required');
+ return false;
+ }
+ if (address === '') {
+ alert('Address is required');
+ return false;
+ }
+ if (cells.length === 0) {
+ alert('At least one Cell ID is required');
+ return false;
+ }
+ if (boundaryEnabled) {
+ for (const point of boundaryPoints) {
+ if (
+ point.lat === '' ||
+ isNaN(Number(point.lat)) ||
+ Number(point.lat) < -90 ||
+ Number(point.lat) > 90
+ ) {
+ alert('Valid Latitude for Boundary Point is required');
+ return false;
+ }
+ if (
+ point.lng === '' ||
+ isNaN(Number(point.lng)) ||
+ Number(point.lng) < -180 ||
+ Number(point.lng) > 180
+ ) {
+ alert('Valid Longitude for Boundary Point is required');
+ return false;
+ }
+ }
+ }
+ return true;
+ };
+
+ useEffect(() => {
+ if (mode === 'edit') {
+ const urlParams = new URLSearchParams(window.location.search);
+ const siteParam = urlParams.get('site');
+ if (siteParam) {
+ try {
+ const siteData = JSON.parse(decodeURIComponent(siteParam));
+ setIdentity(siteData.identity);
+ setName(siteData.name);
+ setLatitude(siteData.latitude.toString());
+ setLongitude(siteData.longitude.toString());
+ setStatus(siteData.status);
+ setAddress(siteData.address);
+ setCells(
+ siteData.cell_id.map((cellId: string, index: number) => ({
+ id: `${Date.now()}-cell-${index}`,
+ cellId: cellId,
+ })),
+ );
+ if (siteData.color) {
+ setColorEnabled(true);
+ setColorValue(siteData.color);
+ }
+ if (siteData.boundary) {
+ setBoundaryEnabled(true);
+ setBoundaryPoints(
+ siteData.boundary
+ .filter(
+ (point: [number, number]) =>
+ point && point[0] !== null && point[1] !== null,
+ )
+ .map((point: [number, number], index: number) => ({
+ id: `${Date.now()}-boundary-${index}`,
+ lat: point[0].toString(),
+ lng: point[1].toString(),
+ })),
+ );
+ }
+ } catch (error) {
+ console.error('Failed to parse site data from URL:', error);
+ }
+ }
+ }
+ }, [mode]);
+
+ return (
+
+
+
+
+
+
+
+
+
+ setName(e.target.value)}
+ sx={{ mb: 2 }}
+ />
+
+
+ setLongitude(e.target.value)}
+ />
+ setLatitude(e.target.value)}
+ />
+
+
+
+ Status
+
+
+
+ setAddress(e.target.value)}
+ sx={{ mb: 2 }}
+ />
+
+
+
+
+ Cells
+
+
+
+
+
+
+ {cells.map(cell => (
+
+
+ Cell ID
+
+ updateCellId(cell.id, e.target.value)}
+ sx={{ flexGrow: 1 }}
+ />
+
+
+ ))}
+
+
+
+
+
+ setColorEnabled(e.target.checked)}
+ />
+ }
+ label='Color'
+ />
+ {colorEnabled && (
+ setColorValue(color.hex)}
+ />
+ )}
+
+
+
+ setBoundaryEnabled(e.target.checked)}
+ />
+ }
+ label='Boundary'
+ />
+ {boundaryEnabled && (
+
+
+
+ )}
+
+
+ {boundaryEnabled &&
+ boundaryPoints.map(point => (
+
+
+ (Lat, Long)
+
+
+ updateBoundaryPoint(point.id, 'lat', e.target.value)
+ }
+ sx={{ flexGrow: 1 }}
+ />
+
+ updateBoundaryPoint(point.id, 'lng', e.target.value)
+ }
+ sx={{ flexGrow: 1 }}
+ />
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/admin/ListSites.tsx b/src/admin/ListSites.tsx
new file mode 100644
index 0000000..65a0b3c
--- /dev/null
+++ b/src/admin/ListSites.tsx
@@ -0,0 +1,197 @@
+import { useEffect, useState } from 'react';
+import {
+ Box,
+ Container,
+ Paper,
+ Typography,
+ Button,
+ Fab,
+ List,
+ ListItem,
+ ListItemText,
+} from '@mui/material';
+import { Add as AddIcon } from '@mui/icons-material';
+import { apiClient } from '@/utils/fetch';
+import { siteToSchema } from '@/utils/siteUtils';
+
+const parseSitesFromJSON = (jsonString: string): Site[] => {
+ try {
+ const parsed = JSON.parse(jsonString);
+
+ if (!Array.isArray(parsed)) {
+ throw new Error('Invalid format: response should be an array of sites');
+ }
+
+ const sites: Site[] = parsed.map((site: any): Site => {
+ return {
+ identity: site._id,
+ name: site.name,
+ latitude: site.latitude,
+ longitude: site.longitude,
+ status: site.status,
+ address: site.address,
+ cell_ids: site.cell_ids,
+ color: site.color,
+ boundary:
+ site.boundary?.map(
+ (point: any) => [point[0], point[1]] as [number, number],
+ ) ?? undefined,
+ };
+ });
+
+ return sites;
+ } catch (error) {
+ console.error('Failed to parse sites JSON:', error);
+ return [];
+ }
+};
+
+export default function ListSites() {
+ const [sites, setSites] = useState([]);
+ const handleEdit = (siteIdentity: string) => {
+ console.log(`Edit site with identity: ${siteIdentity}`);
+ const site = sites.find(s => s.identity === siteIdentity);
+ if (site) {
+ const siteData = encodeURIComponent(JSON.stringify(site));
+ window.open(`/admin/new-edit-site?site=${siteData}`, '_self');
+ }
+ };
+
+ const handleDelete = (siteIdentity: string) => {
+ const site = sites.find(s => s.identity === siteIdentity);
+ if (site) {
+ const confirmed = window.confirm(
+ `Are you sure you want to delete "${site.name}"?`,
+ );
+ if (confirmed) {
+ deleteSite(site);
+ reloadSites();
+ }
+ }
+ };
+
+ const handleAdd = () => {
+ console.log('Add new site');
+ window.open('/admin/create-site', '_self');
+ };
+
+ const reloadSites = () => {
+ apiClient
+ .GET('/api/sites')
+ .then(res => {
+ const { data, error } = res;
+ if (error || !data) {
+ console.log(`Unable to query sites: ${error}`);
+ return;
+ }
+ setSites(parseSitesFromJSON(JSON.stringify(data)));
+ })
+ .catch(err => {
+ return ;
+ });
+ };
+ useEffect(() => {
+ reloadSites();
+ }, []);
+
+ const deleteSite = (site: Site) => {
+ apiClient
+ .DELETE('/secure/edit-sites', {
+ body: {
+ identity: site.identity,
+ },
+ })
+ .then(res => {
+ const { data, error } = res;
+ if (error) {
+ console.error(`Failed to delete site: ${error}`);
+ return;
+ }
+ console.log(
+ `Successfully deleted site: ${site.name} (identity: ${site.identity})`,
+ );
+ reloadSites();
+ })
+ .catch(err => {
+ console.error(`Error deleting site: ${err}`);
+ });
+ };
+
+ return (
+
+
+
+ Site Management
+
+
+
+ {sites.map(site => (
+
+
+ {site.name}
+
+ }
+ sx={{ flexGrow: 1 }}
+ />
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
diff --git a/src/index.tsx b/src/index.tsx
index 793b884..2fe99f9 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -23,6 +23,18 @@ root.render(
} />
} />
} />
+ }
+ />
+ }
+ />
+ }
+ />
}
diff --git a/src/leaflet-component/site-marker.ts b/src/leaflet-component/site-marker.ts
index b479b0d..f441482 100644
--- a/src/leaflet-component/site-marker.ts
+++ b/src/leaflet-component/site-marker.ts
@@ -18,6 +18,7 @@ export function isMarkerArray(marker: any[]): marker is Marker[] {
export function isSite(prop: any): prop is Site {
return (
+ typeof prop?.identity === 'string' ||
typeof prop?.name === 'string' ||
typeof prop?.latitude === 'number' ||
typeof prop?.longitude === 'number' ||
diff --git a/src/types.d.ts b/src/types.d.ts
index e213942..d8dd57d 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -1,7 +1,10 @@
+type LatLng = [number, number];
+
type SiteOption = {
label: string;
value: string;
status: SiteStatus;
+ identity: string;
};
type DeviceOption = {
@@ -22,7 +25,13 @@ type DisplayOption = {
type SiteStatus = 'active' | 'confirmed' | 'in-conversation' | 'unknown';
-type AdminPage = 'users' | 'edit-site' | 'edit-data';
+type AdminPage =
+ | 'users'
+ | 'edit-site'
+ | 'edit-data'
+ | 'list-sites'
+ | 'create-site'
+ | 'new-edit-site';
type UserRow = {
identity: string;
@@ -36,12 +45,13 @@ type UserRow = {
};
type Site = {
+ identity: string;
name: string;
latitude: number;
longitude: number;
status: SiteStatus;
address: string;
- cell_id: string[];
+ cell_ids?: string[];
color?: string;
boundary?: LatLng[];
};
diff --git a/src/types/api.d.ts b/src/types/api.d.ts
index 07f96f8..873eec3 100644
--- a/src/types/api.d.ts
+++ b/src/types/api.d.ts
@@ -618,7 +618,6 @@ export interface paths {
* @description Parses CSV data and stores it as both signal and measurement records.
* If a group is specified, any existing data with that group will be removed first.
* The CSV should include columns for date, time, coordinate, cell_id, dbm, ping, download_speed, and upload_speed.
- *
*/
post: {
parameters: {
@@ -698,7 +697,6 @@ export interface paths {
* @description Returns two lists:
* 1. Registered users sorted by issue date (newest first)
* 2. Pending users whose issue date is within the expiry display limit, sorted by issue date (newest first)
- *
*/
post: {
parameters: {
@@ -825,7 +823,6 @@ export interface paths {
* Uses Passport LDAP strategy which binds to the LDAP server with the provided credentials.
* On success, creates a session and redirects to /api/success.
* On failure, redirects to /api/failure.
- *
*/
post: {
parameters: {
@@ -937,7 +934,6 @@ export interface paths {
* Update site configuration
* @description Updates the sites configuration file with provided data.
* Requires user to be authenticated - will redirect to login page if not authenticated.
- *
*/
post: {
parameters: {
@@ -1016,7 +1012,6 @@ export interface paths {
* @description Creates a new user with a cryptographically secure identity using EC keys.
* Generates keypairs, creates signatures, and stores user information.
* Requires authentication - will redirect to login page if not authenticated.
- *
*/
post: {
parameters: {
@@ -1075,6 +1070,190 @@ export interface paths {
patch?: never;
trace?: never;
};
+ '/secure/edit-sites': {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ /**
+ * Update an existing site
+ * @description Updates an existing site with the provided information
+ */
+ put: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['Site'];
+ };
+ };
+ responses: {
+ /** @description Site successfully updated */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Bad request - invalid site data */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Unauthorized - User not logged in */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Server error while updating site */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ };
+ };
+ /**
+ * Add a new site
+ * @description Creates a new site with the provided information
+ */
+ post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': components['schemas']['NewSiteRequest'];
+ };
+ };
+ responses: {
+ /** @description Site successfully created */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Bad request - invalid site data */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Unauthorized - User not logged in */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Server error while creating site */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ };
+ };
+ /**
+ * Delete a site
+ * @description Removes an existing site from the system using its unique identity
+ */
+ delete: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ 'application/json': {
+ /**
+ * @description The unique identifier of the site to delete
+ * @example 123e4567-e89b-12d3-a456-426614174000
+ */
+ identity: string;
+ };
+ };
+ };
+ responses: {
+ /** @description Site successfully deleted */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Bad request - invalid site data */
+ 400: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Unauthorized - User not logged in */
+ 401: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ /** @description Server error while deleting site */
+ 500: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ 'text/plain': string;
+ };
+ };
+ };
+ };
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record;
export interface components {
@@ -1271,6 +1450,11 @@ export interface components {
package_loss: number;
};
Site: {
+ /**
+ * @description Unique identifier of the site
+ * @example 123e4567-e89b-12d3-a456-426614174000
+ */
+ identity: string;
/**
* @description Name of the site
* @example Filipino Community Center
@@ -1300,23 +1484,64 @@ export interface components {
*/
address: string;
/** @description Array of cell identifiers associated with the site */
- cell_id: string[];
+ cell_ids?: string[];
/**
* @description Optional color identifier for the site in hex code
* @example #FF5733
*/
color?: string;
/** @description Optional geographical boundary coordinates defining the site perimeter as [latitude, longitude] pairs */
- boundary?: [number, number][];
+ boundaries?: [number, number][];
};
- /** @example {
+ NewSiteRequest: {
+ /**
+ * @description Name of the site
+ * @example Filipino Community Center
+ */
+ name: string;
+ /**
+ * Format: double
+ * @description Geographic latitude coordinate
+ * @example 47.681932654395915
+ */
+ latitude: number;
+ /**
+ * Format: double
+ * @description Geographic longitude coordinate
+ * @example -122.31829217664796
+ */
+ longitude: number;
+ /**
+ * @description Current status of the site
+ * @example active
+ * @enum {string}
+ */
+ status: NewSiteRequestStatus;
+ /**
+ * @description Physical address of the site
+ * @example 5740 Martin Luther King Jr Way S, Seattle, WA 98118
+ */
+ address: string;
+ /** @description Array of cell identifiers associated with the site */
+ cell_ids?: string[];
+ /**
+ * @description Optional color identifier for the site in hex code
+ * @example #FF5733
+ */
+ color?: string;
+ /** @description Optional geographical boundary coordinates defining the site perimeter as [latitude, longitude] pairs */
+ boundaries?: [number, number][];
+ };
+ /**
+ * @example {
* "Filipino Community Center": {
* "ping": 115.28,
* "download_speed": 7.16,
* "upload_speed": 8.63,
* "dbm": -78.4
* }
- * } */
+ * }
+ */
SitesSummary: {
[key: string]: {
/**
@@ -1737,3 +1962,8 @@ export enum SiteStatus {
confirmed = 'confirmed',
in_conversation = 'in-conversation',
}
+export enum NewSiteRequestStatus {
+ active = 'active',
+ confirmed = 'confirmed',
+ in_conversation = 'in-conversation',
+}
diff --git a/src/utils/siteUtils.ts b/src/utils/siteUtils.ts
new file mode 100644
index 0000000..758c250
--- /dev/null
+++ b/src/utils/siteUtils.ts
@@ -0,0 +1,50 @@
+import { components } from '@/types/api';
+
+export const siteToSchema = (site: Site): components['schemas']['Site'] => {
+ return {
+ identity: site.identity,
+ name: site.name,
+ latitude: site.latitude,
+ longitude: site.longitude,
+ status: siteStatusToSchema(site.status),
+ address: site.address,
+ cell_ids: site.cell_ids,
+ color: site.color,
+ boundaries: site.boundary,
+ };
+};
+
+export const siteToNewSiteRequest = (
+ site: Site,
+): components['schemas']['NewSiteRequest'] => {
+ return {
+ name: site.name,
+ latitude: site.latitude,
+ longitude: site.longitude,
+ status: siteStatusToNewSiteRequestSchema(site.status),
+ address: site.address,
+ cell_ids: site.cell_ids,
+ color: site.color,
+ boundaries: site.boundary,
+ };
+};
+
+export const siteStatusToSchema = (
+ siteStatus: SiteStatus,
+): components['parameters']['SiteStatus'] => {
+ if (siteStatus === 'unknown') {
+ throw new Error(`Invalid site status: ${siteStatus}`);
+ } else {
+ return siteStatus as components['parameters']['SiteStatus'];
+ }
+};
+
+export const siteStatusToNewSiteRequestSchema = (
+ siteStatus: SiteStatus,
+): components['schemas']['NewSiteRequest']['status'] => {
+ if (siteStatus === 'unknown') {
+ throw new Error(`Invalid site status: ${siteStatus}`);
+ } else {
+ return siteStatus as components['schemas']['NewSiteRequest']['status'];
+ }
+};
diff --git a/src/vis/LineChart.tsx b/src/vis/LineChart.tsx
index d1272f1..b68b42f 100644
--- a/src/vis/LineChart.tsx
+++ b/src/vis/LineChart.tsx
@@ -83,7 +83,7 @@ const LineChart = ({
}, [setXAxis, setYAxis, setLines, setYTitle, setLoading]);
useEffect(() => {
(async () => {
- const _selectedSites = selectedSites.map(ss => ss.label);
+ const _selectedSites = selectedSites.map(ss => ss.value);
if (selectedSites.length === 0) {
return;
}
@@ -111,9 +111,9 @@ const LineChart = ({
if (!xAxis || !yAxis || !lines || !yTitle || !lineSummary) return;
(async function () {
setLoading(true);
- let colors: { [name: string]: string } = {};
+ let colors: { [identity: string]: string } = {};
for (let site of allSites) {
- colors[site.name] = site.color ?? '#000000';
+ colors[site.identity] = site.color ?? '#000000';
}
const data: {
site: string;
diff --git a/src/vis/MeasurementMap.tsx b/src/vis/MeasurementMap.tsx
index 456e9da..331e249 100644
--- a/src/vis/MeasurementMap.tsx
+++ b/src/vis/MeasurementMap.tsx
@@ -228,25 +228,31 @@ const MeasurementMap = ({
}
const summary = sitesSummary[site.name];
if (!summary) {
- console.warn(`Unknown site: ${site.name}`);
+ console.warn(`Unknown site: ${site.name} (identity: ${site.identity})`);
continue;
}
- _markers.set(site.name, siteMarker(site, summary, map).addTo(slayer));
+ _markers.set(site.identity, siteMarker(site, summary, map).addTo(slayer));
}
- _markers.forEach((marker, site) => {
- if (selectedSites.some(s => s.label === site)) {
+ _markers.forEach((marker, siteIdentity) => {
+ if (selectedSites.some(s => s.value === siteIdentity)) {
marker.setOpacity(1);
} else {
marker.setOpacity(0.5);
}
- if (allSites.some(s => s.name === site && s.status === 'active')) {
+ if (
+ allSites.some(s => s.identity === siteIdentity && s.status === 'active')
+ ) {
marker.setIcon(greenIcon);
} else if (
- allSites.some(s => s.name === site && s.status === 'confirmed')
+ allSites.some(
+ s => s.identity === siteIdentity && s.status === 'confirmed',
+ )
) {
marker.setIcon(goldIcon);
} else if (
- allSites.some(s => s.name === site && s.status === 'in-conversation')
+ allSites.some(
+ s => s.identity === siteIdentity && s.status === 'in-conversation',
+ )
) {
marker.setIcon(redIcon);
}
@@ -264,7 +270,7 @@ const MeasurementMap = ({
const { data, error } = await apiClient.GET('/api/markers', {
params: {
query: {
- sites: selectedSites.map(ss => ss.label).join(','),
+ sites: selectedSites.map(ss => ss.value).join(','),
devices: selectedDevices.map(ss => ss.label).join(','),
timeFrom: timeFrom.toISOString(),
timeTo: timeTo.toISOString(),
@@ -323,7 +329,7 @@ const MeasurementMap = ({
top: bounds.top,
binSizeShift: BIN_SIZE_SHIFT,
zoom: DEFAULT_ZOOM,
- selectedSites: selectedSites.map(ss => ss.label).join(','),
+ selectedSites: selectedSites.map(ss => ss.value).join(','),
mapType: mapType,
timeFrom: timeFrom.toISOString(),
timeTo: timeTo.toISOString(),
diff --git a/src/vis/SiteSelect.tsx b/src/vis/SiteSelect.tsx
index 75017e2..0fa6088 100644
--- a/src/vis/SiteSelect.tsx
+++ b/src/vis/SiteSelect.tsx
@@ -12,10 +12,11 @@ interface SidebarProps {
}
const SiteSelect = (props: SidebarProps) => {
- const siteOptions = props.allSites.map(({ name, status }) => ({
+ const siteOptions = props.allSites.map(({ identity, name, status }) => ({
label: name,
- value: name,
+ value: identity,
status: status,
+ identity: identity,
}));
return (
diff --git a/src/vis/Vis.tsx b/src/vis/Vis.tsx
index c496878..cb95e8f 100644
--- a/src/vis/Vis.tsx
+++ b/src/vis/Vis.tsx
@@ -155,10 +155,11 @@ export default function Vis() {
return;
}
- const siteOptions = data.map(({ name, status }) => ({
+ const siteOptions = data.map(({ identity, name, status }) => ({
label: name,
- value: name,
+ value: identity,
status: status,
+ identity: identity,
}));
setSites(data);
setSiteOptions(siteOptions);