Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions frontend/__tests__/module/k8s/k8s-actions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/* eslint-disable no-undef, no-unused-vars */

import Spy = jasmine.Spy;

import k8sActions, { types } from '../../../public/module/k8s/k8s-actions';
import * as k8sResource from '../../../public/module/k8s/resource';
import { K8sResourceKind } from '../../../public/module/k8s';
import { PodModel } from '../../../public/models';
import { testResourceInstance } from '../../../__mocks__/k8sResourcesMocks';

describe(types.watchK8sList, () => {
const {watchK8sList} = k8sActions;
const id = 'some-redux-id';
let k8sList: Spy;
let websocket: {[method: string]: Spy};
let resourceList: {items: K8sResourceKind[], metadata: {resourceVersion: string, continue?: string}, kind: string, apiVersion: string};

beforeEach(() => {
websocket = {
onclose: jasmine.createSpy('onclose'),
ondestroy: jasmine.createSpy('ondestroy'),
onbulkmessage: jasmine.createSpy('onbulkmessage')
};
websocket.onclose.and.returnValue(websocket);
websocket.ondestroy.and.returnValue(websocket);
websocket.onbulkmessage.and.returnValue(websocket);

resourceList = {
apiVersion: testResourceInstance.apiVersion,
kind: `${testResourceInstance.kind}List`,
items: new Array(300).fill(testResourceInstance),
metadata: {resourceVersion: '10000000'},
};

k8sList = spyOn(k8sResource, 'k8sList').and.returnValue(Promise.resolve({}));
spyOn(k8sResource, 'k8sWatch').and.returnValue(websocket);
});

it('incrementally fetches lists until `continue` token is no longer returned in response', (done) => {
k8sList.and.callFake((k8sKind, params, raw) => {
expect(params.limit).toEqual(250);
if (k8sList.calls.count() > 1) {
expect(params.continue).toEqual('toNextPage');
}

resourceList.metadata.resourceVersion = (parseInt(resourceList.metadata.resourceVersion, 10) + 1).toString();
resourceList.metadata.continue = parseInt(resourceList.metadata.resourceVersion, 10) > 10000005 ? null : 'toNextPage';
return resourceList;
});

const dispatch = jasmine.createSpy('dispatch').and.callFake((action) => {
switch (action.type) {
case types.watchK8sList:
expect(action.id).toEqual(id);
expect(action.query).toEqual({});
break;
case types.bulkAddToList:
expect(action.k8sObjects).toEqual(resourceList.items);
expect(dispatch.calls.allArgs().filter(args => args[0].type === types.bulkAddToList).length).toEqual(k8sList.calls.count());
break;
case types.errored:
fail(action.k8sObjects);
break;
case types.loaded:
expect(k8sList.calls.count()).toEqual(1);
done();
break;
default:
break;
}
});

watchK8sList(id, {}, PodModel)(dispatch);
});

xit('stops incrementally fetching if `stopK8sWatch` action is dispatched', () => {
// TODO(alecmerdler)
});
});
5 changes: 2 additions & 3 deletions frontend/public/components/factory/list-page.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { ErrorPage404 } from '../error';
import { makeReduxID, makeQuery } from '../utils/k8s-watcher';
import { referenceForModel } from '../../module/k8s';


