This is an automated email from the ASF dual-hosted git repository. rabbah pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/openwhisk.git
commit 81d6b4daa88efb1bdfd08b185568b75e8c7dbdac Author: Joshua Auerbach <[email protected]> AuthorDate: Fri Oct 11 16:45:00 2019 -0400 A UI providing playground functionality for authoring functions and running them in the browser. --- .../playground/actions/playground-delete.js | 34 + .../playground/actions/playground-fetch.js | 34 + .../resources/playground/actions/playground-run.js | 72 ++ .../playground/actions/playground-userpackage.js | 60 ++ .../src/main/resources/playground/ui/index.html | 139 ++++ .../main/resources/playground/ui/playground.css | 228 ++++++ .../resources/playground/ui/playgroundFunctions.js | 799 +++++++++++++++++++++ 7 files changed, 1366 insertions(+) diff --git a/core/standalone/src/main/resources/playground/actions/playground-delete.js b/core/standalone/src/main/resources/playground/actions/playground-delete.js new file mode 100644 index 0000000..75e4f50 --- /dev/null +++ b/core/standalone/src/main/resources/playground/actions/playground-delete.js @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * 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. + */ + +var openwhisk = require('openwhisk'); + +// Deletes a deployed action (named according to the playgroundId and action name) if the action exists. +function main(outerParam) { + let param = JSON.parse(outerParam['__ow_body']) + let playgroundId = param['playgroundId'] + let actionName = param['actionName'] + let wsk = openwhisk({ignore_certs: outerParam.__ignore_certs}) // ignores self-signed certs, necessary in some deployments + let fullName = 'user' + playgroundId + '/' + actionName + console.log("deleting action", fullName) + return wsk.actions.delete(fullName).then(result => { + console.log('deleted user action') + return result + }).catch(err => { + console.error('action did not exist or error occurred', err) + }) +} diff --git a/core/standalone/src/main/resources/playground/actions/playground-fetch.js b/core/standalone/src/main/resources/playground/actions/playground-fetch.js new file mode 100644 index 0000000..76540ab --- /dev/null +++ b/core/standalone/src/main/resources/playground/actions/playground-fetch.js @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * 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. + */ + +var openwhisk = require('openwhisk'); + +// Returns the code of a deployed action named according to the playgroundId action name +function main(outerParam) { + let param = JSON.parse(outerParam['__ow_body']) + let playgroundId = param['playgroundId'] + let actionName = param['actionName'] + let wsk = openwhisk({ignore_certs: outerParam.__ignore_certs}) // ignores self-signed certs, necessary in some deployments + let fullName = 'user' + playgroundId + '/' + actionName + console.log("fetching action", fullName) + return wsk.actions.get(fullName).then(result => { + console.log('got user action') + return result + }).catch(err => { + console.error('error retrieving action', err) + }) +} diff --git a/core/standalone/src/main/resources/playground/actions/playground-run.js b/core/standalone/src/main/resources/playground/actions/playground-run.js new file mode 100644 index 0000000..6a4984d --- /dev/null +++ b/core/standalone/src/main/resources/playground/actions/playground-run.js @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * 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. + */ + +var openwhisk = require('openwhisk'); + +// Deploys code as an action and optionally runs it. +// The input parameters are +// code -- the code to run +// saveOnly -- if present and true, the action is not run but only deployed +// web-export -- if present and true, the action is deployed as a web action (annotated with web-export=true). Implies saveOnly. +// params -- parameters to pass to the code when running it (ignored if saveOnly is present or implied) +// playgroundId -- the identity of the browser instance submitting the code (functions as a kind of user id but not enduring +// or authenticated). Becomes part of the name of the action. +// action -- the name of the action as assigned by the user or one of the default sample names; combines with playgroundId to form +// the action name as viewed by OpenWhisk +// runtime -- the whisk runtime ('kind') value to use in running or saving the action. +function main(outerParam) { + let t0 = new Date().getTime() + //console.log('outerParam: ', outerParam) + // Get parameters + let param = JSON.parse(outerParam['__ow_body']) + let saveOnly = param['saveOnly'] + let webExport = param['web-export'] + let code = param['code'] + let codeParams = param['params'] + let playgroundId = param['playgroundId'] + let action = param['actionName'] + let runtime = param['runtime'] + // Deploy the action. The action is left deployed after running it, which allows playground-fetch to fetch the code back + // for a later edit session. In a saveOnly or web-export scenario, the code is not even run after that. + let wsk = openwhisk({ignore_certs: outerParam.__ignore_certs}) // ignores self-signed certs, necessary in some deployments + let actionName = 'user' + playgroundId + '/' + action + let annotations = {"web-export": webExport ? true : false } + var deployParams = {name: actionName, action: code, kind: runtime, annotations: annotations} + return wsk.actions.update(deployParams).then(uresult => { + // Unless saveOnly, run the action once deployed. + let t1 = new Date().getTime() + console.log('made user action') + if (saveOnly || webExport) { + return { saved: true } + } else { + return wsk.actions.invoke({ actionName: actionName, blocking: true, params: codeParams }).then(aresult => { + // Return the result + let t2 = new Date().getTime() + console.log('aresult: ', aresult) + let response = aresult['response'] + let result = response['result'] + return { param: param, result: result, deployTime: t1 - t0, runTime: t2 - t1 } + }).catch(err => { + console.error('error invoking action', err) + return {error: err} + }) + } + }).catch(err => { + console.error('error creating action', err) + return {error: err} + }) +} diff --git a/core/standalone/src/main/resources/playground/actions/playground-userpackage.js b/core/standalone/src/main/resources/playground/actions/playground-userpackage.js new file mode 100644 index 0000000..6259b54 --- /dev/null +++ b/core/standalone/src/main/resources/playground/actions/playground-userpackage.js @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * 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. + */ + +var openwhisk = require('openwhisk'); + +// Returns the package structure for a given user, creating it if it doesn't exist. +// Used to initialize playground state when an existing user loads the playground page and also to begin the +// process with an empty package for a new user. +// This code also maintains a lastSession date as a package annotation. This denotes the last time +// this user opened the playground and can be used to expire the package. +function main(outerParam) { + let param = JSON.parse(outerParam['__ow_body']) + let playgroundId = param['playgroundId'] + let wsk = openwhisk({ignore_certs: outerParam.__ignore_certs}) // ignores self-signed certs, necessary in some deployments + let name = "user" + playgroundId + let ts = new Date().toISOString() + let tsAnnotation = { key: "lastSession", value: ts } + return wsk.packages.get(name).then(result => { + console.log('found existing package', result) + let annotations = result.annotations + annotations.push(tsAnnotation) + return wsk.packages.update({"name": name, "package": {annotations: annotations}}).then(_ => { + // Return original response, which has the old timestamp. Client does not use the timestamp in the response. + // The response from the update does not include the package list. + return result + }).catch(err => { + console.log("could not add lastSession annotation (proceeding)", err) + return result // even if not updated + }) + }).catch(err => { + console.log('package does not exist or other error') + if (err.statusCode === 404) { + // Simple not found error. Just create the package + return wsk.packages.create({"name": name, "package": { annotations: [ tsAnnotation ]}}).then(result => { + console.log('created package', result) + return result + }).catch(err => { + console.error('error creating package', err) + return { error: err } + }) + } else { + console.error('unrecoverable error retrieving package', err) + return { error: err } + } + }) +} diff --git a/core/standalone/src/main/resources/playground/ui/index.html b/core/standalone/src/main/resources/playground/ui/index.html new file mode 100644 index 0000000..0aaba42 --- /dev/null +++ b/core/standalone/src/main/resources/playground/ui/index.html @@ -0,0 +1,139 @@ +<!-- +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# 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. +# +--> + +<!DOCTYPE html> +<html> + +<head> +<meta charset="utf-8"/> +<title>Function Playground</title> + +<!-- for the ACE editor component --> +<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.4.2/ace.js" type="text/javascript" charset="utf-8"></script> + +<!-- for the Google material UI icons --> +<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"> + +<!-- begin - to enable panel resize --> +<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script> +<script src="https://rawgit.com/RickStrahl/jquery-resizable/master/src/jquery-resizable.js"></script> + +<script> +$(document).ready(function() { + $("#panel-left").resizable({ + handleSelector: ".splitter-vertical", + resizeHeight: false + }); +}); +</script> +<!-- end - to enable panel resize --> + +<!-- The js needs to go after jquery is loaded because it uses jquery to run the init block after DOM loading. + The css is placed next to it to make the inlining easier. + --> +<!--Start Inlining--> +<link rel="stylesheet" type="text/css" href="playground.css"> +<script src="playgroundFunctions.js"></script> +<!--End Inlining--> + +<!-- OpenWhisk and Function Playground icons (svgomg was used to compact) --> +<svg aria-hidden="true" focusable="false" style="display:none" xmlns="http://www.w3.org/2000/svg"> + <symbol id="svg-logo-icon" viewBox="0 0 32 32"> + <image xlink:href="https://openwhisk.apache.org/images/logo/apache-openwhisk-logo-only.png" height="32" width="32" /> + </symbol> + <symbol id="svg-logo-text" viewBox="0 0 195 32"> + <text x="0.259234" y="29.829633" fill="#808080" font-family="Helvetica, Arial, sans-serif" font-size="10.667px" font-weight="bold" stroke="#000000" stroke-width="0" xml:space="preserve">OpenWhisk</text> + <text transform="matrix(.89868 0 0 1 -.044817 0)" x="-0.480163" y="16.794799" fill="#cccccc" font-family="Helvetica, Arial, sans-serif" font-size="18.667px" font-weight="bold" stroke-width="1px" xml:space="preserve"> + <tspan x="-0.480163" y="16.794799" fill="#cccccc" font-family="Helvetica, Arial, sans-serif" font-size="18.667px" font-weight="bold">Function Playground</tspan> + </text> + </symbol> +</svg> + +</head> + +<body id="body" class="body-container"> + <div class="navbar"> + <div class="nav-item"> + <svg aria-hidden="true" focusable="false" class="logo-icon"><use xlink:href="#svg-logo-icon"/></svg> + <svg id="logo-text" aria-hidden="true" focusable="false" class="logo-text nav-right-spacer"><use xlink:href="#svg-logo-text"/></svg> + </div> + <div class="nav-item"> + <button id="run" class="nav-button" type="button" onclick="runClicked()"> + <i style="font-size:12pt !important;" class="material-icons icon-size">play_arrow</i>Run + </button> + </div> + <div class="nav-item"> + <button id="publish" class="nav-button nav-right-spacer" type="button" onclick="publishClicked()"> + <i class="material-icons icon-size icon-extra-margin">cloud_upload</i>Publish + </button> + </div> + <div class="nav-item"> + <select id="languageSelector" class="nav-select" onchange="languageChanged()"> + <option value="JavaScript" selected="selected">JavaScript</option> + <option value="Python">Python</option> + </select> + </div> + <div class="nav-item"> + <select id="actionSelector" class="nav-select" onchange="actionChanged()" select=""> + <option value="sampleJavaScript" selected="selected">sampleJavaScript</option> + <option value="--New Action--">--New Action--</option> + <option value="--Rename--">--Rename--</option> + </select> + <input id="nameInput" class="nav-input" onchange="processNewName()" type="text"> + </div> + <div class="nav-item-last"> + <button id="theme" class="nav-button" type="button" onclick="themeClicked()"> + <i class="material-icons icon-size icon-extra-margin">web</i> + <span id="themeName">Light</span> + </button> + </div> + </div> + <div class="central-container"> + <div id="panel-left" class="panel-left"> + <div class="panel-header"> + <i style="margin-left: 4px;" class="material-icons icon-size icon-extra-margin">cloud_queue</i> + URL: <span id="urlText">[editable, private]</span> + </div> + <div id="editor" ace-editor [(text)]="text"></div> + </div> + <div class="splitter-vertical"></div> + <div class="panel-right"> + <div class="panel-header"> + <i class="material-icons icon-size icon-extra-margin">input</i>INPUT PARAMETERS + </div> + <div class="panel-right-top"> + <textarea id="input" spellcheck="false" class="panel-right-input">{ "name" : "openwhisk" }</textarea> + </div> + <div class="panel-header"> + <i class="material-icons icon-size icon-extra-margin">access_time</i>EXECUTION TIME + </div> + <div class="panel-right-mid"> + <div id="timingText" class="panel-right-box"></div> + </div> + <div class="panel-header"> + <i class="material-icons icon-size icon-extra-margin">done</i>OUTPUT + </div> + <div class="panel-right-bottom"> + <div id="resultText" class="panel-right-box"></div> + </div> + </div> + </div> +</body> + +</html> diff --git a/core/standalone/src/main/resources/playground/ui/playground.css b/core/standalone/src/main/resources/playground/ui/playground.css new file mode 100644 index 0000000..8df6370 --- /dev/null +++ b/core/standalone/src/main/resources/playground/ui/playground.css @@ -0,0 +1,228 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * 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. + */ + +html, body { + height: 100%; + margin: 0px; +} + +* { + box-sizing: border-box; /* include the border and padding in width / height calcuations */ +} + +#editor { + flex: 1 1 auto; +} + +.body-container { + font-family: Arial, Helvetica, sans-serif; + margin: 0; + display: flex; + flex-direction: column; + background-color: #26282C; +} + +.navbar { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + align-items: center; + margin-top: 8px; + margin-bottom: 8px; +} + +.nav-item { + display: flex; + flex: 0 0 auto; + border-right: 12px solid transparent; +} + +.nav-item-center { + display: flex; + align-items: center; +} + +.nav-item-last { + flex: 1 0 auto; + text-align:right; + margin-right: 4px; +} + +.logo-icon { + width: 32px; + height: 32px; + margin-left: 8px; + margin-right: 6px; +} + +.logo-text { + width: 195px; + height: 32px; +} + +.nav-button { + padding-top: 8px; + padding-bottom: 8px; + padding-left: 15px; + padding-right: 15px; + border: 2px solid #424446; + border-radius: 8px; + background-color: #26282C; + color: white; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 12pt; + cursor: pointer; +} + +.icon-size { + font-size:12pt !important; + position: relative; + top: 2px; + margin-right: 4px; +} + +.icon-extra-margin { + margin-right: 8px; +} + +.nav-select { + color: white; + border: 2px solid #424446; + border-radius: 8px; + background-color: #26282C; + padding-top: 8px; + padding-bottom: 8px; + padding-left: 20px; + padding-right: 20px; + text-align: center; + text-decoration: none; + display: inline-block; + font-size: 10pt; + cursor: pointer; +} + +.nav-input { + padding-top: 5px; + padding-bottom: 5px; + color: white; + background-color: black; + margin-left: 6px; + display: none; +} + +.nav-right-spacer { + margin-right: 30px; +} + +.nav-label { + color:#C0C0C0; +} + +.central-container { + flex: 1 1 auto; + display: flex; /* make this a flex container */ + /* by default flex-direction is row */ + /* by default, elements will stretch vertically to the full height of the container */ +} + +.panel-left { + width: 65%; + flex: 0 1 auto; + min-height: 160px; + min-width: 160px; + display: flex; + flex-direction: column; +} + +.splitter-vertical { + flex: 0 0 auto; + width: 12px; + min-width: 12px; + cursor: col-resize; + background-color: #26282C; +} + +.panel-right { + flex: 1 1 auto; + min-width: 160px; + display: flex; + flex-direction: column; +} + +.panel-right-box { + flex: 1 1 auto; + font-family: "Courier New", Courier, monospace; + font-size: 12pt; + padding: 4px; + color: white; + background-color: black; + margin: 0px; +} + +.panel-right-input { + flex: 1 1 auto; + font-family: "Courier New", Courier, monospace; + font-size: 12pt; + padding: 4px; + color: white; + background-color: black; + border:solid 1px grey; + margin: 0px; + resize:none; +} + +.panel-header { + padding-top: 4px; + padding-bottom: 4px; + font-size: 10pt; + font-weight: bold; + background-color: #202020; + color: #9098A0; +} + +.panel-right-top { + flex: 3 1 auto; /* grow(relative size) shrink basis */ + display: flex; + flex-direction: column; +} + +.panel-right-mid { + flex: 1 1 auto; + display: flex; + flex-direction: column; +} + +.panel-right-bottom { + flex: 3 1 auto; + display: flex; + flex-direction: column; +} + +/* for smaller screens - get rid of text logo and shrink button margins */ +@media screen and (min-width: 0px) and (max-width: 1000px) { + #logo-text { + display: none; + } + .nav-button { + padding-left: 4px; + padding-right: 4px; + margin-left: 0px; + margin-right: 0px; + } +} diff --git a/core/standalone/src/main/resources/playground/ui/playgroundFunctions.js b/core/standalone/src/main/resources/playground/ui/playgroundFunctions.js new file mode 100644 index 0000000..35c33ab --- /dev/null +++ b/core/standalone/src/main/resources/playground/ui/playgroundFunctions.js @@ -0,0 +1,799 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * 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. + */ + +$(document).ready(function(){ + // This is the location of the supporting API + // The host value may get replaced in PlaygroundLauncher to a specific host + window.APIHOST=window.location ? window.location.origin : '' + + // To install in a different namespace, change this value + window.PLAYGROUND='whisk.system' + + // Keys for cookies + window.colorKey = 'colorId' + window.languageKey = 'language' + window.playgroundIdKey = 'playgroundId' + window.actionKey = 'actionName' + + // Initialize GUI elements + window.editor = initializeEditor() + window.colorSetting = initializeColor() + + // The language table (a JS object acting as an associative array) + // Maps from language symbol to structure (1) repeating the symbol as 'name', (2) the editor mode, + // (3) the whisk runtime 'kind' to use for the language, and (4) the starting example code for that language. + window.languages = { + JavaScript: { + name: "JavaScript", + editMode: "ace/mode/javascript", + kind: "nodejs:default", + example:`function main(args) { + let name = args.name || 'stranger' + let greeting = 'Hello ' + name + '!' + console.log(greeting) + return {"body": greeting} +}` + }, + + Python: { + name: "Python", + editMode: "ace/mode/python", + kind: "python:default", + example: `def main(args): + if 'name' in args: + name = args['name'] + else: + name = "stranger" + greeting = "Hello " + name + "!" + print(greeting) + return {"body": greeting} +` + }, + + Swift: { + name: "Swift", + editMode: "ace/mode/swift", + kind: "swift:default", + example:`func main(args: [String:Any]) -> [String:Any] { + if let name = args["name"] as? String { + let greeting = "Hello \\(name)!" + print(greeting) + return [ "body" : greeting ] + } else { + let greeting = "Hello stranger!" + print(greeting) + return [ "body" : greeting ] + } +}` + }, + + Go: { + name: 'Go', + editMode: 'ace/mode/go', + kind: `go:default`, + example: `package main + +func Main(args map[string]interface{}) map[string]interface{} { + name, ok := args["name"].(string) + if !ok { + name = "stranger" + } + msg := make(map[string]interface{}) + msg["body"] = "Hello, " + name + "!" + return msg +}` + }, + + PHP: { + name: 'PHP', + editMode: 'ace/mode/php', + kind: `php:default`, + example: `<?php +function main(array $args) : array { + $name = $args["name"] ?? "stranger"; + $greeting = "Hello $name!"; + echo $greeting; + return ["body" => $greeting]; +}` + } + } + + // Other initialization + window.playgroundId = initializePlaygroundId() + window.EditSession = require("ace/edit_session").EditSession // Per ACE doc + window.activeSessions = [] // Contains triples {actionName, EditSession, webbiness} for actions visited in this browser session + window.editorContentsChanged = false // A 'dirty' flag consulted as part of autosave logic + window.language = initializeLanguage() // Requires languages table to exist + window.actionList = [] // Populated asynchronously by initializeUserPackage. Contains pairs {actionName, actionKind} + window.currentAction = null // Name of the action displayed in the editor and actionSelector. Initialized by initializeActionSelector. + window.entryFollowup = null // Function to execute when name entry completes (for renameAction and startNewAction). Null except during name entry. + document.onkeydown = detectEscapeKey // Examine key presses to see if they indicate a desire to cancel name input mode + + initializeUserPackage().then(initializeActionSelector).then(startAutosave) +}); + +// Start autosave polling +function startAutosave() { + window.setInterval(maybeSave, 15 * 1000) +} + +// Initialize the playgroundId +function initializePlaygroundId() { + let playgroundId = getCookie(window.playgroundIdKey) + if (playgroundId == "") { + playgroundId = (new Date().getTime()) % 1000000 + console.log('New playgroundId: ', playgroundId) + } else { + console.log('Existing playgroundId: ', playgroundId) + } + setCookie(window.playgroundIdKey, playgroundId) // regardless of whether it was set before; refreshes expiration + return playgroundId +} + +// Initialize the actionList to reflect the user's package structure stored on the server, perhaps creating a new package for a new user +// with no actions. Returns a promise. Initialization code dependent on the action list should be in the promise chain. +function initializeUserPackage() { + console.log("Initializing user", window.playgroundId) + return makeOpenWhiskRequest('playground-userpackage.json', { playgroundId: window.playgroundId }).then(result => { + console.log("userpackage raw response:", result) + let userPackage = JSON.parse(result) + if (userPackage && userPackage.actions && Array.isArray(userPackage.actions)) { + for (action of userPackage.actions) { + let kind = getAnnotation(action, "exec") + window.actionList.push({ name: action.name, kind: kind } ) + } + } + return window.actionList // For definiteness, to carry on the promise chain. actionList is also global. + }).catch(err => { + console.error("Error getting user package.", err) + }) +} + +// Initialize the actions in the action selector and select one (also assigning currentAction) based on a user cookie. +// Assumes the 'language' global variable is initialized. Only actions in that language are listed. +// If the cookie is not set, or it denotes an action for a non-selected language, we arbitrarily select the first action +// of the selected language and also put it into the cookie. End by calling 'imposeAction' to initialize the editor session +// for the action, returning the result thereof which is a Promise. Editor code may be filled in asynchronously. +function initializeActionSelector(actionList) { + const selector = elem("actionSelector") + // Determine the list of action names that should be used. + // Start with those that can be read from the user's package (pre-existing). + // Add a sample for the current language iff the user has no actions for that language. + let actions = actionList.filter(action => matchesLanguage(action)) + console.log("read", actions.length, "actions from user package") + if (actions.length == 0) { + console.log("adding sample for", window.language.name) + actions.push({ name: "sample" + window.language.name, kind: window.language.kind} ) + } + // Place the action names in the selector's options + selector.options.length = 0 + for (action of actions) { + console.log("adding action to selector", action.name) + selector.options[selector.options.length] = new Option(action.name, action.name) + } + // Add other capabilities to the action list. + // Add --New Action-- iff the user is within his quota. Add --Delete-- iff there is more than one action. + // Add --Rename-- unconditionally. However, --Rename-- and --Delete-- are also enabled/disabled as part of + // the imposeWebbiness function (when the action isn't editable it seems illogical that you can rename and delete it) + if (actions.length < 10) { // quota is arbitrary + let other = "--New Action--" + console.log("adding capability", other) + selector.options[selector.options.length] = new Option(other, other) + } + if (actions.length > 1) { + let other = "--Delete--" + console.log("adding capability", other) + selector.options[selector.options.length] = new Option(other, other) + } + let other = "--Rename--" + console.log("adding capability", other) + selector.options[selector.options.length] = new Option(other, other) + // Now select the action according to the user's cookie (if present and applicable) else arbitrarily choose + // the first (or only) list element. The list has at least one action at this point. + const cookieVal = getCookie(window.actionKey) + window.currentAction = (cookieVal != "" && matchesLanguageByName(cookieVal)) ? cookieVal : actions[0].name + selector.value = window.currentAction + setCookie(window.actionKey, window.currentAction) + return imposeAction(window.currentAction) +} + +// Initialize the editor +function initializeEditor() { + editor = ace.edit("editor"); + editor.setTheme("ace/theme/monokai"); + editor.setShowPrintMargin(false); + elem('editor').style.fontSize='12pt'; + return editor +} + +// Initialize the color theme +function initializeColor() { + let color = getCookie(window.colorKey) + if (color == "") { + color = "dark" + } + imposeColor(color) + return color +} + +// Initialize the language +function initializeLanguage() { + // First initialize the options of the language selector from the language table + var selector = elem("languageSelector") + selector.options.length = 0 // probably unneeded but just in case this gets done more than once + for (member in window.languages) { + let languageName = window.languages[member].name + console.log("Adding language " + languageName + " to selector") + selector.options[selector.options.length] = new Option(languageName, languageName) + } + console.log("Selector now has " + selector.options.length + " choices") + // Retrieve the language choice from the cookie or set to default + var language = window.languages.JavaScript // Default + let languageName = getCookie(window.languageKey) + if (languageName != "") { + language = window.languages[languageName] + console.log("Language " + languageName + " was retrieved from the cookie") + } else { + console.log("Language defaulted to " + language.name) + setCookie(window.languageKey, language.name) + } + // Set the language into the selector + selector.value = language.name + return language +} + +// Examine key presses looking for esc +function detectEscapeKey(evt) { + evt = evt || window.event; + var isEscape = false; + if ("key" in evt) { + isEscape = (evt.key == "Escape" || evt.key == "Esc"); + } else { + isEscape = (evt.keyCode == 27); + } + if (isEscape && window.entryFollowup != null) { + console.log("Cancel detected via esc key") + endNameEntry() + } +} + +// Test whether an action (from the action list) matches the current language (the action {name, kind} pair is the argument) +function matchesLanguage(action) { + console.log("matching", action.name, "for kind", window.language.kind) + let matched = action.kind === window.language.kind + console.log("matched", matched) + return matched +} + +// Test whether an action matches the current language (language name given) +// Answers false if the action isn't found. +function matchesLanguageByName(actionName) { + let action = getAction(actionName) + return action ? matchesLanguage(action) : false +} + +// Lookup an action by name in the actionList. +function getAction(actionName) { + let index = indexOfAction(actionName) + if (index < 0) { + return undefined + } + return window.actionList[index] +} + +// Find the index of an action name in the action list +function indexOfAction(actionName) { + for (i = 0; i < window.actionList.length; i++) { + if (window.actionList[i].name == actionName) { + return i + } + } + return -1 +} + +// Change the language in response to a change in the language selector +function languageChanged() { + const newName = elem("languageSelector").value + if (window.language.name == newName) { + // Avoid disruption if not really changed (not sure if this can actually happen but just in case) + return + } + maybeSave() // Before language change: saves previous contents. Save is asynchronous but racing with the + // following is ok because the asynchronous part of save follows the network send. Once the network send + // has occurred, the local state is free to change (if the save fails there is no real recovery). + // Change the language global variable and reset the cookie + window.language = window.languages[newName] + setCookie(window.languageKey, newName) + // Redo action selector initialization. This returns a promise but we need not hook it because + // we are running in response to a UI event and things can settle in any order. + initializeActionSelector(window.actionList) +} + +// Change the selected action or process the special options (new/rename/delete) that are handled via that selector +function actionChanged() { + let newAction = elem("actionSelector").value + if (newAction == window.currentAction) { + return + } else if (newAction.startsWith("--")) { + switch (newAction.charAt(2)) { + case 'N': + nameEntry(completeNewAction) + break + case 'R': + nameEntry(completeRename) + break + case 'D': + deleteAction() + break + } + } else { + maybeSave() // Save previous contents. Save is asynchronous but racing with the following is ok because the + // asynchronous part of save follows the network send. Once the network send has occurred, the local state is + // free to change (if the save fails there is no real recovery). + window.currentAction = newAction + setCookie(window.actionKey, window.currentAction) + imposeAction(window.currentAction) + } +} + +// Start a name entry sequence (for rename or new action) +function nameEntry(followup) { + window.entryFollowup = followup + const selector = elem("actionSelector") + const entry = elem("nameInput") + selector.style.display = "none" + entry.style.display = "block" + entry.value = "" + entry.focus() +} + +// End the name entry phase, either after processing a valid name or after cancellation +function endNameEntry() { + window.entryFollowup = null + const selector = elem("actionSelector") + const entry = elem("nameInput") + selector.style.display = "block" + entry.style.display = "none" + console.log("Name entry ending. Setting selector to the correct action", window.currentAction) + selector.value = window.currentAction +} + +// Followup after user enters the name of a new action +function completeNewAction(newName) { + window.actionList.push({ name: newName, kind: window.language.kind }) + window.currentAction = newName + endNameEntry() + setCookie(window.actionKey, window.currentAction) + initializeActionSelector(window.actionList) +} + +// Followup after user renames an existing action +function completeRename(newName) { + let action = getAction(window.currentAction) + if (action) { + let oldName = window.currentAction + // Rename locally + action.name = newName + window.currentAction = newName + // Resave under the new name, delete old copy on success + let web = elem("publish").value != 'Publish' // The presence of a Publish button means locally editable. + save(web).then(_ => deleteRemote(oldName)) + // Restabilize action selector and editor + setCookie(window.actionKey, newName) + initializeActionSelector(window.actionList) + } else { + // Should not happen + console.log(window.currentAction, "not found in action list", window.actionList) + } + endNameEntry() +} + +// Delete the current action +function deleteAction() { + // Get index of current action in action list + let index = indexOfAction(window.currentAction) + if (index < 0) { + // Should not happen + console.log("current action not found in action list", window.currentAction) + endNameEntry() + return + } + // Remove locally + window.actionList.splice(index, 1) + // Remove remotely + deleteRemote(window.currentAction) + // Restabilize the action selector, window.currentAction, and current cookie based on what's left in the list + initializeActionSelector(window.actionList) + // Don't end name entry until a new currentAction has been nominated + endNameEntry() +} + +// Delete the remote copy of an action if present. If absent, no error is indicated except on the console. Local processing +// proceeds in either case. +function deleteRemote(actionName) { + return makeOpenWhiskRequest('playground-delete.json', { playgroundId: window.playgroundId, actionName: actionName }).then(result => { + console.log("deleted", actionName) + console.log("full result", result) + }).catch(err => { + console.log("not deleted (perhaps doesn't exist)", actionName) + console.log("full error object", err) + }) +} + +// Fetch code from a deployed action. Returns a promise, for chaining purposes, but both the resolve and the reject path simply provide the +// action name. Code, if retrieved, is placed directly in the editor. Failure to retrieve code is tolerated as a sometimes-expected condition. +function getCode(actionName) { + return makeOpenWhiskRequest('playground-fetch.json', { playgroundId: window.playgroundId, actionName: actionName }).then(result => { + let response = JSON.parse(result) + console.log("getCode response", response) + if ('exec' in response) { + console.log("Code retrieved from deployed action") + let exec = response.exec + let code = exec.code + window.editor.setValue(code) + editorContentsChanged = false // Setting the editor contents will fire the change event but there is no need to re-save. + } else { + console.log("No deployed action, no code retrieved") + } + let webbiness = isWeb(response) + imposeWebbiness(webbiness) + return actionName + }).catch(err => { + console.error("Error retrieving code", err) + imposeWebbiness(false) + return actionName + }) +} + +// Determine if an action being fetched is a web action by examining its annotations. The argument is the response to a wsk get operation on the +// action. If there are no annotations in the response, the answer is false. +function isWeb(response) { + return getAnnotation(response, "web-export") === true // ensures boolean +} + +// Get an annotation from an object that may or may not have an 'annotations' member (as whisk responses generally do). Returns undefined if +// (1) The 'annotations' member is absent. (2) The 'annotations' member's members are not key value pairs. (3) The 'annotations' member does not +// contain a key value pair matching the requested annotation. On a match, returns the value of the annotation. +function getAnnotation(object, name) { + if ('annotations' in object && Array.isArray(object.annotations)) { + for (i = 0; i < object.annotations.length; i++) { + let member = object.annotations[i] + if (member.key === name) { // false if no key + return member.value // undefined if no value + } + } + } + return undefined +} + +// Impose the local conventions for a currently published (web) action (argument is true) or a private (non-web) action (argument is false) +function imposeWebbiness(isWeb) { + console.log("Webbiness being set to " + isWeb) + let button = elem("publish") + let urlText = elem("urlText") + let actionSelector = elem("actionSelector") + let mutableOptions = [] // For some reason, select.options doesn't support 'filter' (backlevel JS?) + for (i = 0; i < actionSelector.options.length; i++) { + let option = actionSelector.options[i] + if (option.value == "--Rename--" || option.value == "--Delete--") { + mutableOptions.push(option) + } + } + if (isWeb) { + button.innerHTML = '<i class="material-icons icon-size icon-extra-margin">cloud_download</i>Edit' + setReadOnly(true) + const url = window.APIHOST + '/api/v1/web/' + window.PLAYGROUND + '/user' + window.playgroundId + '/' + window.currentAction + urlText.innerHTML = "Readonly, public at <a style='text-decoration:none;color:#488' href='" + url + "'>" + url + "</a>" + for (opt of mutableOptions) { + opt.disabled = true + } + } else { + button.innerHTML = '<i class="material-icons icon-size icon-extra-margin">cloud_upload</i>Publish' + setReadOnly(false) + urlText.innerHTML = "[ editable, private ]" + for (opt of mutableOptions) { + opt.disabled = false + } + } + // Record the webbiness in the session record + getSession(window.currentAction).isWeb = isWeb + // Since this may be called as part of publish or edit, remove focus from the button + button.blur() +} + +// Sets the readonly properties of the editor on or off. A thorough job, including a proper visual indication, +// requires taggling several properties +function setReadOnly(on) { + window.editor.setOptions({readOnly: on, highlightActiveLine: !on, highlightGutterLine: !on}); + window.editor.renderer.$cursorLayer.element.style.display = on ? "none" : "" + if (on) { + window.editor.clearSelection() + } +} + +// Parse out a specific cookie by key +function getCookie(key) { + let keyPrefix = key + "="; + let cookie = decodeURIComponent(document.cookie) + let parts = cookie.split(';'); + for(var i = 0; i <parts.length; i++) { + let p = parts[i].trim() + if (p.startsWith(keyPrefix)) { + return p.substring(keyPrefix.length) + } + } + return "" +} + +// Set a specific cookie by key (note that the document.cookie field has asymmetric behavior: on reference you get all the cookies but +// on setting you provide a single cookie and it is added to the list) +function setCookie(key, value) { + let age = String(60 * 60 * 24 * 7) // one week: kind of arbitrary + document.cookie = key + "=" + String(value) + ";max-age=" + age +} + +// Respond to click of the theme button +function themeClicked() { + window.colorSetting = (window.colorSetting == "dark") ? "light" : "dark" + imposeColor(window.colorSetting) +} + +// Impose a color scheme. Called at startup and when theme is clicked +function imposeColor(color) { + let $white = 'white'; + let $black = 'black' + $reverseTheme = 'Light'; + if (color == 'light') { + $white = 'black'; + $black = 'white'; + $reverseTheme = 'Dark'; + editor.setTheme('ace/theme/xcode'); + } else { + editor.setTheme('ace/theme/terminal'); + } + elem('themeName').textContent = $reverseTheme; + elem('input').style.color = $white; + elem('input').style.background = $black; + elem('timingText').style.color = $white; + elem('timingText').style.background = $black; + elem('resultText').style.color = $white; + elem('resultText').style.background = $black; + setCookie(window.colorKey, color) +} + +// Get the active session for a given action if present +function getSession(actionName) { + for (i in window.activeSessions) { + let candidate = window.activeSessions[i] + if (candidate.name == actionName) { + return candidate + } + } + return null +} + +// Impose a specific action on the editor. Each action that the user has visited or created gets its own session and at most one +// session can exist for each action. Returns a Promise, which is either the result of calling getCode (truly asynchronous) +// or a vacuous promise that simply continues the resolve chain (if an existing session was used). +// Assumes that the 'language' global variable is correctly initialized for the action. +function imposeAction(actionName) { + // Check whether we already have an ACE EditSession going for the action. If so, just switch to it. + let candidate = getSession(actionName) + if (candidate != null) { + console.log("Used existing session for action " + actionName) + window.editor.setSession(candidate.session) + imposeWebbiness(candidate.isWeb) + return Promise.resolve(actionName) + } + // If we are making a new session, we initialize it here with example code. This may be overwritten by saved + // code. However, if there is no saved code, getCode will do nothing but will resolve to the action name rather + // than rejecting. This will leave the sample code in place + let session = new window.EditSession(language.example) + session.setMode(language.editMode) + session.on("change", codeChanged) + window.activeSessions[window.activeSessions.count] = { name: actionName, session: session, isWeb: false } + window.editor.setSession(session) + return getCode(actionName) +} + +// Called when code changes +function codeChanged(delta) { + window.editorContentsChanged = true +} + +// Open a request session to nimbella +function makeOpenWhiskRequest(actionName, args) { + return new Promise(function (resolve, reject) { + const xhr = new XMLHttpRequest() + const url = window.APIHOST + '/api/v1/web/' + window.PLAYGROUND + '/default/' + actionName + xhr.open('POST', url) + xhr.onload = function () { + if (this.status >= 200 && this.status < 300) { + resolve(xhr.responseText) + } else { + console.log("calling reject with status", this.status) + reject({status: this.status, statusText: xhr.statusText}) + } + } + xhr.onerror = function () { + console.log("calling reject with network error") + reject({statusText: "Network error"}) + } + xhr.send(JSON.stringify(args)) + }) +} + +// Conditionally save the code from the current editor without actually running it (and only if contents of the editor +// have changed since initialization or last save). Invoked periodically ("autosave"). +function maybeSave() { + if (window.editorContentsChanged) { + save(false) + } +} + +// Save the code without running it, either as a standard action or a webaction. Called for autosaving iff editor contents changed +// and when imposing webbiness or non-webbiness. +function save(web) { + elem("run").disabled = true // Suppress run while saving + console.log("Saving editor contents") + let contents = window.editor.getValue() + let arg = { code : contents, playgroundId: window.playgroundId, actionName: window.currentAction, runtime: window.language.kind } + if (web) { + arg['web-export'] = true + } else { + arg['saveOnly'] = true + } + return makeOpenWhiskRequest('playground-run.json', arg).then(result => { + window.editorContentsChanged = false // regardless of error. We don't want to keep trying if it isn't going to work. + elem("run").disabled = false // Save is over, run is ok + let response = JSON.parse(result) + if ("error" in response) { // this is error as defined by the remote action, not xhr + let error = response.error + console.log("Error response: " + error) + } else if ("saved" in response) { // success + console.log("Saved") + } else { + console.log("Unexpected", response) + } + }).catch(err => { + console.error("Error performing save action", err) + }) +} + +// Set the contents of a text display area +function setAreaContents(areaID, contents, error) { + let innerHTML = error ? "<p style=\"color:red\">" + contents + "</p>" : contents + elem(areaID).innerHTML = innerHTML +} + +// Respond to click of the publish/retract button +function publishClicked() { + let button = elem("publish") + let session = getSession(currentAction) + let newWebbiness = !session.isWeb + save(newWebbiness).then(imposeWebbiness(newWebbiness)).catch(button.blur()) +} + +// Process a new name entered in the nameInput area +function processNewName() { + if (window.entryFollowup == null) { + // Can happen because of cancelling with escape key after some data was entered + console.log("Not processing new name due to previous cancellation") + return + } + let newName = elem("nameInput").value + if (newName.trim() == "") { + // Cancel request + console.log("Cancel detected as empty name") + endNameEntry() + return + } + console.log("Processing new name", newName) + if (isInvalidActionName(newName)) { + postNameError("Invalid name") + } else if (isConflictingActionName(newName)) { + postNameError("Conflicting Name") + } else { + console.log("Valid new name", newName) + window.entryFollowup(newName) // leave remainder to the individual followups + } +} + +// Check for valid syntax of action name. Returns true IF INVALID! Rule: +// The first character must be an alphanumeric character, or an underscore. +// The subsequent characters can be alphanumeric, spaces, or any of the following values: _, @, ., -. +// The last character can't be a space. +function isInvalidActionName(newName) { + if (newName.trim() !== newName) { + return true + } + let valid = /^[0-9a-zA-Z_][ [email protected]]*$/ + return !valid.test(newName) +} + +// Check for conflict between a proposed action name and any existing action in the same package +function isConflictingActionName(newName) { + for (action of window.actionList) { + if (action.name == newName) { + return true + } + } + return false +} + +// Post an error over the name entry area +function postNameError(msg) { + console.log("Posting name error", msg) + let nameInput = elem("nameInput") + let savedValue = nameInput.value + let savedColor = nameInput.style.color + nameInput.style.color = "red" + nameInput.value = msg + setTimeout(function() { + nameInput.value = savedValue + nameInput.style.color = savedColor + }, 2000) +} + +// abbreviation for document.getElementById +function elem(name) { + return document.getElementById(name) +} + +// Respond to click of the run button +function runClicked() { + window.editorContentsChanged = false // don't permit save to run in parallel + let contents = window.editor.getValue() + console.log("Contents: ", contents) + setAreaContents("resultText", "Running...") + let t0 = new Date().getTime() + let inputStr = elem("input").value + let params = JSON.parse(inputStr) + let arg = { code : contents, params: params, playgroundId: window.playgroundId, actionName: window.currentAction, runtime: window.language.kind } + return makeOpenWhiskRequest('playground-run.json', arg).then(result => { + let elapsed = new Date().getTime() - t0 + let response = JSON.parse(result) + if ("error" in response) { + let msg = response.error.response.result.error // seems the more readable form of the error is buried here + let inx = msg.indexOf("\n") + let usermsg = inx > 0 ? msg.substring(0, inx) : msg + console.log("Error response: " + msg) + setAreaContents("resultText", usermsg, true) + setAreaContents("timingText", "", false) + } else { + console.log('response: ', response) + console.log('elapsed: ', elapsed) + let result = response['result'] + let deploy = response['deployTime'] + let exec = response['runTime'] + let network = elapsed - (deploy + exec) + + if (result.body && result.headers && result.headers['content-type'] == 'image/jpeg') { + setAreaContents("resultText", '<img src="data:image/png;base64, ' + result.body + '">', false) + } else { + setAreaContents("resultText", JSON.stringify(result, null, 4), false) + } + + let timingStr = "Network: " + network + " ms<br>Deploy: " + deploy + " ms<br>Exec: " + exec + " ms" + setAreaContents("timingText", timingStr, false) + } + }).catch(err => { + console.log("Error contacting service", err) + setAreaContents("resultText", "Error contacting service, status = " + err.status, true) + setAreaContents("timingText", "", false) + }); +}
