diff --git a/features/zones/Create.feature b/features/zones/Create.feature index 67ffed640..1470bcda1 100644 --- a/features/zones/Create.feature +++ b/features/zones/Create.feature @@ -16,7 +16,8 @@ Feature: zones / create | environment-kubernetes-config | [data-testid='zone-kubernetes-config'] | | ingress-input-switch | [for='zone-ingress-enabled'] | | egress-input-switch | [for='zone-egress-enabled'] | - | zone-connected-scanner | [data-testid='zone-connected-scanner'] | + | waiting | [data-testid='waiting'] | + | connected | [data-testid='connected'] | | error | [data-testid='create-zone-error'] | | instructions | [data-testid='connect-zone-instructions'] | And the environment @@ -68,57 +69,59 @@ Feature: zones / create """ KUMA_SUBSCRIPTION_COUNT: 0 """ + And the URL "/provision-zone" responds with + """ + body: + token: spat_595QOxTSreRmrtdh8ValuoeUAzXMfBmRwYU3V35NQvwgLAWIU + """ When I visit the "/zones/-create" URL Then the "$create-zone-button" element is disabled When I "type" "test" into the "$name-input" element Then the "$create-zone-button" element isn't disabled - When the URL "/provision-zone" responds with - """ - body: - token: spat_595QOxTSreRmrtdh8ValuoeUAzXMfBmRwYU3V35NQvwgLAWIU - """ - And I click the "$create-zone-button" element + When I click the "$create-zone-button" element Then the URL "/provision-zone" was requested with """ method: POST body: name: test """ - Then the "$environment-universal-radio-button" element isn't checked - Then the "$environment-kubernetes-radio-button" element is checked - Then the "$ingress-input-switch input" element is checked - Then the "$egress-input-switch input" element is checked - Then the "$environment-kubernetes-config" element contains "kdsGlobalAddress: grpcs://:5685" - Then the "$zone-connected-scanner[data-test-state='waiting']" element exists + And the "$environment-universal-radio-button" element isn't checked + And the "$environment-kubernetes-radio-button" element is checked + And the "$ingress-input-switch input" element is checked + And the "$egress-input-switch input" element is checked + And the "$environment-kubernetes-config" element contains "kdsGlobalAddress: grpcs://:5685" + And the "$waiting" element exists When I click the "$ingress-input-switch" element Then the "$ingress-input-switch input" element isn't checked - Then the "$egress-input-switch input" element is checked + And the "$egress-input-switch input" element is checked When I click the "$egress-input-switch" element Then the "$ingress-input-switch input" element isn't checked - Then the "$egress-input-switch input" element isn't checked + And the "$egress-input-switch input" element isn't checked When I click the "$environment-universal-radio-button + label" element Then the "$ingress-input-switch input" element doesn't exist - Then the "$egress-input-switch input" element doesn't exist - Then the "$environment-universal-config" element contains "globalAddress: grpcs://:5685" + And the "$egress-input-switch input" element doesn't exist + And the "$environment-universal-config" element contains "globalAddress: grpcs://:5685" Given the environment """ KUMA_SUBSCRIPTION_COUNT: 1 """ - When the URL "/zones/test/_overview" responds with + And the URL "/zones/test/_overview" responds with """ body: + zone: + enabled: true zoneInsight: subscriptions: - connectTime: '2020-07-28T16:18:09.743141Z' disconnectTime: !!js/undefined """ - Then the "$zone-connected-scanner[data-test-state='success']" element exists + Then the "$connected" element exists Scenario: The form shows expected error for 409 response Given the URL "/provision-zone" responds with @@ -186,6 +189,8 @@ Feature: zones / create And the URL "/zones/test/_overview" responds with """ body: + zone: + enabled: true zoneInsight: subscriptions: - connectTime: '2020-07-28T16:18:09.743141Z' @@ -197,7 +202,7 @@ Feature: zones / create And I click the "$create-zone-button" element Then the "$instructions" element exists - And the "$zone-connected-scanner[data-test-state='success']" element exists + And the "$connected" element exists When I click the "$exit-button" element @@ -220,7 +225,7 @@ Feature: zones / create And I click the "$create-zone-button" element Then the "$instructions" element exists - And the "$zone-connected-scanner[data-test-state='waiting']" element exists + And the "$waiting" element exists When I click the "$exit-button" element diff --git a/src/app/application/index.ts b/src/app/application/index.ts index 419d30990..efcf0c3fc 100644 --- a/src/app/application/index.ts +++ b/src/app/application/index.ts @@ -7,14 +7,17 @@ import RouteView from './components/route-view/RouteView.vue' import { routes } from './routes' import can from './services/can' import I18n from './services/i18n/I18n' +import DataSourceLifeCycle, { getSource } from '@/app/application/services/data-source' +import type { Source } from '@/app/application/services/data-source' import { DataSourcePool } from '@/app/application/services/data-source/DataSourcePool' -import DataSourceLifeCycle from '@/app/application/services/data-source/index' import type { EnvVars } from '@/services/env/Env' import Env from '@/services/env/Env' import type { ServiceDefinition } from '@/services/utils' -import { token, createInjections } from '@/services/utils' +import { token, createInjections, constant } from '@/services/utils' import type { Component } from 'vue' +export type { DataSourceResponse, Source } from './services/data-source' + type Can = ReturnType type Token = ReturnType @@ -39,6 +42,7 @@ const $ = { notFoundView: token<() => Promise>('application.not-found'), applicationComponents: token('application.components'), + source: token('data.source'), sources: token('data.sources'), dataSourcePool: token('data.DataSourcePool'), dataSourceLifecycle: token('data.DataSourceLifecycle'), @@ -114,6 +118,11 @@ export const services = (app: Record): ServiceDefinition[] => { constant: DataSourceLifeCycle, }], + [$.source, { + service: getSource, + arguments: [constant(document, { description: 'dom.document' })], + }], + [$.getDataSourceCacheKeyPrefix, { service: () => () => '', arguments: [ diff --git a/src/app/application/services/data-source/CallableEventSource.ts b/src/app/application/services/data-source/CallableEventSource.ts index c103cff1e..dd683d9bf 100644 --- a/src/app/application/services/data-source/CallableEventSource.ts +++ b/src/app/application/services/data-source/CallableEventSource.ts @@ -4,7 +4,8 @@ const CLOSED = 2 export const isClosed = (source: { readyState: number }) => source.readyState === CLOSED // CallableEventSource turns a Promise returning function into an EventTarget, // making it act like a standard EventSource. -export default class CallableEventSource extends EventTarget { + +export default class CallableEventSource extends EventTarget { url = '' withCredentials = false readonly CONNECTING = CONNECTING @@ -18,7 +19,7 @@ export default class CallableEventSource extends EventTarget { constructor( protected source: () => AsyncGenerator, - _configuration = {}, + public configuration: T, ) { super() this._open() @@ -42,6 +43,7 @@ export default class CallableEventSource extends EventTarget { // temporarily commented until we can avoid console.errors being // reported in environments where we don't want to see them // console.error(e) + self.close() self.dispatchEvent(new ErrorEvent('error', { error: e, })) diff --git a/src/app/application/services/data-source/index.ts b/src/app/application/services/data-source/index.ts index a346917c3..98c3759a9 100644 --- a/src/app/application/services/data-source/index.ts +++ b/src/app/application/services/data-source/index.ts @@ -1,36 +1,97 @@ -import CallableEventSource, { isClosed } from './CallableEventSource' +import CallableEventSource from './CallableEventSource' import type { Creator, Destroyer } from './DataSourcePool' +export type { DataSourceResponse } from './DataSourcePool' + +type Configuration = { + interval?: number + retry?: (e: unknown) => Promise | undefined +} +type RetryingEventSource = CallableEventSource +type Hideable = EventTarget & { hidden: boolean } + +export const getSource = (doc: Hideable) => { + return (cb: (source: RetryingEventSource) => Promise, config: Configuration = {}) => { + let attempts = 0 + let iterations = 0 + return new CallableEventSource(async function * (this: RetryingEventSource) { + const self = this + while (true) { + self.readyState = 1 + // this this isn't the first call then we should wait before calling again + if (iterations > 0) { + await new Promise((resolve) => setTimeout(resolve, self.configuration.interval ?? 1000)) + } + if (attempts > 0 || iterations > 0) { + // if the document/browser tab is hidden then wait for it to regain + // focus but, for the first call (if we aren't erroring) we probably + // still want to send of the request, so then at least when you come + // back you immediately see data + if (doc.hidden) { + await new Promise((resolve) => { + doc.addEventListener('visibilitychange', resolve, { once: true }) + }) + } + } + let res + try { + res = cb(self) + // if we aren't polling then immediately close after calling + if (typeof self.configuration.interval === 'undefined') { + self.close() + } + // only increase iterations if we didn't error + iterations++ + // return the result + yield res + } catch (e) { + // if retry is configured await it before entering the loop again to + // try again + // TODO(jc): we should probably pass through attempts and maybe other + // things here + const retry = self.configuration?.retry?.(e) + if (typeof retry?.then === 'function') { + // make sure we never mistakenly retry sooner than 1s + await Promise.all([retry, new Promise(resolve => setTimeout(resolve, 1000))]) + attempts++ + } else { + throw e + } + } + } + }, config) + } +} +export type Source = ReturnType + +// its fine to not wait for an unfocussed tab for promise returning sources +const source = getSource(new (class extends EventTarget {hidden = false})()) export const create: Creator = (src, router) => { const [path, query] = src.split('?') const queryParams = new URLSearchParams(query) // use the router to find which function to call const route = router.match(path) - const _source = new CallableEventSource(async function * (this: CallableEventSource) { - while (true) { - this.readyState = 1 - // `.route` here is the function call to the 'source' i.e. the Promise - // returning call that can be polled, in our case right now the HTTP - // calls but in the future could also be 'listeners' on localStorage, or - // 'listeners' on a session - yield route.route({ - ...{ - offset: parseInt(queryParams.get('offset') || '0'), - size: parseInt(queryParams.get('size') || '0'), - page: parseInt(queryParams.get('page') || '0'), - search: queryParams.get('search') || '', - }, - ...route.params, - }, this) - if (!isClosed(this)) { - // right now any polling has a 5s interval, we currently just hardcode - // here but if/when we use this more this should be a user setting - // that we can save/retrieve from localStorage - await new Promise(resolve => setTimeout(resolve, 5000)) - } + const params = { + ...{ + offset: parseInt(queryParams.get('offset') || '0'), + size: parseInt(queryParams.get('size') || '0'), + page: parseInt(queryParams.get('page') || '0'), + search: queryParams.get('search') || '', + }, + ...route.params, + } + try { + // TODO(jc) Once we remove all the source.closes in the sources.ts files the + // second argument here can go + const init = route.route(params, { close: () => {} }) + if (init instanceof CallableEventSource) { + return init + } else { + return source(() => Promise.resolve(init)) } - }) - return _source + } catch (e) { + return source(() => Promise.reject(e)) + } } export const destroy: Destroyer = (_src, source) => { if (source) { diff --git a/src/app/zones/components/EntityScanner.vue b/src/app/zones/components/EntityScanner.vue deleted file mode 100644 index cf2afc2bb..000000000 --- a/src/app/zones/components/EntityScanner.vue +++ /dev/null @@ -1,140 +0,0 @@ - - - diff --git a/src/app/zones/data/index.ts b/src/app/zones/data/index.ts index 98fecde9c..c4074f445 100644 --- a/src/app/zones/data/index.ts +++ b/src/app/zones/data/index.ts @@ -1,6 +1,16 @@ -import type { ZoneOverview, StatusKeyword } from '@/types/index.d' +import type { ZoneOverview } from '@/types/index.d' import { get } from '@/utilities/get' +// TODO(jc): These two are extremely similar, see if we can merge +export function getZoneControlPlaneEnvironment(zoneOverview: ZoneOverview): string { + // TODO(jc): is it ok to use the config from the first subscription we find here? + for (const subscription of zoneOverview.zoneInsight?.subscriptions ?? []) { + if (subscription.config) { + return JSON.parse(subscription.config).environment + } + } + return '' +} export function getZoneDpServerAuthType(zone: ZoneOverview): string { const subscriptions = zone.zoneInsight?.subscriptions ?? [] if (subscriptions.length > 0) { @@ -12,25 +22,15 @@ export function getZoneDpServerAuthType(zone: ZoneOverview): string { } return '' } +// TODO(jc): end -export function getZoneControlPlaneStatus(zoneOverview: ZoneOverview): StatusKeyword | 'disabled' { +export function getZoneControlPlaneStatus(zoneOverview: ZoneOverview): 'online' | 'offline' | 'disabled' { if (zoneOverview.zone.enabled === false) { return 'disabled' } - - const subscriptions = zoneOverview.zoneInsight?.subscriptions ?? [] - if (subscriptions.length === 0) { - return 'offline' - } - - const lastSubscription = subscriptions[subscriptions.length - 1] - return lastSubscription.connectTime?.length && !lastSubscription.disconnectTime ? 'online' : 'offline' + return getStatus(zoneOverview.zoneInsight?.subscriptions) } -export function getZoneControlPlaneEnvironment(zoneOverview: ZoneOverview): string { - for (const subscription of zoneOverview.zoneInsight?.subscriptions ?? []) { - if (subscription.config) { - return JSON.parse(subscription.config).environment - } - } - return '' +export function getStatus(subscriptions: {connectTime?: string, disconnectTime?: string}[] | undefined = []): 'online' | 'offline' { + const proxyOnline = subscriptions.length > 0 && [subscriptions[subscriptions.length - 1]].every((item) => item.connectTime?.length && !item.disconnectTime) + return proxyOnline ? 'online' : 'offline' } diff --git a/src/app/zones/index.ts b/src/app/zones/index.ts index d239ec2d4..ba3878d62 100644 --- a/src/app/zones/index.ts +++ b/src/app/zones/index.ts @@ -23,6 +23,7 @@ export const services = (app: Record): ServiceDefinition[] => { [token('zone.sources'), { service: sources, arguments: [ + app.source, app.api, ], labels: [ diff --git a/src/app/zones/sources.ts b/src/app/zones/sources.ts index 0a51fb33a..8d44199ea 100644 --- a/src/app/zones/sources.ts +++ b/src/app/zones/sources.ts @@ -1,6 +1,8 @@ -import { DataSourceResponse } from '@/app/application/services/data-source/DataSourcePool' +import { getZoneControlPlaneStatus } from './data' +import type { DataSourceResponse, Source } from '@/app/application' import { sources as zoneEgresses } from '@/app/zone-egresses/sources' import { sources as zoneIngresses } from '@/app/zone-ingresses/sources' +import { ApiError } from '@/services/kuma-api/ApiError' import type KumaApi from '@/services/kuma-api/KumaApi' import type { PaginatedApiListResponse as CollectionResponse } from '@/types/api.d' import type { ZoneOverview } from '@/types/index.d' @@ -14,34 +16,55 @@ type DetailParams = { name: string } -type Closeable = { close: () => void } - export type ZoneOverviewCollection = CollectionResponse export type ZoneOverviewSource = DataSourceResponse export type ZoneOverviewCollectionSource = DataSourceResponse export type EnvoyDataSource = DataSourceResponse -export const sources = (api: KumaApi) => { +export const sources = (source: Source, api: KumaApi) => { return { ...zoneIngresses(api), ...zoneEgresses(api), - '/zone-cps': async (params: PaginationParams, source: Closeable) => { - source.close() - + '/zone-cps': async (params: PaginationParams) => { const { size } = params - const offset = params.size * (params.page - 1) - - return await api.getAllZoneOverviews({ size, offset }) + const offset = size * (params.page - 1) + return api.getAllZoneOverviews({ size, offset }) }, - '/zone-cps/:name': async (params: DetailParams, source: Closeable) => { - source.close() - + '/zone-cps/:name': async (params: DetailParams) => { const { name } = params - - return await api.getZoneOverview({ name }) + return api.getZoneOverview({ name }) + }, + '/zone-cps/online/:name': (params: DetailParams) => { + // this source retries until we have a 200 and the zone found is online + // any non-404 errors will error as usual + const { name } = params + return source(async () => { + const res = await api.getZoneOverview({ name }) + if (getZoneControlPlaneStatus(res) === 'online') { + return res + } else { + const e = new ApiError({ + status: 404, + title: `The ${res.name} Zone is offline`, + }) + throw e + } + }, { + retry: (e) => { + const hasStatus = (e: unknown): e is T => typeof (e as T).status !== 'undefined' + if (hasStatus(e)) { + const status = e.status.toString() + switch (true) { + case status === '404': + // wait 2 seconds and try again + return new Promise((resolve) => setTimeout(resolve, 2000)) + } + } + }, + }) }, } diff --git a/src/app/zones/views/CreateView.vue b/src/app/zones/views/CreateView.vue index 3c1255793..906a1ca8e 100644 --- a/src/app/zones/views/CreateView.vue +++ b/src/app/zones/views/CreateView.vue @@ -1,5 +1,6 @@ @@ -349,7 +374,7 @@ :title="t('zones.form.confirm_modal.title')" data-testid="confirm-exit-modal" @canceled="toggleConfirmModal" - @proceed="router.push({ name: 'zone-cp-list-view' })" + @proceed="route.replace({ name: 'zone-cp-list-view' })" >