Add adjustable probe area for OpenSky test connection
Let the OpenSky health probe run over a chosen bounding box without touching the saved default, so its credit cost (which scales with area) can be checked cheaply. - Backend: the plugin health endpoint (panel Check) and the OpenSky health endpoint (Web App Test connection) accept an optional ?bbox=, validated with validBBox and applied to a transient probe instance; both BFF and API forward it. Saved config is untouched. - API panel: a "Probe area" picker beside each bbox-capable plugin's Check button — saved default, all countries (grouped by continent), or Custom. Adds a compact pv-input-sm style. - Web App: a "Test area" picker beside Test connection, using the full country list (new countryGroups() helper) plus Custom. This is where the probe is authenticated and the credit cost is shown. Builds pass across both Go modules and both frontends. Panel picker verified live (186 options, defaults to saved default). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
763cc67081
commit
15bff7f517
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#0F1E3D" />
|
<meta name="theme-color" content="#0F1E3D" />
|
||||||
<title>PilotVault · API Server</title>
|
<title>PilotVault · API Server</title>
|
||||||
<script type="module" crossorigin src="/assets/index-B8Dqe_UO.js"></script>
|
<script type="module" crossorigin src="/assets/index-Dh-j3HLZ.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CVh8EDzq.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-4CKxSIBg.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -536,11 +536,17 @@ func (s *Server) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
"status": "down", "detail": "OpenSky is disabled for your organization"}})
|
"status": "down", "detail": "OpenSky is disabled for your organization"}})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A ?bbox= override lets the Settings "Test connection" probe a specific area
|
||||||
|
// (to check its credit cost) without changing the saved default.
|
||||||
|
bbox := res.eff.Bbox
|
||||||
|
if q := strings.TrimSpace(r.URL.Query().Get("bbox")); q != "" && validBBox(q) {
|
||||||
|
bbox = q
|
||||||
|
}
|
||||||
cfg := map[string]string{
|
cfg := map[string]string{
|
||||||
"clientId": res.eff.ClientID,
|
"clientId": res.eff.ClientID,
|
||||||
"clientSecret": res.eff.ClientSecret,
|
"clientSecret": res.eff.ClientSecret,
|
||||||
"plan": res.eff.Plan,
|
"plan": res.eff.Plan,
|
||||||
"bbox": res.eff.Bbox,
|
"bbox": bbox,
|
||||||
"allowAnonymous": boolStr(res.allowAnon),
|
"allowAnonymous": boolStr(res.allowAnon),
|
||||||
}
|
}
|
||||||
h, err := s.plugins.HealthCheckWith(r.Context(), openSkyPlugin, cfg)
|
h, err := s.plugins.HealthCheckWith(r.Context(), openSkyPlugin, cfg)
|
||||||
|
|||||||
@@ -93,9 +93,41 @@ func (s *Server) handleDeletePlugin(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/admin/plugins/{name}/health — run a health check now.
|
// POST /api/admin/plugins/{name}/health — run a health check now. An optional
|
||||||
|
// ?bbox= overrides the plugin's stored bounding box for this probe only (used by
|
||||||
|
// the panel to test-probe OpenSky over a smaller, cheaper area without changing
|
||||||
|
// the saved default). Ignored by plugins that don't use a bbox.
|
||||||
func (s *Server) handlePluginHealth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePluginHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
h, err := s.plugins.HealthCheck(r.Context(), r.PathValue("name"))
|
name := r.PathValue("name")
|
||||||
|
|
||||||
|
if bbox := strings.TrimSpace(r.URL.Query().Get("bbox")); bbox != "" {
|
||||||
|
if !validBBox(bbox) {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid bbox")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, _, ok := s.plugins.RawConfig(name)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = map[string]string{}
|
||||||
|
}
|
||||||
|
cfg["bbox"] = bbox
|
||||||
|
h, err := s.plugins.HealthCheckWith(r.Context(), name, cfg)
|
||||||
|
if err != nil {
|
||||||
|
if plugins.IsUnknown(err) {
|
||||||
|
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"health": h})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := s.plugins.HealthCheck(r.Context(), name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if plugins.IsUnknown(err) {
|
if plugins.IsUnknown(err) {
|
||||||
writeError(w, http.StatusNotFound, "unknown plugin")
|
writeError(w, http.StatusNotFound, "unknown plugin")
|
||||||
|
|||||||
@@ -217,6 +217,41 @@ function setBboxSelect(key, v) {
|
|||||||
bboxCustom.value = { ...bboxCustom.value, [key]: false };
|
bboxCustom.value = { ...bboxCustom.value, [key]: false };
|
||||||
editConfig.value[key] = v;
|
editConfig.value[key] = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Probe bounding box (Check-only override) ----------------------------
|
||||||
|
// A plugin can be test-probed over a specific area without touching its saved
|
||||||
|
// config, so OpenSky's credit cost (which scales with box size) can be checked
|
||||||
|
// cheaply. State is per plugin name; empty value = "saved default" (no override).
|
||||||
|
const probeBbox = ref({}); // name -> bbox string ("" = saved default)
|
||||||
|
const probeBboxCustom = ref({}); // name -> in "Custom…" mode
|
||||||
|
function pluginHasBbox(p) {
|
||||||
|
return !!(p.configFields && p.configFields.some((f) => f.type === "bbox"));
|
||||||
|
}
|
||||||
|
function probeBboxSelectValue(name) {
|
||||||
|
if (probeBboxCustom.value[name]) return "__custom__";
|
||||||
|
const v = probeBbox.value[name];
|
||||||
|
if (!v) return "__default__";
|
||||||
|
const norm = normBbox(v);
|
||||||
|
const match = norm && BBOX_FLAT.find((o) => normBbox(o.value) === norm);
|
||||||
|
return match ? match.value : "__custom__";
|
||||||
|
}
|
||||||
|
function setProbeBbox(name, v) {
|
||||||
|
if (v === "__default__") {
|
||||||
|
probeBboxCustom.value = { ...probeBboxCustom.value, [name]: false };
|
||||||
|
probeBbox.value = { ...probeBbox.value, [name]: "" };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (v === "__custom__") {
|
||||||
|
probeBboxCustom.value = { ...probeBboxCustom.value, [name]: true };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probeBboxCustom.value = { ...probeBboxCustom.value, [name]: false };
|
||||||
|
probeBbox.value = { ...probeBbox.value, [name]: v };
|
||||||
|
}
|
||||||
|
function setProbeBboxCustom(name, v) {
|
||||||
|
probeBbox.value = { ...probeBbox.value, [name]: v };
|
||||||
|
}
|
||||||
|
|
||||||
const newExt = ref({ name: "", baseURL: "", provider: "" });
|
const newExt = ref({ name: "", baseURL: "", provider: "" });
|
||||||
const newExtErr = ref("");
|
const newExtErr = ref("");
|
||||||
const newExtBusy = ref(false);
|
const newExtBusy = ref(false);
|
||||||
@@ -348,7 +383,10 @@ async function checkPlugin(p) {
|
|||||||
busyPlugin.value = p.name;
|
busyPlugin.value = p.name;
|
||||||
pluginsMsg.value = "";
|
pluginsMsg.value = "";
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}/health`, {
|
// A probe area (if chosen) overrides the saved bbox for this check only.
|
||||||
|
const area = (probeBbox.value[p.name] || "").trim();
|
||||||
|
const qs = area ? `?bbox=${encodeURIComponent(area)}` : "";
|
||||||
|
const r = await fetch(`/api/admin/plugins/${encodeURIComponent(p.name)}/health${qs}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
});
|
});
|
||||||
@@ -1303,6 +1341,27 @@ const deviceApi = [
|
|||||||
{{ p.enabled ? "Enabled" : "Disabled" }}
|
{{ p.enabled ? "Enabled" : "Disabled" }}
|
||||||
</button>
|
</button>
|
||||||
<button v-if="p.configFields && p.configFields.length" class="pv-btn-sec pv-btn-sm" @click="startPluginEdit(p)">Configure</button>
|
<button v-if="p.configFields && p.configFields.length" class="pv-btn-sec pv-btn-sm" @click="startPluginEdit(p)">Configure</button>
|
||||||
|
<template v-if="pluginHasBbox(p)">
|
||||||
|
<select
|
||||||
|
:value="probeBboxSelectValue(p.name)"
|
||||||
|
class="pv-input pv-input-sm max-w-[11rem]"
|
||||||
|
title="Bounding box used for this Check — smaller areas cost fewer OpenSky credits"
|
||||||
|
@change="setProbeBbox(p.name, $event.target.value)"
|
||||||
|
>
|
||||||
|
<option value="__default__">Probe: saved default</option>
|
||||||
|
<optgroup v-for="g in BBOX_GROUPS" :key="g.label" :label="g.label">
|
||||||
|
<option v-for="o in g.options" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||||
|
</optgroup>
|
||||||
|
<option value="__custom__">Custom…</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-if="probeBboxSelectValue(p.name) === '__custom__'"
|
||||||
|
:value="probeBbox[p.name] || ''"
|
||||||
|
class="pv-input pv-input-sm w-40 font-mono"
|
||||||
|
placeholder="lamin,lomin,lamax,lomax"
|
||||||
|
@input="setProbeBboxCustom(p.name, $event.target.value)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<button class="pv-btn-sec pv-btn-sm" :disabled="busyPlugin === p.name" @click="checkPlugin(p)">Check</button>
|
<button class="pv-btn-sec pv-btn-sm" :disabled="busyPlugin === p.name" @click="checkPlugin(p)">Check</button>
|
||||||
<button v-if="p.kind === 'external'" class="pv-btn-sec pv-btn-sm !text-danger" :disabled="busyPlugin === p.name" @click="removePlugin(p)">Remove</button>
|
<button v-if="p.kind === 'external'" class="pv-btn-sec pv-btn-sm !text-danger" :disabled="busyPlugin === p.name" @click="removePlugin(p)">Remove</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -210,6 +210,15 @@ h1, h2, h3 {
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Compact input to sit inline with pv-btn-sm (auto width, not full-width) */
|
||||||
|
@utility pv-input-sm {
|
||||||
|
width: auto;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
/* Primary button */
|
/* Primary button */
|
||||||
@utility pv-btn {
|
@utility pv-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -229,7 +229,12 @@ func (a *App) handlePutOpenSky(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// POST /bff/integrations/opensky/health → API Server /api/integrations/opensky/health
|
// POST /bff/integrations/opensky/health → API Server /api/integrations/opensky/health
|
||||||
func (a *App) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handleOpenSkyHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
req, _ := http.NewRequest(http.MethodPost, a.apiBaseFor(r)+"/api/integrations/opensky/health", nil)
|
target := a.apiBaseFor(r) + "/api/integrations/opensky/health"
|
||||||
|
// Forward the optional ?bbox= probe-area override from Test connection.
|
||||||
|
if r.URL.RawQuery != "" {
|
||||||
|
target += "?" + r.URL.RawQuery
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, target, nil)
|
||||||
req.Header.Set("Authorization", tokenOf(r))
|
req.Header.Set("Authorization", tokenOf(r))
|
||||||
a.doRelay(w, req)
|
a.doRelay(w, req)
|
||||||
}
|
}
|
||||||
|
|||||||
-20
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+2
-2
@@ -35,8 +35,8 @@
|
|||||||
})()
|
})()
|
||||||
</script>
|
</script>
|
||||||
<title>PilotVault — Control Panel</title>
|
<title>PilotVault — Control Panel</title>
|
||||||
<script type="module" crossorigin src="./assets/index-BSx6Lf8i.js"></script>
|
<script type="module" crossorigin src="./assets/index-BfiNicZk.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-FLz21UuE.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-CcgvhCJr.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -160,9 +160,12 @@ export async function saveOpenSky(payload) {
|
|||||||
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
|
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run a live health probe against the caller's resolved config.
|
// Run a live health probe against the caller's resolved config. An optional
|
||||||
export async function testOpenSky() {
|
// `bbox` ("lamin,lomin,lamax,lomax") overrides the probe area for this test only
|
||||||
const r = await fetch('/bff/integrations/opensky/health', { method: 'POST' })
|
// (to check an area's credit cost without changing the saved default).
|
||||||
|
export async function testOpenSky(bbox) {
|
||||||
|
const qs = bbox ? `?bbox=${encodeURIComponent(bbox)}` : ''
|
||||||
|
const r = await fetch(`/bff/integrations/opensky/health${qs}`, { method: 'POST' })
|
||||||
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
|
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Segmented from './settings/Segmented.vue'
|
|||||||
import Row from './settings/Row.vue'
|
import Row from './settings/Row.vue'
|
||||||
import { themeMode, setThemeMode } from '../theme.js'
|
import { themeMode, setThemeMode } from '../theme.js'
|
||||||
import { prefs, formatDateTime, importPrefs, applyFontSize, applyReduceMotion } from '../prefs.js'
|
import { prefs, formatDateTime, importPrefs, applyFontSize, applyReduceMotion } from '../prefs.js'
|
||||||
import { europeanCountries, regionOptions } from '../countries.js'
|
import { europeanCountries, regionOptions, countryGroups } from '../countries.js'
|
||||||
import {
|
import {
|
||||||
getUsers, createUser, updateUser, deleteUser,
|
getUsers, createUser, updateUser, deleteUser,
|
||||||
getOrgs, createOrg, updateOrg, deleteOrg,
|
getOrgs, createOrg, updateOrg, deleteOrg,
|
||||||
@@ -371,10 +371,45 @@ async function saveOpenSkySettings() {
|
|||||||
flash(osEditingOrg.value ? 'Organization OpenSky settings saved.' : 'OpenSky settings saved.')
|
flash(osEditingOrg.value ? 'Organization OpenSky settings saved.' : 'OpenSky settings saved.')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test-connection "probe area": an optional bounding box used only for the
|
||||||
|
// Test connection probe (to check an area's credit cost) without touching the
|
||||||
|
// saved default. Full country list (World / Continents / all countries) + Custom.
|
||||||
|
const OS_PROBE_GROUPS = [
|
||||||
|
{ label: 'World', options: [{ value: '-90,-180,90,180', label: 'World' }] },
|
||||||
|
{ label: 'Continents', options: [
|
||||||
|
{ value: '34,-25,72,45', label: 'Europe' },
|
||||||
|
{ value: '-35,-18,38,52', label: 'Africa' },
|
||||||
|
{ value: '5,25,82,180', label: 'Asia' },
|
||||||
|
{ value: '7,-168,72,-52', label: 'North America' },
|
||||||
|
{ value: '-56,-82,13,-34', label: 'South America' },
|
||||||
|
{ value: '-48,110,-10,180', label: 'Oceania' },
|
||||||
|
] },
|
||||||
|
...countryGroups(),
|
||||||
|
]
|
||||||
|
const OS_PROBE_FLAT = OS_PROBE_GROUPS.flatMap((g) => g.options)
|
||||||
|
const osProbeBbox = ref('') // '' = use the saved default bounding box
|
||||||
|
const osProbeCustom = ref(false)
|
||||||
|
const osProbeSelect = computed({
|
||||||
|
get() {
|
||||||
|
if (osProbeCustom.value) return '__custom__'
|
||||||
|
if (!osProbeBbox.value) return '__default__'
|
||||||
|
const norm = osNormBbox(osProbeBbox.value)
|
||||||
|
const m = norm && OS_PROBE_FLAT.find((o) => osNormBbox(o.value) === norm)
|
||||||
|
return m ? m.value : '__custom__'
|
||||||
|
},
|
||||||
|
set(v) {
|
||||||
|
if (v === '__default__') { osProbeCustom.value = false; osProbeBbox.value = ''; return }
|
||||||
|
if (v === '__custom__') { osProbeCustom.value = true; return }
|
||||||
|
osProbeCustom.value = false
|
||||||
|
osProbeBbox.value = v
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const osProbeManual = computed(() => osProbeSelect.value === '__custom__')
|
||||||
|
|
||||||
async function testOpenSkyConnection() {
|
async function testOpenSkyConnection() {
|
||||||
osTesting.value = true
|
osTesting.value = true
|
||||||
osHealth.value = null
|
osHealth.value = null
|
||||||
const { ok, body } = await testOpenSky()
|
const { ok, body } = await testOpenSky((osProbeBbox.value || '').trim() || undefined)
|
||||||
osTesting.value = false
|
osTesting.value = false
|
||||||
osHealth.value = ok && body.health ? body.health : { status: 'down', detail: body.error || 'Probe failed.' }
|
osHealth.value = ok && body.health ? body.health : { status: 'down', detail: body.error || 'Probe failed.' }
|
||||||
osHealthTs.value = Date.now()
|
osHealthTs.value = Date.now()
|
||||||
@@ -1562,6 +1597,17 @@ onBeforeUnmount(() => {
|
|||||||
<button v-if="!osReadOnly" class="btn-accent" :disabled="osSaving || !os.available" @click="saveOpenSkySettings">
|
<button v-if="!osReadOnly" class="btn-accent" :disabled="osSaving || !os.available" @click="saveOpenSkySettings">
|
||||||
{{ osSaving ? 'Saving…' : osEditingOrg ? 'Save organization settings' : 'Save settings' }}
|
{{ osSaving ? 'Saving…' : osEditingOrg ? 'Save organization settings' : 'Save settings' }}
|
||||||
</button>
|
</button>
|
||||||
|
<div v-if="!osEditingOrg" class="flex items-center gap-2" title="Bounding box used for Test connection — smaller areas cost fewer OpenSky credits">
|
||||||
|
<label class="text-xs text-ink-muted">Test area</label>
|
||||||
|
<select v-model="osProbeSelect" class="field w-44">
|
||||||
|
<option value="__default__">Default bounding box</option>
|
||||||
|
<optgroup v-for="g in OS_PROBE_GROUPS" :key="g.label" :label="g.label">
|
||||||
|
<option v-for="o in g.options" :key="o.value" :value="o.value">{{ o.label }}</option>
|
||||||
|
</optgroup>
|
||||||
|
<option value="__custom__">Custom…</option>
|
||||||
|
</select>
|
||||||
|
<input v-if="osProbeManual" v-model="osProbeBbox" class="field w-44 font-mono" placeholder="lamin,lomin,lamax,lomax" />
|
||||||
|
</div>
|
||||||
<button v-if="!osEditingOrg" class="btn-ghost" :disabled="osTesting || !os.available" @click="testOpenSkyConnection">
|
<button v-if="!osEditingOrg" class="btn-ghost" :disabled="osTesting || !os.available" @click="testOpenSkyConnection">
|
||||||
{{ osTesting ? 'Testing…' : 'Test connection' }}
|
{{ osTesting ? 'Testing…' : 'Test connection' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -255,3 +255,23 @@ export function regionOptions() {
|
|||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
.map((c) => [c.code, c.name])
|
.map((c) => [c.code, c.name])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-continent picker groups ({ label, options:[{value:bbox, label:name}] }),
|
||||||
|
// countries A→Z within each. Used by the "Test area" probe picker.
|
||||||
|
const CONTINENT_LABELS = [
|
||||||
|
['EU', 'European countries'],
|
||||||
|
['AS', 'Asian countries'],
|
||||||
|
['AF', 'African countries'],
|
||||||
|
['NA', 'North American countries'],
|
||||||
|
['SA', 'South American countries'],
|
||||||
|
['OC', 'Oceanian countries'],
|
||||||
|
]
|
||||||
|
export function countryGroups() {
|
||||||
|
return CONTINENT_LABELS.map(([code, label]) => ({
|
||||||
|
label,
|
||||||
|
options: COUNTRIES.filter((c) => c.continent === code)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((c) => ({ value: c.bbox, label: c.name })),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user