Add OpenWeather plugin with full Web App settings cascade

Builtin API Server connector for the OpenWeather API (current weather +
5-day forecast for a point, API-key auth, stdlib-only), surfaced to end
users through the same three-layer settings cascade as OpenSky/WebDAV.

- New builtin plugin (internal/plugins/builtin/openweather) with
  health probe, weather.current/forecast.5day capabilities, and tests.
- resolveOpenWeather cascade (global -> org -> user) with per-field
  independent resolution and API-key masking; GET/PUT/health endpoints.
- Web App BFF relays, api.js client, and a Settings card under
  APIs - External (scope switch, enable toggles, test connection).
- Resolver unit tests + rebuilt embedded frontend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81andClaude Opus 4.8 committed 2026-07-14 11:21:48 +02:00
1 parent 816db9b42f
commit 6231b43076
14 files changed
+1379 -25

No files matched your search

+29
View File
@@ -281,6 +281,35 @@ export async function testWebDav() {
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
/* ---------- Plugin integrations: OpenWeather ---------- */
// Resolved OpenWeather settings for the current user (cascade + masked API key).
export async function getOpenWeather() {
try {
const r = await fetch('/bff/integrations/openweather')
if (!r.ok) return { ok: false, status: r.status, body: await r.json().catch(() => ({})) }
return { ok: true, status: 200, body: await r.json() }
} catch {
return { ok: false, status: 0, body: {} }
}
}
// Save the caller's editable layer. payload: { scope?, enabled?, config? }.
export async function saveOpenWeather(payload) {
const r = await fetch('/bff/integrations/openweather', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
// Run a live current-weather probe against the caller's resolved config.
export async function testOpenWeather() {
const r = await fetch('/bff/integrations/openweather/health', { method: 'POST' })
return { ok: r.ok, status: r.status, body: await r.json().catch(() => ({})) }
}
/* ---------- Logbook: drones ---------- */
export async function getDrones() {
+272 -2
View File
@@ -14,6 +14,7 @@ import {
getFileTransfer, saveFileTransfer, testFileTransfer,
getLocalStorage, saveLocalStorage, testLocalStorage,
getWebDav, saveWebDav, testWebDav,
getOpenWeather, saveOpenWeather, testOpenWeather,
} from '../api.js'
const props = defineProps({
@@ -50,7 +51,7 @@ const SECTIONS = computed(() => {
const list = [
{ id: 'account', label: 'Account', icon: 'user', kw: 'name username email password verification login credentials role' },
{ id: 'appearance', label: 'Appearance', icon: 'sliders', kw: 'theme light dark system language region font size accessibility date time format motion' },
{ id: 'integrations', label: 'Integrations', icon: 'radio', kw: 'opensky flights adsb aircraft plugin oauth credentials bounding box plan connection ftp sftp ftps file transfer server host upload download local storage folder drive isolated private read only webdav nextcloud owncloud dav url https' },
{ id: 'integrations', label: 'Integrations', icon: 'radio', kw: 'opensky flights adsb aircraft plugin oauth credentials bounding box plan connection ftp sftp ftps file transfer server host upload download local storage folder drive isolated private read only webdav nextcloud owncloud dav url https openweather weather forecast temperature api key units' },
{ id: 'profile', label: 'Profile', icon: 'image', kw: 'avatar photo display name bio public' },
{ id: 'security', label: 'Privacy & Security', icon: 'shield', kw: 'two factor authentication 2fa sessions devices logout security privacy' },
]
@@ -79,7 +80,7 @@ function sectionMatches(s) {
const ROW_KW = {
account: ['full name', 'username', 'email address verification verify', 'password change current new'],
appearance: ['theme light dark system', 'language', 'region', 'font size accessibility', 'reduce motion', 'date format', 'time format clock'],
integrations: ['opensky live flights', 'enable plugin', 'oauth client id secret', 'plan credits', 'bounding box', 'test connection', 'file transfer ftp sftp ftps', 'server host port username password', 'private key passphrase', 'base path directory', 'local storage folder drive', 'private isolated folder', 'read only access mode', 'webdav nextcloud owncloud dav', 'server url username password tls', 'base path directory folder'],
integrations: ['opensky live flights', 'enable plugin', 'oauth client id secret', 'plan credits', 'bounding box', 'test connection', 'file transfer ftp sftp ftps', 'server host port username password', 'private key passphrase', 'base path directory', 'local storage folder drive', 'private isolated folder', 'read only access mode', 'webdav nextcloud owncloud dav', 'server url username password tls', 'base path directory folder', 'openweather weather forecast', 'api key units metric imperial', 'default latitude longitude language'],
profile: ['profile photo avatar', 'display name', 'bio about', 'show email public'],
security: ['two factor authentication', 'active sessions devices', 'sign out'],
team: ['add user create account', 'members list role admin remove delete', 'organization org assign'],
@@ -716,6 +717,150 @@ function wdHealthClass(status) {
return status === 'ok' ? badgeClass.success : status === 'degraded' ? badgeClass.warning : badgeClass.danger
}
/* ---------- integrations: OpenWeather ---------- */
// Same three-layer cascade as OpenSky (global → org → user); every field resolves
// independently (the API key is a lone secret, so no field group is needed).
const ow = reactive({
loaded: false,
available: false,
orgEnabled: true, // org master switch (admins toggle it; gates the org's users)
enabled: false, // personal opt-in
canEditOrg: false,
isSuperadmin: false,
scopes: {}, // { user: { editableLayer, fields }, org?: {...} }
})
const owScope = ref('user')
// All editable fields; the API key is prefilled with the mask and preserved on save.
const OW_KEYS = ['apiKey', 'units', 'lat', 'lon', 'lang']
const owForm = reactive(Object.fromEntries(OW_KEYS.map((k) => [k, ''])))
const owMsg = ref('')
const owSaving = ref(false)
const owTesting = ref(false)
const owHealth = ref(null) // { status, detail, latencyMs }
const owHealthTs = ref(null)
const OW_HEALTH_KEY = 'pv.openweather.health'
const OW_UNITS_OPTS = [
{ value: 'metric', label: 'Metric (°C)' },
{ value: 'imperial', label: 'Imperial (°F)' },
{ value: 'standard', label: 'Standard (K)' },
]
const owReadOnly = computed(() => ow.isSuperadmin)
const owScopeKey = computed(() => (ow.isSuperadmin ? 'user' : owScope.value))
const owScopeData = computed(() => ow.scopes[owScopeKey.value] || { editableLayer: 'user', fields: {} })
const owEditingOrg = computed(() => owScopeKey.value === 'org')
function owField(k) {
return owScopeData.value.fields[k] || { effective: '', own: '', source: 'unset', locked: false }
}
function owLocked(k) {
return owReadOnly.value || owField(k).locked
}
function owSourceLabel(k) {
const src = owField(k).source
if (src === 'global') return 'Set by administrator'
if (src === 'org') return 'Set by your organization'
return ''
}
function fillOwForm() {
for (const k of OW_KEYS) owForm[k] = owField(k).own || ''
if (!owForm.units) owForm.units = 'metric'
}
function applyOpenWeatherView(body) {
ow.available = !!body.available
ow.orgEnabled = body.orgEnabled !== false // default enabled
ow.enabled = !!body.enabled
ow.canEditOrg = !!body.canEditOrg
ow.isSuperadmin = !!body.isSuperadmin
ow.scopes = body.scopes || {}
if (owScope.value === 'org' && !ow.canEditOrg) owScope.value = 'user'
fillOwForm()
ow.loaded = true
}
watch(owScope, () => { owMsg.value = ''; fillOwForm() })
function owHealthAgo() {
if (!owHealthTs.value) return ''
const s = Math.max(0, Math.round((Date.now() - owHealthTs.value) / 1000))
if (s < 60) return 'just now'
const m = Math.round(s / 60)
if (m < 60) return `${m} min ago`
const h = Math.round(m / 60)
if (h < 24) return `${h} h ago`
return `${Math.round(h / 24)} d ago`
}
function persistOwHealth() {
try {
if (owHealth.value) localStorage.setItem(OW_HEALTH_KEY, JSON.stringify({ health: owHealth.value, ts: owHealthTs.value }))
} catch { /* storage unavailable — non-fatal */ }
}
function restoreOwHealth() {
try {
const raw = localStorage.getItem(OW_HEALTH_KEY)
if (!raw) return
const d = JSON.parse(raw)
if (d && d.health) { owHealth.value = d.health; owHealthTs.value = d.ts || null }
} catch { /* ignore corrupt cache */ }
}
async function loadOpenWeather() {
restoreOwHealth()
const { ok, body } = await getOpenWeather()
if (ok) applyOpenWeatherView(body)
}
async function toggleOpenWeather(v) {
const org = owEditingOrg.value
if (org) ow.orgEnabled = v
else ow.enabled = v
const { ok, body } = await saveOpenWeather(org ? { scope: 'org', enabled: v } : { scope: 'user', enabled: v })
if (ok) {
applyOpenWeatherView(body)
flash(org ? (v ? 'OpenWeather enabled for your organization.' : 'OpenWeather disabled for your organization.')
: (v ? 'OpenWeather enabled.' : 'OpenWeather disabled.'))
} else {
if (org) ow.orgEnabled = !v
else ow.enabled = !v
flash(body.error || 'Could not update.')
}
}
async function saveOpenWeatherSettings() {
owMsg.value = ''
owSaving.value = true
const config = {}
for (const k of OW_KEYS) {
if (!owLocked(k)) config[k] = owForm[k]
}
const payload = { scope: owScopeKey.value, config }
if (!owEditingOrg.value) payload.enabled = ow.enabled
const { ok, body } = await saveOpenWeather(payload)
owSaving.value = false
if (!ok) {
owMsg.value = body.error || 'Could not save settings.'
return
}
applyOpenWeatherView(body)
flash(owEditingOrg.value ? 'Organization OpenWeather settings saved.' : 'OpenWeather settings saved.')
}
async function testOpenWeatherConnection() {
owTesting.value = true
owHealth.value = null
const { ok, body } = await testOpenWeather()
owTesting.value = false
owHealth.value = ok && body.health ? body.health : { status: 'down', detail: body.error || 'Probe failed.' }
owHealthTs.value = Date.now()
persistOwHealth()
}
function owHealthClass(status) {
return status === 'ok' ? badgeClass.success : status === 'degraded' ? badgeClass.warning : badgeClass.danger
}
/* ---------- integrations: Local storage (host filesystem) ---------- */
// Same three-layer cascade as OpenSky (global → org → user), but the folder is not
// editable: it is derived from identity so each org-less user gets a private folder
@@ -1279,6 +1424,7 @@ onMounted(() => {
loadOpenSky()
loadFileTransfer()
loadWebDav()
loadOpenWeather()
loadLocalStorage()
})
onBeforeUnmount(() => {
@@ -1621,6 +1767,130 @@ onBeforeUnmount(() => {
</span>
</div>
</div>
<!-- ---- OpenWeather ---- -->
<div class="panel mb-5 p-5">
<!-- header -->
<div class="mb-4 flex items-start gap-3 border-b border-line pb-4">
<div class="grid h-10 w-10 place-items-center rounded-lg bg-accent-soft text-accent-soft-fg">
<Icon name="sun" :size="20" />
</div>
<div class="min-w-0">
<div class="text-sm font-semibold text-ink">OpenWeather</div>
<div class="mt-0.5 text-xs text-ink-muted">
Current conditions and forecast from the OpenWeather API. Configure the API key and default location your account uses.
</div>
</div>
</div>
<!-- unavailable notice -->
<div v-if="ow.loaded && !ow.available" class="mb-4 rounded-lg border border-line bg-surface-2 px-4 py-3 text-xs text-ink-secondary">
<Icon name="lock" :size="14" class="mr-1 inline" />
OpenWeather is currently disabled by your administrator. Contact them to enable it.
</div>
<!-- scope switch (admins manage personal + organization-wide settings) -->
<div v-if="ow.canEditOrg" class="mb-4 flex flex-col gap-2 border-b border-line pb-4 sm:flex-row sm:items-center sm:justify-between">
<div class="text-xs text-ink-muted">Manage your own settings, or organization-wide settings that apply to every user.</div>
<Segmented v-model="owScope" :options="OS_SCOPE_OPTS" />
</div>
<!-- enable toggle: org master switch (Organization) or personal (My settings) -->
<Row v-if="owEditingOrg" title="Enable OpenWeather (organization-wide)" desc="Turn it on or off for everyone in your organization." keywords="enable disable plugin openweather weather organization">
<Toggle :model-value="ow.orgEnabled" :disabled="!ow.available" @update:model-value="toggleOpenWeather" />
</Row>
<Row v-else title="Enable OpenWeather" desc="Turn the plugin on for your account in the Web App." keywords="enable disable plugin openweather weather">
<Toggle :model-value="ow.enabled" :disabled="!ow.available || !ow.orgEnabled" @update:model-value="toggleOpenWeather" />
</Row>
<!-- organization-disabled notice (personal scope) -->
<div v-if="!owEditingOrg && ow.available && !ow.orgEnabled" class="border-b border-line py-3 text-xs text-amber-fg">
<Icon name="lock" :size="13" class="mr-1 inline" />OpenWeather is turned off for your organization<span v-if="ow.canEditOrg"> — switch to <span class="font-semibold">Organization</span> to turn it back on</span>.
</div>
<!-- scope note -->
<div v-if="owEditingOrg" class="border-b border-line py-3 text-xs text-ink-muted">
<Icon name="users" :size="13" class="mr-1 inline" />These are organization-wide settings — they apply to everyone in
<span class="font-semibold text-ink-secondary">{{ organizationName || 'your organization' }}</span>. Leave the API key blank to let each user configure their own; a key set here overrides the user's.
</div>
<div v-else-if="owReadOnly" class="border-b border-line py-3 text-xs text-ink-muted">
As a superadmin you manage the global OpenWeather configuration in the API Server panel. The effective configuration is shown below.
</div>
<!-- API key -->
<Row title="API key" desc="Your OpenWeather API key (the appid parameter). Required — OpenWeather has no anonymous tier." keywords="api key appid secret credentials token openweather">
<template v-if="owLocked('apiKey')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('apiKey').effective || '—' }}
<span v-if="owSourceLabel('apiKey')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('apiKey') }}</span>
</span>
</template>
<input v-else v-model="owForm.apiKey" type="password" class="field w-64" placeholder="••••••••" />
</Row>
<!-- units -->
<Row title="Units" desc="Measurement system for temperatures and wind speed." keywords="units metric imperial standard celsius fahrenheit kelvin">
<template v-if="owLocked('units')">
<span class="inline-flex items-center gap-2 text-sm text-ink">
{{ (OW_UNITS_OPTS.find((o) => o.value === owField('units').effective) || {}).label || owField('units').effective || '—' }}
<span v-if="owSourceLabel('units')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('units') }}</span>
</span>
</template>
<Segmented v-else v-model="owForm.units" :options="OW_UNITS_OPTS" />
</Row>
<!-- default latitude -->
<Row title="Default latitude" desc="Latitude used by the health probe and calls with no location (−90…90)." keywords="latitude location coordinates default">
<template v-if="owLocked('lat')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('lat').effective || '—' }}
<span v-if="owSourceLabel('lat')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('lat') }}</span>
</span>
</template>
<input v-else v-model="owForm.lat" inputmode="decimal" class="field w-40 font-mono" placeholder="52.2297" />
</Row>
<!-- default longitude -->
<Row title="Default longitude" desc="Longitude used by the health probe and calls with no location (−180…180)." keywords="longitude location coordinates default">
<template v-if="owLocked('lon')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('lon').effective || '—' }}
<span v-if="owSourceLabel('lon')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('lon') }}</span>
</span>
</template>
<input v-else v-model="owForm.lon" inputmode="decimal" class="field w-40 font-mono" placeholder="21.0122" />
</Row>
<!-- language (cascades independently) -->
<Row title="Language" desc="Optional ISO code for human-readable weather descriptions, e.g. en, pl, de." keywords="language locale description">
<template v-if="owLocked('lang')">
<span class="inline-flex items-center gap-2 font-mono text-sm text-ink">
{{ owField('lang').effective || '—' }}
<span v-if="owSourceLabel('lang')" class="inline-flex items-center gap-1 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] font-semibold text-ink-secondary"><Icon name="lock" :size="10" />{{ owSourceLabel('lang') }}</span>
</span>
</template>
<input v-else v-model="owForm.lang" class="field w-24 font-mono" placeholder="en" />
</Row>
<!-- actions -->
<div class="mt-4 flex flex-wrap items-center gap-3">
<button v-if="!owReadOnly" class="btn-accent" :disabled="owSaving || !ow.available" @click="saveOpenWeatherSettings">
{{ owSaving ? 'Saving…' : owEditingOrg ? 'Save organization settings' : 'Save settings' }}
</button>
<button v-if="!owEditingOrg" class="btn-ghost" :disabled="owTesting || !ow.available" @click="testOpenWeatherConnection">
{{ owTesting ? 'Testing…' : 'Test connection' }}
</button>
<span v-if="owMsg" class="text-xs text-danger-fg">{{ owMsg }}</span>
<span v-if="owHealthTs && !owEditingOrg" class="text-[11px] text-ink-muted">Checked {{ owHealthAgo() }}</span>
<span
v-if="owHealth && !owEditingOrg"
class="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[11px] font-semibold"
:class="owHealthClass(owHealth.status)"
>
<span class="h-1.5 w-1.5 rounded-full bg-current"></span>{{ owHealth.detail || owHealth.status }}
</span>
</div>
</div>
</template>
<!-- ==== Drives — External ==== -->