[UI] authorize tensorboard actions (#3639)

* Authorization service proto

* implement auth service

* Add unit tests

* Generate auth api client

* Authorization checks for tensorboard apis

* UI Server authorization checks

* Clean up error parsing

* Revert changes

* Fix portable-fetch not found bug

* Fix unit test

* Include portable-fetch required by api client

* Fix portable-fetch module import error

* Fix portable-fetch again

* Add unit tests

* Address CR comments

* add unit test for header

* Update readme
This commit is contained in:
Yuan (Bob) Gong 2020-04-29 06:26:24 +08:00 committed by GitHub
parent a5b3e7e3f0
commit cb71eed5f9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 963 additions and 172 deletions

View File

@ -61,6 +61,7 @@ Then it depends on what you want to develop:
| ----------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------ |
| Client UI | `NAMESPACE=kubeflow npm run start:proxy` | |
| Client UI + Node server | `NAMESPACE=kubeflow npm run start:proxy-and-server` | You need to rerun the script every time you edit node server code. |
| Client UI + Node server (debug mode) | `NAMESPACE=kubeflow npm run start:proxy-and-server-inspect` | Same as above, and you can use chrome to debug the server. |
## Unit testing FAQ
There are a few typees of tests during presubmit:

View File

@ -29,13 +29,14 @@
},
"scripts": {
"analyze-bundle": "node analyze_bundle.js",
"apis": "npm run apis:experiment && npm run apis:job && npm run apis:pipeline && npm run apis:run && npm run apis:filter && npm run apis:visualization",
"apis": "npm run apis:experiment && npm run apis:job && npm run apis:pipeline && npm run apis:run && npm run apis:filter && npm run apis:visualization && npm run apis:auth",
"apis:experiment": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/experiment.swagger.json -l typescript-fetch -o ./src/apis/experiment -c ./swagger-config.json",
"apis:job": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/job.swagger.json -l typescript-fetch -o ./src/apis/job -c ./swagger-config.json",
"apis:pipeline": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/pipeline.swagger.json -l typescript-fetch -o ./src/apis/pipeline -c ./swagger-config.json",
"apis:run": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/run.swagger.json -l typescript-fetch -o ./src/apis/run -c ./swagger-config.json",
"apis:filter": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/filter.swagger.json -l typescript-fetch -o ./src/apis/filter -c ./swagger-config.json",
"apis:visualization": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/visualization.swagger.json -l typescript-fetch -o ./src/apis/visualization -c ./swagger-config.json",
"apis:auth": "java -jar swagger-codegen-cli.jar generate -i ../backend/api/swagger/auth.swagger.json -l typescript-fetch -o ./server/src/generated/apis/auth -c ./swagger-config.json",
"build": "npm run lint && EXTEND_ESLINT=true react-scripts build",
"docker": "COMMIT_HASH=`git rev-parse HEAD`; docker build -q -t ml-pipelines-frontend:${COMMIT_HASH} --build-arg COMMIT_HASH=${COMMIT_HASH} --build-arg DATE=\"`date -u`\" -f Dockerfile ..",
"eject": "react-scripts eject",
@ -45,9 +46,11 @@
"lint": "eslint --ext js,ts,tsx src",
"mock:api": "ts-node-dev -O '{\"module\": \"commonjs\"}' --project mock-backend/tsconfig.json mock-backend/mock-api-server.ts 3001",
"mock:server": "node server/dist/server.js build",
"mock:server:inspect": "node inspect server/dist/server.js build",
"postinstall": "cd ./server && npm ci && cd ../mock-backend && npm ci",
"start:proxy": "./scripts/start-proxy.sh",
"start:proxy-and-server": "./scripts/start-proxy-and-server.sh",
"start:proxy-and-server-inspect": "./scripts/start-proxy-and-server.sh --inspect",
"start": "EXTEND_ESLINT=true react-scripts start",
"sync-backend-sample-config": "node scripts/sync-backend-sample-config.js",
"test": "react-scripts test",

View File

@ -37,4 +37,8 @@ kubectl port-forward -n $NAMESPACE svc/ml-pipeline 3002:8888 &
kubectl port-forward -n $NAMESPACE svc/minio-service 9000:9000 &
export MINIO_HOST=localhost
export MINIO_NAMESPACE=
if [ "$1" == "--inspect" ]; then
ML_PIPELINE_SERVICE_PORT=3002 npm run mock:server:inspect 3001
else
ML_PIPELINE_SERVICE_PORT=3002 npm run mock:server 3001
fi

View File

@ -33,6 +33,9 @@ jest.mock('node-fetch');
jest.mock('@google-cloud/storage');
jest.mock('./minio-helper');
// TODO: move sections of tests here to individual files in `frontend/server/integration-tests/`
// for better organization and shorter/more focused tests.
const mockedFetch: jest.Mock = fetch as any;
beforeEach(() => {
@ -627,10 +630,11 @@ describe('UIServer apis', () => {
});
describe('/apps/tensorboard', () => {
let request: requests.SuperTest<requests.Test>;
let k8sGetCustomObjectSpy: jest.SpyInstance;
let k8sDeleteCustomObjectSpy: jest.SpyInstance;
let k8sCreateCustomObjectSpy: jest.SpyInstance;
let kfpApiServer: Server;
function newGetTensorboardResponse({
name = 'viewer-example',
logDir = 'log-dir-example',
@ -655,9 +659,8 @@ describe('UIServer apis', () => {
},
};
}
beforeEach(() => {
app = new UIServer(loadConfigs(argv, {}));
request = requests(app.start());
k8sGetCustomObjectSpy = jest.spyOn(
K8S_TEST_EXPORT.k8sV1CustomObjectClient,
'getNamespacedCustomObject',
@ -672,18 +675,29 @@ describe('UIServer apis', () => {
);
});
afterEach(() => {
if (kfpApiServer) {
kfpApiServer.close();
}
});
describe('get', () => {
it('requires logdir for get tensorboard', done => {
request.get('/apps/tensorboard').expect(404, 'logdir argument is required', done);
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.get('/apps/tensorboard')
.expect(400, 'logdir argument is required', done);
});
it('requires namespace for get tensorboard', done => {
request
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.get('/apps/tensorboard?logdir=some-log-dir')
.expect(404, 'namespace argument is required', done);
.expect(400, 'namespace argument is required', done);
});
it('does not crash with a weird query', done => {
app = new UIServer(loadConfigs(argv, {}));
k8sGetCustomObjectSpy.mockImplementation(() =>
Promise.resolve(newGetTensorboardResponse()),
);
@ -691,10 +705,79 @@ describe('UIServer apis', () => {
// exception, so this can verify handler doesn't do extra
// decodeURIComponent on queries.
const weirdLogDir = encodeURIComponent('%2');
request.get(`/apps/tensorboard?logdir=${weirdLogDir}&namespace=test-ns`).expect(200, done);
requests(app.start())
.get(`/apps/tensorboard?logdir=${weirdLogDir}&namespace=test-ns`)
.expect(200, done);
});
it('authorizes user requests from KFP auth api', done => {
app = new UIServer(loadConfigs(argv, { ENABLE_AUTHZ: 'true' }));
const appKfpApi = express();
const receivedHeaders: any[] = [];
appKfpApi.get('/apis/v1beta1/auth', (req, res) => {
receivedHeaders.push(req.headers);
res.status(200).send('{}'); // Authorized
});
kfpApiServer = appKfpApi.listen(3001);
k8sGetCustomObjectSpy.mockImplementation(() =>
Promise.resolve(newGetTensorboardResponse()),
);
requests(app.start())
.get(`/apps/tensorboard?logdir=some-log-dir&namespace=test-ns`)
.set('x-goog-authenticated-user-email', 'accounts.google.com:user@google.com')
.expect(200, err => {
expect(receivedHeaders).toHaveLength(1);
expect(receivedHeaders[0]).toMatchInlineSnapshot(`
Object {
"accept": "*/*",
"accept-encoding": "gzip,deflate",
"connection": "close",
"host": "localhost:3001",
"user-agent": "node-fetch/1.0 (+https://github.com/bitinn/node-fetch)",
"x-goog-authenticated-user-email": "accounts.google.com:user@google.com",
}
`);
done(err);
});
});
it('rejects user requests when KFP auth api rejected', done => {
const errorSpy = jest.spyOn(console, 'error');
errorSpy.mockImplementation();
app = new UIServer(loadConfigs(argv, { ENABLE_AUTHZ: 'true' }));
const appKfpApi = express();
appKfpApi.get('/apis/v1beta1/auth', (_, res) => {
res.status(400).send(
JSON.stringify({
error: 'User xxx is not unauthorized to list viewers',
details: ['unauthorized', 'callstack'],
}),
);
});
kfpApiServer = appKfpApi.listen(3001);
k8sGetCustomObjectSpy.mockImplementation(() =>
Promise.resolve(newGetTensorboardResponse()),
);
requests(app.start())
.get(`/apps/tensorboard?logdir=some-log-dir&namespace=test-ns`)
.expect(
401,
'User is not authorized to GET VIEWERS in namespace test-ns: User xxx is not unauthorized to list viewers',
err => {
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(
errorSpy,
).toHaveBeenCalledWith(
'User is not authorized to GET VIEWERS in namespace test-ns: User xxx is not unauthorized to list viewers',
['unauthorized', 'callstack'],
);
done(err);
},
);
});
it('gets tensorboard url and version', done => {
app = new UIServer(loadConfigs(argv, {}));
k8sGetCustomObjectSpy.mockImplementation(() =>
Promise.resolve(
newGetTensorboardResponse({
@ -705,7 +788,7 @@ describe('UIServer apis', () => {
),
);
request
requests(app.start())
.get(`/apps/tensorboard?logdir=${encodeURIComponent('log-dir-1')}&namespace=test-ns`)
.expect(
200,
@ -732,19 +815,24 @@ describe('UIServer apis', () => {
describe('post (create)', () => {
it('requires logdir', done => {
request.post('/apps/tensorboard').expect(404, 'logdir argument is required', done);
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.post('/apps/tensorboard')
.expect(400, 'logdir argument is required', done);
});
it('requires namespace', done => {
request
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.post('/apps/tensorboard?logdir=some-log-dir')
.expect(404, 'namespace argument is required', done);
.expect(400, 'namespace argument is required', done);
});
it('requires tfversion', done => {
request
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.post('/apps/tensorboard?logdir=some-log-dir&namespace=test-ns')
.expect(404, 'tfversion (tensorflow version) argument is required', done);
.expect(400, 'tfversion (tensorflow version) argument is required', done);
});
it('creates tensorboard viewer custom object and waits for it', done => {
@ -768,7 +856,8 @@ describe('UIServer apis', () => {
});
k8sCreateCustomObjectSpy.mockImplementation(() => Promise.resolve());
request
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.post(
`/apps/tensorboard?logdir=${encodeURIComponent(
'log-dir-1',
@ -845,7 +934,8 @@ describe('UIServer apis', () => {
);
k8sCreateCustomObjectSpy.mockImplementation(() => Promise.resolve());
request
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.post(
`/apps/tensorboard?logdir=${encodeURIComponent(
'log-dir-1',
@ -853,7 +943,7 @@ describe('UIServer apis', () => {
)
.expect(
500,
`Failed to start Tensorboard app: Error: There's already an existing tensorboard instance with a different version 2.1.0`,
`Failed to start Tensorboard app: There's already an existing tensorboard instance with a different version 2.1.0`,
err => {
expect(errorSpy).toHaveBeenCalledTimes(1);
done(err);
@ -873,7 +963,8 @@ describe('UIServer apis', () => {
);
k8sCreateCustomObjectSpy.mockImplementation(() => Promise.resolve());
request
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.post(
`/apps/tensorboard?logdir=${encodeURIComponent(
'log-dir-1',
@ -889,13 +980,17 @@ describe('UIServer apis', () => {
describe('delete', () => {
it('requires logdir', done => {
request.delete('/apps/tensorboard').expect(404, 'logdir argument is required', done);
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.delete('/apps/tensorboard')
.expect(400, 'logdir argument is required', done);
});
it('requires namespace', done => {
request
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.delete('/apps/tensorboard?logdir=some-log-dir')
.expect(404, 'namespace argument is required', done);
.expect(400, 'namespace argument is required', done);
});
it('deletes tensorboard viewer custom object', done => {
@ -910,7 +1005,8 @@ describe('UIServer apis', () => {
);
k8sDeleteCustomObjectSpy.mockImplementation(() => Promise.resolve());
request
app = new UIServer(loadConfigs(argv, {}));
requests(app.start())
.delete(`/apps/tensorboard?logdir=${encodeURIComponent('log-dir-1')}&namespace=test-ns`)
.expect(200, 'Tensorboard deleted.', err => {
expect(k8sDeleteCustomObjectSpy.mock.calls[0]).toMatchInlineSnapshot(`

View File

@ -24,11 +24,7 @@ import {
getArtifactsProxyHandler,
getArtifactServiceGetter,
} from './handlers/artifacts';
import {
getCreateTensorboardHandler,
getTensorboardHandler,
deleteTensorboardHandler,
} from './handlers/tensorboard';
import { getTensorboardHandlers } from './handlers/tensorboard';
import { getPodLogsHandler } from './handlers/pod-logs';
import { podInfoHandler, podEventsHandler } from './handlers/pod-info';
import { getClusterNameHandler, getProjectIdHandler } from './handlers/gke-metadata';
@ -129,13 +125,17 @@ function createUIServer(options: UIConfigs) {
registerHandler(app.get, '/artifacts/get', getArtifactsHandler(options.artifacts));
/** Tensorboard viewer */
registerHandler(app.get, '/apps/tensorboard', getTensorboardHandler);
registerHandler(app.delete, '/apps/tensorboard', deleteTensorboardHandler);
registerHandler(
app.post,
'/apps/tensorboard',
getCreateTensorboardHandler(options.viewer.tensorboard),
);
const {
get: tensorboardGetHandler,
create: tensorboardCreateHandler,
delete: tensorboardDeleteHandler,
} = getTensorboardHandlers(options.viewer.tensorboard, {
apiServerAddress,
authzEnabled: options.auth.enabled,
});
registerHandler(app.get, '/apps/tensorboard', tensorboardGetHandler);
registerHandler(app.delete, '/apps/tensorboard', tensorboardDeleteHandler);
registerHandler(app.post, '/apps/tensorboard', tensorboardCreateHandler);
/** Pod logs */
registerHandler(app.get, '/k8s/pod/logs', getPodLogsHandler(options.argo, options.artifacts));

View File

@ -88,6 +88,8 @@ export function loadConfigs(argv: string[], env: ProcessEnv): UIConfigs {
ARGO_ARCHIVE_PREFIX = 'logs',
/** Disables GKE metadata endpoint. */
DISABLE_GKE_METADATA = 'false',
/** Enable authorization checks for multi user mode. */
ENABLE_AUTHZ = 'false',
/** Deployment type. */
DEPLOYMENT: DEPLOYMENT_STR = '',
} = env;
@ -158,6 +160,9 @@ export function loadConfigs(argv: string[], env: ProcessEnv): UIConfigs {
gkeMetadata: {
disabled: asBool(DISABLE_GKE_METADATA),
},
auth: {
enabled: asBool(ENABLE_AUTHZ),
},
};
}
@ -216,6 +221,9 @@ export interface ServerConfigs {
export interface GkeMetadataConfigs {
disabled: boolean;
}
export interface AuthorizationConfigs {
enabled: boolean;
}
export interface UIConfigs {
server: ServerConfigs;
artifacts: {
@ -230,4 +238,5 @@ export interface UIConfigs {
viewer: ViewerConfigs;
pipeline: PipelineConfigs;
gkeMetadata: GkeMetadataConfigs;
auth: AuthorizationConfigs;
}

View File

@ -239,7 +239,6 @@ function getGCSArtifactHandler(options: { key: string; bucket: string }, peek: n
};
}
const AUTH_EMAIL_HEADER = 'x-goog-authenticated-user-email';
const ARTIFACTS_PROXY_DEFAULTS = {
serviceName: 'ml-pipeline-ui-artifact',
servicePort: '80',

View File

@ -11,58 +11,127 @@
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { Handler } from 'express';
import { Handler, Request, Response } from 'express';
import * as k8sHelper from '../k8s-helper';
import { ViewerTensorboardConfig } from '../configs';
import {
AuthServiceApi,
AuthorizeRequestResources,
AuthorizeRequestVerb,
} from '../src/generated/apis/auth';
import portableFetch from 'portable-fetch';
import { parseError } from '../utils';
async function authorize(
req: Request,
res: Response,
authService: AuthServiceApi,
{
resources,
verb,
namespace,
}: { resources: AuthorizeRequestResources; verb: AuthorizeRequestVerb; namespace: string },
): Promise<boolean> {
try {
// Resources and verb are string enums, they are used as string here, that
// requires a force type conversion. If we generated client should accept
// enums instead.
await authService.authorize(namespace, resources as any, verb as any, {
// Pass authentication header.
// TODO: parameterize the header.
headers: { [AUTH_EMAIL_HEADER]: req.headers[AUTH_EMAIL_HEADER] },
});
console.debug(`Authorized to ${verb} ${resources} in namespace ${namespace}.`);
return true;
} catch (err) {
const details = await parseError(err);
const message = `User is not authorized to ${verb} ${resources} in namespace ${namespace}: ${details.message}`;
console.error(message, details.additionalInfo);
res.status(401).send(message);
}
return false;
}
export const getTensorboardHandlers = (
tensorboardConfig: ViewerTensorboardConfig,
otherConfig: { apiServerAddress: string; authzEnabled: boolean },
): { get: Handler; create: Handler; delete: Handler } => {
const { apiServerAddress, authzEnabled } = otherConfig;
console.log('api server address ' + apiServerAddress);
// TODO: Use portable-fetch instead of node-fetch in other parts too. The generated api here only
// supports portable-fetch.
const authService = new AuthServiceApi(
{ basePath: apiServerAddress },
undefined,
portableFetch as any,
);
/**
* A handler which retrieve the endpoint for a tensorboard instance. The
* handler expects a query string `logdir`.
*/
export const getTensorboardHandler: Handler = async (req, res) => {
const get: Handler = async (req, res) => {
const { logdir, namespace } = req.query;
if (!logdir) {
res.status(404).send('logdir argument is required');
res.status(400).send('logdir argument is required');
return;
}
if (!namespace) {
res.status(404).send('namespace argument is required');
res.status(400).send('namespace argument is required');
return;
}
try {
if (authzEnabled) {
const authorized = await authorize(req, res, authService, {
verb: AuthorizeRequestVerb.GET,
resources: AuthorizeRequestResources.VIEWERS,
namespace,
});
if (!authorized) {
return;
}
}
res.send(await k8sHelper.getTensorboardInstance(logdir, namespace));
} catch (err) {
console.error('Failed to list Tensorboard pods: ', err?.body || err);
res.status(500).send(`Failed to list Tensorboard pods: ${err}`);
const details = await parseError(err);
console.error(`Failed to list Tensorboard pods: ${details.message}`, details.additionalInfo);
res.status(500).send(`Failed to list Tensorboard pods: ${details.message}`);
}
};
/**
* Returns a handler which will create a tensorboard viewer CRD, waits for the
* A handler which will create a tensorboard viewer CRD, waits for the
* tensorboard instance to be ready, and return the endpoint to the instance.
* The handler expects the following query strings in the request:
* - `logdir`
* - `tfversion`
* @param tensorboardConfig The configuration for Tensorboard.
*/
export function getCreateTensorboardHandler(tensorboardConfig: ViewerTensorboardConfig): Handler {
return async (req, res) => {
const create: Handler = async (req, res) => {
const { logdir, namespace, tfversion } = req.query;
if (!logdir) {
res.status(404).send('logdir argument is required');
res.status(400).send('logdir argument is required');
return;
}
if (!namespace) {
res.status(404).send('namespace argument is required');
res.status(400).send('namespace argument is required');
return;
}
if (!tfversion) {
res.status(404).send('tfversion (tensorflow version) argument is required');
res.status(400).send('tfversion (tensorflow version) argument is required');
return;
}
try {
if (authzEnabled) {
const authorized = await authorize(req, res, authService, {
verb: AuthorizeRequestVerb.CREATE,
resources: AuthorizeRequestResources.VIEWERS,
namespace,
});
if (!authorized) {
return;
}
}
await k8sHelper.newTensorboardInstance(
logdir,
namespace,
@ -77,32 +146,52 @@ export function getCreateTensorboardHandler(tensorboardConfig: ViewerTensorboard
);
res.send(tensorboardAddress);
} catch (err) {
console.error('Failed to start Tensorboard app: ', err?.body || err);
res.status(500).send(`Failed to start Tensorboard app: ${err}`);
const details = await parseError(err);
console.error(`Failed to start Tensorboard app: ${details.message}`, details.additionalInfo);
res.status(500).send(`Failed to start Tensorboard app: ${details.message}`);
}
};
}
/**
* A handler that deletes a tensorboard viewer. The handler expects query string
* `logdir` in the request.
*/
export const deleteTensorboardHandler: Handler = async (req, res) => {
const deleteHandler: Handler = async (req, res) => {
const { logdir, namespace } = req.query;
if (!logdir) {
res.status(404).send('logdir argument is required');
res.status(400).send('logdir argument is required');
return;
}
if (!namespace) {
res.status(404).send('namespace argument is required');
res.status(400).send('namespace argument is required');
return;
}
try {
if (authzEnabled) {
const authorized = await authorize(req, res, authService, {
verb: AuthorizeRequestVerb.DELETE,
resources: AuthorizeRequestResources.VIEWERS,
namespace,
});
if (!authorized) {
return;
}
}
await k8sHelper.deleteTensorboardInstance(logdir, namespace);
res.send('Tensorboard deleted.');
} catch (err) {
console.error('Failed to delete Tensorboard app: ', err?.body || err);
res.status(500).send(`Failed to delete Tensorboard app: ${err}`);
const details = await parseError(err);
console.error(`Failed to delete Tensorboard app: ${details.message}`, details.additionalInfo);
res.status(500).send(`Failed to delete Tensorboard app: ${details.message}`);
}
};
return {
get,
create,
delete: deleteHandler,
};
};
const AUTH_EMAIL_HEADER = 'x-goog-authenticated-user-email';

View File

@ -23,6 +23,7 @@ import {
import * as crypto from 'crypto-js';
import * as fs from 'fs';
import { PartialArgoWorkflow } from './workflow-helper';
import { parseError } from './utils';
// If this is running inside a k8s Pod, its namespace should be written at this
// path, this is also how we can tell whether we're running in the cluster.
@ -233,7 +234,7 @@ export async function getPod(
const { body } = await k8sV1Client.readNamespacedPod(podName, podNamespace);
return [body, undefined];
} catch (error) {
const { message, additionalInfo } = parseK8sError(error);
const { message, additionalInfo } = await parseError(error);
const userMessage = `Could not get pod ${podName} in namespace ${podNamespace}: ${message}`;
return [undefined, { message: userMessage, additionalInfo }];
}
@ -258,7 +259,7 @@ export async function listPodEvents(
);
return [body, undefined];
} catch (error) {
const { message, additionalInfo } = parseK8sError(error);
const { message, additionalInfo } = await parseError(error);
const userMessage = `Error when listing pod events for pod "${podName}" in "${podNamespace}" namespace: ${message}`;
return [undefined, { message: userMessage, additionalInfo }];
}
@ -307,48 +308,6 @@ export async function getK8sSecret(name: string, key: string) {
return buff.toString('ascii');
}
const UNKOWN_ERROR = {
message: 'Unknown error',
additionalInfo: 'Unknown error',
};
function parseK8sError(error: any): { message: string; additionalInfo: any } {
try {
if (!error) {
return UNKOWN_ERROR;
} else if (typeof error === 'string') {
return {
message: error,
additionalInfo: error,
};
} else if (error.body) {
// Kubernetes client http error has body with all the info.
// Example error.body
// {
// kind: 'Status',
// apiVersion: 'v1',
// metadata: {},
// status: 'Failure',
// message: 'pods "test-pod" not found',
// reason: 'NotFound',
// details: { name: 'test-pod', kind: 'pods' },
// code: 404
// }
return {
message: error.body.message || UNKOWN_ERROR.message,
additionalInfo: error.body,
};
} else {
return {
message: error.message || UNKOWN_ERROR.message,
additionalInfo: error,
};
}
} catch (parsingError) {
console.error('There was a parsing error: ', parsingError);
return UNKOWN_ERROR;
}
}
export const TEST_ONLY = {
k8sV1Client,
k8sV1CustomObjectClient,

View File

@ -2694,6 +2694,14 @@
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
},
"encoding": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz",
"integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=",
"requires": {
"iconv-lite": "~0.4.13"
}
},
"end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@ -3784,8 +3792,7 @@
"is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
"integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=",
"dev": true
"integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ="
},
"is-stream-ended": {
"version": "0.1.4",
@ -7036,6 +7043,26 @@
"integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==",
"dev": true
},
"portable-fetch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/portable-fetch/-/portable-fetch-3.0.0.tgz",
"integrity": "sha1-PL9KptvFpXNLQcBBnJJzMTv9mtg=",
"requires": {
"node-fetch": "^1.0.1",
"whatwg-fetch": ">=0.10.0"
},
"dependencies": {
"node-fetch": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
"integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
"requires": {
"encoding": "^0.1.11",
"is-stream": "^1.0.1"
}
}
}
},
"posix-character-classes": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz",
@ -7046,6 +7073,12 @@
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
"integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ="
},
"prettier": {
"version": "1.19.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-1.19.1.tgz",
"integrity": "sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==",
"dev": true
},
"pretty-format": {
"version": "24.9.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-24.9.0.tgz",
@ -8390,6 +8423,11 @@
"iconv-lite": "0.4.24"
}
},
"whatwg-fetch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz",
"integrity": "sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q=="
},
"whatwg-mimetype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz",

View File

@ -13,6 +13,7 @@
"minio": "^7.0.0",
"node-fetch": "^2.1.2",
"peek-stream": "^1.1.3",
"portable-fetch": "^3.0.0",
"tar-stream": "^2.1.0"
},
"devDependencies": {
@ -28,6 +29,7 @@
"@types/tar": "^4.0.3",
"@types/tar-stream": "^1.6.1",
"jest": "^25.3.0",
"prettier": "1.19.1",
"supertest": "^4.0.2",
"ts-jest": "^25.2.1",
"tslint": "^5.20.1",

View File

@ -0,0 +1,3 @@
wwwroot/*.js
node_modules
typings

View File

@ -0,0 +1,23 @@
# Swagger Codegen Ignore
# Generated by swagger-codegen https://github.com/swagger-api/swagger-codegen
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell Swagger Codgen to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@ -0,0 +1 @@
2.4.7

View File

@ -0,0 +1,319 @@
/// <reference path="./custom.d.ts" />
// tslint:disable
/**
* backend/api/auth.proto
* No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
*
* OpenAPI spec version: version not set
*
*
* NOTE: This class is auto generated by the swagger code generator program.
* https://github.com/swagger-api/swagger-codegen.git
* Do not edit the class manually.
*/
import * as url from 'url';
import * as portableFetch from 'portable-fetch';
import { Configuration } from './configuration';
const BASE_PATH = 'http://localhost'.replace(/\/+$/, '');
/**
*
* @export
*/
export const COLLECTION_FORMATS = {
csv: ',',
ssv: ' ',
tsv: '\t',
pipes: '|',
};
/**
*
* @export
* @interface FetchAPI
*/
export interface FetchAPI {
(url: string, init?: any): Promise<Response>;
}
/**
*
* @export
* @interface FetchArgs
*/
export interface FetchArgs {
url: string;
options: any;
}
/**
*
* @export
* @class BaseAPI
*/
export class BaseAPI {
protected configuration: Configuration;
constructor(
configuration?: Configuration,
protected basePath: string = BASE_PATH,
protected fetch: FetchAPI = portableFetch,
) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath || this.basePath;
}
}
}
/**
*
* @export
* @class RequiredError
* @extends {Error}
*/
export class RequiredError extends Error {
name: 'RequiredError';
constructor(public field: string, msg?: string) {
super(msg);
}
}
/**
*
* @export
* @interface ApiStatus
*/
export interface ApiStatus {
/**
*
* @type {string}
* @memberof ApiStatus
*/
error?: string;
/**
*
* @type {number}
* @memberof ApiStatus
*/
code?: number;
/**
*
* @type {Array<ProtobufAny>}
* @memberof ApiStatus
*/
details?: Array<ProtobufAny>;
}
/**
* Type of resources in pipelines system.
* @export
* @enum {string}
*/
export enum AuthorizeRequestResources {
UNASSIGNEDRESOURCES = <any>'UNASSIGNED_RESOURCES',
VIEWERS = <any>'VIEWERS',
}
/**
* Type of verbs that act on the resources.
* @export
* @enum {string}
*/
export enum AuthorizeRequestVerb {
UNASSIGNEDVERB = <any>'UNASSIGNED_VERB',
CREATE = <any>'CREATE',
GET = <any>'GET',
DELETE = <any>'DELETE',
}
/**
* `Any` contains an arbitrary serialized protocol buffer message along with a URL that describes the type of the serialized message. Protobuf library provides support to pack/unpack Any values in the form of utility functions or additional generated methods of the Any type. Example 1: Pack and unpack a message in C++. Foo foo = ...; Any any; any.PackFrom(foo); ... if (any.UnpackTo(&foo)) { ... } Example 2: Pack and unpack a message in Java. Foo foo = ...; Any any = Any.pack(foo); ... if (any.is(Foo.class)) { foo = any.unpack(Foo.class); } Example 3: Pack and unpack a message in Python. foo = Foo(...) any = Any() any.Pack(foo) ... if any.Is(Foo.DESCRIPTOR): any.Unpack(foo) ... Example 4: Pack and unpack a message in Go foo := &pb.Foo{...} any, err := ptypes.MarshalAny(foo) ... foo := &pb.Foo{} if err := ptypes.UnmarshalAny(any, foo); err != nil { ... } The pack methods provided by protobuf library will by default use 'type.googleapis.com/full.type.name' as the type URL and the unpack methods only use the fully qualified type name after the last '/' in the type URL, for example \"foo.bar.com/x/y.z\" will yield type name \"y.z\". JSON ==== The JSON representation of an `Any` value uses the regular representation of the deserialized, embedded message, with an additional field `@type` which contains the type URL. Example: package google.profile; message Person { string first_name = 1; string last_name = 2; } { \"@type\": \"type.googleapis.com/google.profile.Person\", \"firstName\": <string>, \"lastName\": <string> } If the embedded message type is well-known and has a custom JSON representation, that representation will be embedded adding a field `value` which holds the custom JSON in addition to the `@type` field. Example (for message [google.protobuf.Duration][]): { \"@type\": \"type.googleapis.com/google.protobuf.Duration\", \"value\": \"1.212s\" }
* @export
* @interface ProtobufAny
*/
export interface ProtobufAny {
/**
* A URL/resource name that uniquely identifies the type of the serialized protocol buffer message. The last segment of the URL's path must represent the fully qualified name of the type (as in `path/google.protobuf.Duration`). The name should be in a canonical form (e.g., leading \".\" is not accepted). In practice, teams usually precompile into the binary all types that they expect it to use in the context of Any. However, for URLs which use the scheme `http`, `https`, or no scheme, one can optionally set up a type server that maps type URLs to message definitions as follows: * If no scheme is provided, `https` is assumed. * An HTTP GET on the URL must yield a [google.protobuf.Type][] value in binary format, or produce an error. * Applications are allowed to cache lookup results based on the URL, or have them precompiled into a binary to avoid any lookup. Therefore, binary compatibility needs to be preserved on changes to types. (Use versioned type names to manage breaking changes.) Note: this functionality is not currently available in the official protobuf release, and it is not used for type URLs beginning with type.googleapis.com. Schemes other than `http`, `https` (or the empty scheme) might be used with implementation specific semantics.
* @type {string}
* @memberof ProtobufAny
*/
type_url?: string;
/**
* Must be a valid serialized protocol buffer of the above specified type.
* @type {string}
* @memberof ProtobufAny
*/
value?: string;
}
/**
* AuthServiceApi - fetch parameter creator
* @export
*/
export const AuthServiceApiFetchParamCreator = function(configuration?: Configuration) {
return {
/**
*
* @param {string} [namespace]
* @param {'UNASSIGNED_RESOURCES' | 'VIEWERS'} [resources]
* @param {'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE'} [verb]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
authorize(
namespace?: string,
resources?: 'UNASSIGNED_RESOURCES' | 'VIEWERS',
verb?: 'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE',
options: any = {},
): FetchArgs {
const localVarPath = `/apis/v1beta1/auth`;
const localVarUrlObj = url.parse(localVarPath, true);
const localVarRequestOptions = Object.assign({ method: 'GET' }, options);
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication Bearer required
if (configuration && configuration.apiKey) {
const localVarApiKeyValue =
typeof configuration.apiKey === 'function'
? configuration.apiKey('authorization')
: configuration.apiKey;
localVarHeaderParameter['authorization'] = localVarApiKeyValue;
}
if (namespace !== undefined) {
localVarQueryParameter['namespace'] = namespace;
}
if (resources !== undefined) {
localVarQueryParameter['resources'] = resources;
}
if (verb !== undefined) {
localVarQueryParameter['verb'] = verb;
}
localVarUrlObj.query = Object.assign(
{},
localVarUrlObj.query,
localVarQueryParameter,
options.query,
);
// fix override query string Detail: https://stackoverflow.com/a/7517673/1077943
delete localVarUrlObj.search;
localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers);
return {
url: url.format(localVarUrlObj),
options: localVarRequestOptions,
};
},
};
};
/**
* AuthServiceApi - functional programming interface
* @export
*/
export const AuthServiceApiFp = function(configuration?: Configuration) {
return {
/**
*
* @param {string} [namespace]
* @param {'UNASSIGNED_RESOURCES' | 'VIEWERS'} [resources]
* @param {'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE'} [verb]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
authorize(
namespace?: string,
resources?: 'UNASSIGNED_RESOURCES' | 'VIEWERS',
verb?: 'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE',
options?: any,
): (fetch?: FetchAPI, basePath?: string) => Promise<any> {
const localVarFetchArgs = AuthServiceApiFetchParamCreator(configuration).authorize(
namespace,
resources,
verb,
options,
);
return (fetch: FetchAPI = portableFetch, basePath: string = BASE_PATH) => {
return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then(response => {
if (response.status >= 200 && response.status < 300) {
return response.json();
} else {
throw response;
}
});
};
},
};
};
/**
* AuthServiceApi - factory interface
* @export
*/
export const AuthServiceApiFactory = function(
configuration?: Configuration,
fetch?: FetchAPI,
basePath?: string,
) {
return {
/**
*
* @param {string} [namespace]
* @param {'UNASSIGNED_RESOURCES' | 'VIEWERS'} [resources]
* @param {'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE'} [verb]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
authorize(
namespace?: string,
resources?: 'UNASSIGNED_RESOURCES' | 'VIEWERS',
verb?: 'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE',
options?: any,
) {
return AuthServiceApiFp(configuration).authorize(
namespace,
resources,
verb,
options,
)(fetch, basePath);
},
};
};
/**
* AuthServiceApi - object-oriented interface
* @export
* @class AuthServiceApi
* @extends {BaseAPI}
*/
export class AuthServiceApi extends BaseAPI {
/**
*
* @param {string} [namespace]
* @param {'UNASSIGNED_RESOURCES' | 'VIEWERS'} [resources]
* @param {'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE'} [verb]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AuthServiceApi
*/
public authorize(
namespace?: string,
resources?: 'UNASSIGNED_RESOURCES' | 'VIEWERS',
verb?: 'UNASSIGNED_VERB' | 'CREATE' | 'GET' | 'DELETE',
options?: any,
) {
return AuthServiceApiFp(this.configuration).authorize(
namespace,
resources,
verb,
options,
)(this.fetch, this.basePath);
}
}

View File

@ -0,0 +1,65 @@
// tslint:disable
/**
* backend/api/auth.proto
* No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
*
* OpenAPI spec version: version not set
*
*
* NOTE: This class is auto generated by the swagger code generator program.
* https://github.com/swagger-api/swagger-codegen.git
* Do not edit the class manually.
*/
export interface ConfigurationParameters {
apiKey?: string | ((name: string) => string);
username?: string;
password?: string;
accessToken?: string | ((name: string, scopes?: string[]) => string);
basePath?: string;
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?: string | ((name: string) => string);
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
username?: string;
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?: string | ((name: string, scopes?: string[]) => string);
/**
* override base path
*
* @type {string}
* @memberof Configuration
*/
basePath?: string;
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
}
}

View File

@ -0,0 +1,2 @@
declare module 'portable-fetch';
declare module 'url';

View File

@ -0,0 +1,51 @@
#!/bin/sh
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
#
# Usage example: /bin/sh ./git_push.sh wing328 swagger-petstore-perl "minor update"
git_user_id=$1
git_repo_id=$2
release_note=$3
if [ "$git_user_id" = "" ]; then
git_user_id="GIT_USER_ID"
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
fi
if [ "$git_repo_id" = "" ]; then
git_repo_id="GIT_REPO_ID"
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
fi
if [ "$release_note" = "" ]; then
release_note="Minor update"
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
fi
# Initialize the local directory as a Git repository
git init
# Adds the files in the local repository and stages them for commit.
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
git commit -m "$release_note"
# Sets the new remote
git_remote=`git remote`
if [ "$git_remote" = "" ]; then # git remote not defined
if [ "$GIT_TOKEN" = "" ]; then
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
git remote add origin https://github.com/${git_user_id}/${git_repo_id}.git
else
git remote add origin https://${git_user_id}:${GIT_TOKEN}@github.com/${git_user_id}/${git_repo_id}.git
fi
fi
git pull origin master
# Pushes (Forces) the changes in the local repository up to the remote repository
echo "Git pushing to https://github.com/${git_user_id}/${git_repo_id}.git"
git push origin master 2>&1 | grep -v 'To https'

View File

@ -0,0 +1,15 @@
// tslint:disable
/**
* backend/api/auth.proto
* No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
*
* OpenAPI spec version: version not set
*
*
* NOTE: This class is auto generated by the swagger code generator program.
* https://github.com/swagger-api/swagger-codegen.git
* Do not edit the class manually.
*/
export * from './api';
export * from './configuration';

View File

@ -21,6 +21,7 @@
"strictNullChecks": true,
"suppressImplicitAnyIndexErrors": true,
"skipLibCheck": true,
"strictPropertyInitialization": false, // Workaround: swagger generated client breaks this.
"strict": true
},
"exclude": ["dist", "coverage", "node_modules"]

View File

@ -94,3 +94,91 @@ export class PreviewStream extends Transform {
super({ ...opts, transform });
}
}
export interface ErrorDetails {
message: string;
additionalInfo: any;
}
const UNKOWN_ERROR = 'Unknown error';
export async function parseError(error: any): Promise<ErrorDetails> {
return (
parseK8sError(error) ||
(await parseKfpApiError(error)) ||
parseGenericError(error) || { message: UNKOWN_ERROR, additionalInfo: error }
);
}
function parseGenericError(error: any): ErrorDetails | undefined {
if (!error) {
return undefined;
} else if (typeof error === 'string') {
return {
message: error,
additionalInfo: error,
};
} else if (error instanceof Error) {
return { message: error.message, additionalInfo: error };
} else if (error.message && typeof error.message === 'string') {
return { message: error.message, additionalInfo: error };
} else if (
error.url &&
typeof error.url === 'string' &&
error.status &&
typeof error.status === 'number' &&
error.statusText &&
typeof error.statusText === 'string'
) {
const { url, status, statusText } = error;
return {
message: `Fetching ${url} failed with status code ${status} and message: ${statusText}`,
additionalInfo: { url, status, statusText },
};
}
// Cannot understand error type
return undefined;
}
async function parseKfpApiError(error: any): Promise<ErrorDetails | undefined> {
if (!error || !error.json || typeof error.json !== 'function') {
return undefined;
}
try {
const json = await error.json();
const { error: message, details } = json;
if (message && details && typeof message === 'string' && typeof details === 'object') {
return {
message,
additionalInfo: details,
};
} else {
return undefined;
}
} catch (err) {
return undefined;
}
}
function parseK8sError(error: any): ErrorDetails | undefined {
if (!error || !error.body || typeof error.body !== 'object') {
return undefined;
}
if (typeof error.body.message !== 'string') {
return undefined;
}
// Kubernetes client http error has body with all the info.
// Example error.body
// {
// kind: 'Status',
// apiVersion: 'v1',
// metadata: {},
// status: 'Failure',
// message: 'pods "test-pod" not found',
// reason: 'NotFound',
// details: { name: 'test-pod', kind: 'pods' },
// code: 404
// }
return {
message: error.body.message,
additionalInfo: error.body,
};
}

View File

@ -19,7 +19,7 @@ import BusyButton from '../../atoms/BusyButton';
import Button from '@material-ui/core/Button';
import Viewer, { ViewerConfig } from './Viewer';
import { Apis } from '../../lib/Apis';
import { commonCss, padding } from '../../Css';
import { commonCss, padding, color } from '../../Css';
import InputLabel from '@material-ui/core/InputLabel';
import Input from '@material-ui/core/Input';
import MenuItem from '@material-ui/core/MenuItem';
@ -47,6 +47,12 @@ export const css = stylesheet({
shortButton: {
width: 50,
},
warningText: {
color: color.warningText,
},
errorText: {
color: color.errorText,
},
});
export interface TensorboardViewerConfig extends ViewerConfig {
@ -67,6 +73,7 @@ interface TensorboardViewerState {
tensorflowVersion: string;
// When podAddress is not null, we need to further tell whether the TensorBoard pod is accessible or not
tensorboardReady: boolean;
errorMessage?: string;
}
// TODO(jingzhang36): we'll later parse Tensorboard version from mlpipeline-ui-metadata.json file.
@ -84,6 +91,7 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
podAddress: '',
tensorflowVersion: DEFAULT_TENSORBOARD_VERSION,
tensorboardReady: false,
errorMessage: undefined,
};
}
@ -117,6 +125,7 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
public render(): JSX.Element {
return (
<div>
{this.state.errorMessage && <div className={css.errorText}>{this.state.errorMessage}</div>}
{this.state.podAddress && (
<div>
<div
@ -138,7 +147,9 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
{this.state.tensorboardReady ? (
``
) : (
<div>Tensorboard is starting, and you may need to wait for a few minutes.</div>
<div className={css.warningText}>
Tensorboard is starting, and you may need to wait for a few minutes.
</div>
)}
</a>
@ -264,6 +275,7 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
private async _checkTensorboardApp(): Promise<void> {
this.setState({ busy: true }, async () => {
try {
const { podAddress, tfVersion } = await Apis.getTensorboardApp(
this._buildUrl(),
this._getNamespace(),
@ -274,11 +286,15 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
// No existing pod
this.setState({ busy: false });
}
} catch (err) {
this.setState({ busy: false, errorMessage: err?.message || 'Unknown error' });
}
});
}
private _startTensorboard = async () => {
this.setState({ busy: true }, async () => {
this.setState({ busy: true, errorMessage: undefined }, async () => {
try {
await Apis.startTensorboardApp(
this._buildUrl(),
this.state.tensorflowVersion,
@ -287,13 +303,17 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
this.setState({ busy: false, tensorboardReady: false }, () => {
this._checkTensorboardApp();
});
} catch (err) {
this.setState({ busy: false, errorMessage: err?.message || 'Unknown error' });
}
});
};
private _deleteTensorboard = async () => {
// delete the already opened Tensorboard, clear the podAddress recorded in frontend,
// and return to the select & start tensorboard page
this.setState({ busy: true }, async () => {
this.setState({ busy: true, errorMessage: undefined }, async () => {
try {
await Apis.deleteTensorboardApp(this._buildUrl(), this._getNamespace());
this.setState({
busy: false,
@ -302,6 +322,9 @@ class TensorboardViewer extends Viewer<TensorboardViewerProps, TensorboardViewer
tensorflowVersion: DEFAULT_TENSORBOARD_VERSION,
tensorboardReady: false,
});
} catch (err) {
this.setState({ busy: false, errorMessage: err?.message || 'Unknown error' });
}
});
};
}