export const CompactExpandButtons = ({expand = false, onExpandChange = _.noop}) => <div className="btn-group btn-group-sm" data-toggle="buttons">
<label className={classNames('btn compaction-btn', expand ? 'btn-default' : 'btn-primary')}>
<input type="radio" onClick={() => onExpandChange(false)} /> Compact
Expand Down Expand Up @@ -236,7 +235,7 @@ FireMan_.propTypes = {

/** @type {React.SFC<{ListComponent: React.ComponentType<any>, kind: string, namespace?: string, filterLabel?: string, title?: string, showTitle?: boolean, dropdownFilters?: any[], rowFilters?: any[], selector?: string, fieldSelector?: string, canCreate?: boolean, fake?: boolean}>} */
export const ListPage = props => {
const {createButtonText, createHandler, filterLabel, kind, namespace, selector, name, fieldSelector, filters, showTitle = true, fake} = props;
const {createButtonText, createHandler, filterLabel, kind, namespace, selector, name, fieldSelector, filters, limit, showTitle = true, fake} = props;
const ko = kindObj(kind);
const {labelPlural, plural, namespaced, label} = ko;
const title = props.title || labelPlural;
Expand All @@ -248,7 +247,7 @@ export const ListPage = props => {
} catch (unused) { /**/ }
}
const createProps = createHandler ? {onClick: createHandler} : {to: href};
const resources = [{ kind, name, namespaced, selector, fieldSelector, filters }];
const resources = [{ kind, name, namespaced, selector, fieldSelector, filters, limit }];

if (!namespaced && namespace) {
return <ErrorPage404 />;
Expand Down
8 changes: 6 additions & 2 deletions frontend/public/components/utils/firehose.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { connect } from 'react-redux';
import { inject } from './index';
import actions from '../../module/k8s/k8s-actions';


export const makeReduxID = (k8sKind = {}, query) => {
let qs = '';
if (!_.isEmpty(query)) {
Expand All @@ -16,7 +15,8 @@ export const makeReduxID = (k8sKind = {}, query) => {
return `${k8sKind.plural}${qs}`;
};

export const makeQuery = (namespace, labelSelector, fieldSelector, name) => {
/** @type {(namespace: string, labelSelector: any, fieldSelector: any, name: string) => {[key: string]: string}} */
export const makeQuery = (namespace, labelSelector, fieldSelector, name, limit) => {
const query = {};

if (!_.isEmpty(labelSelector)) {
Expand All @@ -34,6 +34,10 @@ export const makeQuery = (namespace, labelSelector, fieldSelector, name) => {
if (fieldSelector) {
query.fieldSelector = fieldSelector;
}

if (limit) {
query.limit = limit;
}
return query;
};

Expand Down
73 changes: 42 additions & 31 deletions frontend/public/module/k8s/k8s-actions.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getResources as getResources_} from './get-resources';
import { getResources as getResources_ } from './get-resources';
import store from '../../redux';
import { k8sList, k8sWatch, k8sGet } from './resource';

Expand All @@ -14,20 +14,20 @@ const types = {
errored: 'errored',

watchK8sList: 'watchK8sList',
addToList: 'addToList',
deleteFromList: 'deleteFromList',
modifyList: 'modifyList',
bulkAddToList: 'bulkAddToList',
filterList: 'filterList',
updateListFromWS: 'updateListFromWS',
};

const action_ = (type) => (id, k8sObjects) => ({type, id, k8sObjects});

/** @type {{[id: string]: WebSocket}} */
const WS = {};
const POLLs = {};
const REF_COUNTS = {};

const nop = () => {};
const paginationLimit = 250;

const isImpersonateEnabled = () => !!store.getState().UI.get('impersonate');
const getImpersonateSubprotocols = () => {
Expand Down Expand Up @@ -69,10 +69,8 @@ const getImpersonateSubprotocols = () => {
};

const actions = {
[types.deleteFromList]: action_(types.deleteFromList),
[types.updateListFromWS]: action_(types.updateListFromWS),
[types.addToList]: action_(types.addToList),
[types.modifyList]: action_(types.modifyList),
[types.bulkAddToList]: action_(types.bulkAddToList),
[types.loaded]: action_(types.loaded),
[types.errored]: action_(types.errored),
[types.modifyObject]: action_(types.modifyObject),
Expand Down Expand Up @@ -155,34 +153,47 @@ const actions = {
dispatch({type: types.watchK8sList, id, query});
REF_COUNTS[id] = 1;

// Fetch entire list (XHR) then use its resourceVersion to
// start listening on a WS (?resourceVersion=$resourceVersion)
// start the process over when:
// 1. the WS closes abnormally
// 2. the WS can not establish a connection within $TIMEOUT
/** @type {(continueToken: string) => Promise<string>} */
const incrementallyLoad = async(continueToken = '') => {
// TODO: Check `REF_COUNTS[id]` here and throw special error if undefined
const response = await k8sList(k8skind, {...query, limit: paginationLimit, ...(continueToken ? {continue: continueToken} : {})}, true);

if (!continueToken) {
dispatch(actions.loaded(id, response.items));
}

if (response.metadata.continue) {
dispatch(actions.bulkAddToList(id, response.items));
return incrementallyLoad(response.metadata.continue);
}
return response.metadata.resourceVersion;
};

/**
* Incrementally fetch list (XHR) using k8s pagination then use its resourceVersion to
* start listening on a WS (?resourceVersion=$resourceVersion)
* start the process over when:
* 1. the WS closes abnormally
* 2. the WS can not establish a connection within $TIMEOUT
*/
const pollAndWatch = () => (delete POLLs[id]) && Promise.all([
getImpersonateSubprotocols(),
k8sList(k8skind, query, true).then(res => {
dispatch(actions.loaded(id, res.items));
if (WS[id]) {
return;
}
const { resourceVersion } = res.metadata;
return resourceVersion;
})
incrementallyLoad(),
]).then(([subProtocols, resourceVersion]) => {
// TODO: Check `REF_COUNTS[id]` here and throw special error if undefined
WS[id] = WS[id] || k8sWatch(k8skind, {...query, resourceVersion}, {subProtocols, timeout: 60 * 1000});
WS[id].onclose(event => {
// Close Frame Status Codes: https://tools.ietf.org/html/rfc6455#section-7.4.1
if (event.code !== 1006) {
return;
}
// eslint-disable-next-line no-console
console.log('WS closed abnormally - starting polling loop over!');
const ws = WS[id];
const timedOut = true;
ws && ws.destroy(timedOut);
})
WS[id]
.onclose(event => {
// Close Frame Status Codes: https://tools.ietf.org/html/rfc6455#section-7.4.1
if (event.code !== 1006) {
return;
}
// eslint-disable-next-line no-console
console.log('WS closed abnormally - starting polling loop over!');
const ws = WS[id];
const timedOut = true;
ws && ws.destroy(timedOut);
})
.ondestroy(timedOut => {
if (!timedOut) {
return;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/* eslint-disable no-unused-vars */

import * as _ from 'lodash-es';
import { Map as ImmutableMap, fromJS } from 'immutable';

import {types} from './k8s-actions';
import {getQN} from './k8s';
import { types } from './k8s-actions';
import { getQN } from './k8s';
import { K8sResourceKind } from './index';

const moreRecent = (a, b) => {
const metaA = a.get('metadata').toJSON();
Expand All @@ -20,7 +23,7 @@ const removeFromList = (list, resource) => {
return list.delete(qualifiedName);
};

const updateList = (list, nextJS) => {
const updateList = (list: ImmutableMap<string, any>, nextJS: K8sResourceKind) => {
const qualifiedName = getQN(nextJS);
const current = list.get(qualifiedName);
const next = fromJS(nextJS);
Expand All @@ -35,9 +38,7 @@ const updateList = (list, nextJS) => {

// TODO: (kans) only store the data for things we display ...
// and then only do this comparison for the same stuff!
const currentJS = current.toJSON();
currentJS.metadata.resourceVersion = nextJS.metadata.resourceVersion;
if (_.isEqual(currentJS, nextJS)) {
if (current.deleteIn(['metadata', 'resourceVersion']).equals(next.deleteIn(['metadata', 'resourceVersion']))) {
Copy link
Contributor

@kans kans May 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried this once a while ago, and it didn't work. I must have used Map for my test instead offromJS - the former only works if the nested objects are ===. Nice.

// If the only thing that differs is resource version, don't fire an update.
return list;
}
Expand All @@ -46,7 +47,6 @@ const updateList = (list, nextJS) => {
};

const loadList = (oldList, resources) => {
// TODO: not supported in ie :(
const existingKeys = new Set(oldList.keys());
return oldList.withMutations(list => {
(resources || []).forEach(r => {
Expand Down Expand Up @@ -75,7 +75,7 @@ export default (state, action) => {
return ImmutableMap();
}
const {k8sObjects, id} = action;
const list = state.getIn([id, 'data']);
const list: ImmutableMap<string, any> = state.getIn([id, 'data']);

let newList;

Expand Down Expand Up @@ -164,18 +164,11 @@ export default (state, action) => {
}
}
break;
case types.deleteFromList:
if (!list) {
return state;
}
newList = removeFromList(list, k8sObjects);
break;
case types.addToList:
case types.modifyList:
case types.bulkAddToList:
if (!list) {
return state;
}
newList = updateList(list, k8sObjects);
newList = list.merge(k8sObjects.reduce((map, obj) => map.set(getQN(obj), fromJS(obj)), ImmutableMap()));
break;
case types.errored:
if (!list) {
Expand Down
3 changes: 0 additions & 3 deletions frontend/public/module/k8s/k8s.js

This file was deleted.

7 changes: 7 additions & 0 deletions frontend/public/module/k8s/k8s.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/* eslint-disable no-unused-vars */

import { K8sResourceKind } from './index';

export const getQN: (obj: K8sResourceKind) => string = ({metadata: {name, namespace}}) => (namespace ? `(${namespace})-` : '') + name;

export const k8sBasePath = `${(window as any).SERVER_FLAGS.basePath}api/kubernetes`;