diff --git a/ccp/js/executionhistorycontroller.js b/ccp/js/executionhistorycontroller.js
new file mode 100644
index 0000000..534bc0d
--- /dev/null
+++ b/ccp/js/executionhistorycontroller.js
@@ -0,0 +1,261 @@
+class CCPExecutionHistory extends HTMLElement {
+
+ #boot = null;
+ #rootdoc = null;
+ #serviceurl = null;
+ #broadcasturl = null;
+ #executions = [];
+ #socket = null;
+
+ constructor(){
+ super()
+ this.#boot = document.querySelector("d4s-boot-2")
+ this.#rootdoc = this.attachShadow({ "mode" : "open"})
+ this.#serviceurl = this.getAttribute("serviceurl")
+ this.#broadcasturl = this.getAttribute("broadcasturl")
+ if(!this.#broadcasturl){
+ this.#broadcasturl = this.#serviceurl.replace(/^http/, "ws")
+ }
+ this.#broadcasturl = this.#broadcasturl + "/ws/notification"
+ this.connectNewExecution()
+ this.connectBroadcast()
+ }
+
+ connectedCallback(){
+ this.#rootdoc.innerHTML = this.render()
+ this.refreshExecutions()
+ }
+
+ render(){
+ return `
+
+
+
+ -
+
+
+
+
+
+
+
+
+ :
+
+
+
+
+
+
+
+
+
+ `
+ }
+
+ refreshExecution(id){
+ this.#boot.secureFetch(`${this.#serviceurl}/executions?id=${id}`).then(reply =>{
+ if(reply.status === 200) return reply.json();
+ else throw `Unable to load execution ${id}`
+ }).then(data=>{
+ const exec = data[0]
+ for(var i=0; i < this.#executions.length; i++){
+ if(this.#executions[i].id == exec.id){
+ this.#executions[i] = exec
+ break
+ }
+ if(i === this.#executions.length){
+ this.#executions = data.concat(this.#executions)
+ }
+ }
+ BSS.apply(this.execution_list_bss, this.#rootdoc)
+ }).catch(err=>{ console.error(err)})
+ }
+
+ deleteExecution(id){
+ this.#boot.secureFetch(`${this.#serviceurl}/executions/${id}`, { method: "DELETE"}).then(reply =>{
+ if(reply.status === 200) this.refreshExecutions();
+ else throw `Unable to delete execution ${id}`
+ }).catch(err=>{ console.error(err)})
+ }
+
+ refreshExecutions(){
+ this.#boot.secureFetch(`${this.#serviceurl}/executions`).then(reply =>{
+ if(reply.status === 200) return reply.json();
+ else throw "Unable to load executions"
+ }).then(data=>{
+ this.#executions = data
+ BSS.apply(this.execution_list_bss, this.#rootdoc)
+ }).catch(err=>{ console.error(err)})
+ }
+
+ connectNewExecution(){
+ document.addEventListener("newexecution", ev=>{
+ this.#executions = [ {id: ev.detail } ].concat(this.#executions)
+ })
+ }
+
+ connectBroadcast(){
+ this.#socket = new WebSocket(this.#broadcasturl + "/executions");
+ this.#socket.onmessage = event=>{
+ const data = JSON.parse(event.data)
+ let exec = this.#executions.filter(e=>e.id === data.jobID)[0]
+ if(exec){
+ this.refreshExecution(exec.id)
+ }
+ }
+ window.setInterval( ()=>this.#socket.send("ping"), 30000)
+ }
+
+ download(url, name) {
+ this.#boot.secureFetch(url).then(reply => {
+ if (reply.status !== 200) {
+ throw "Unable to download"
+ }
+ return reply.blob()
+
+ }).then(blob => {
+ const objectURL = URL.createObjectURL(blob)
+ var tmplnk = document.createElement("a")
+ tmplnk.download = name
+ tmplnk.href = objectURL
+ //tmplnk.target="_blank"
+ document.body.appendChild(tmplnk)
+ tmplnk.click()
+ document.body.removeChild(tmplnk)
+
+ }).catch(err => console.error(err))
+ }
+
+ execution_list_bss = {
+ template : "#EXECUTIOM_LIST_TEMPLATE",
+ target : "ol[name=ccp_execution_list]",
+ in : ()=>{ return {executions : this.#executions} },
+ recurse:[
+ {
+ target : "li",
+ in : (e,d)=>d.executions,
+ apply: (e,d,i)=>e.setAttribute("data-index", i),
+ on_click: ev=>{
+ if(ev.target.getAttribute("name") === "delete"){
+ if(window.confirm("Confirm deletion of this execution?")){
+ const index = Number(ev.target.getAttribute("data-index"))
+ this.deleteExecution(this.#executions[index].id)
+ }
+ }
+ },
+ recurse : [
+ {
+ target : "details",
+ apply : (e,d)=>{
+ e.alt = e.title = d.id
+ if(sessionStorage.getItem(d.id) === "open") e.open = "open";
+ else e.removeAttribute("open");
+ },
+ on_toggle : ev=>{
+ if(ev.target.open){
+ sessionStorage.setItem(ev.currentTarget.alt, 'open')
+ }else{
+ sessionStorage.removeItem(ev.currentTarget.alt)
+ }
+ }
+ },
+ {
+ target : "span[name=runtime]",
+ apply : (e,d)=>{
+ e.classList.add(d.infrastructuretype)
+ e.title = e.alt = `${d.runtime} (image: ${d.runtimeimage}) on ${d.infrastructure}`
+ e.textContent = d.runtime
+ }
+ },
+ {
+ target : "h5",
+ apply : (e,d)=>{
+ e.textContent = `${d.method} v. ${d.methodversion}`
+ }
+ },
+ {
+ target : "span[name=updated]",
+ apply : (e,d)=>{
+ const dt = new Date(d.updated)
+ e.textContent = `Last update ${dt.toLocaleDateString()} @ ${dt.toLocaleTimeString()}`
+ }
+ },
+ {
+ target : "span[name=status]",
+ apply : (e,d)=>{
+ if(d.status){
+ const status = d.status
+ e.textContent = status
+ if (status === "running") e.classList.add("badge-primary");
+ else if (status === "successful") e.classList.add("badge-success");
+ else if (status === "failure") e.classList.add("badge-danger");
+ else e.classList.add("badge-secondary");
+ }
+ }
+ },
+ {
+ target : "span[name=message]",
+ apply : (e,d)=>{
+ if(d.message){
+ e.textContent = d.message
+ }
+ }
+ },
+ {
+ target : "ul",
+ recurse : [
+ {
+ target : "li",
+ "in" : (e,d)=>{
+ return d.resources.map(l=>{
+ return { href : this.#serviceurl + "/executions/" + d.id + "/" + l.path, path : l.path}
+ })
+ },
+ on_click : ev=>{
+ const href = ev.currentTarget.bss_input.data.href
+ const name = ev.currentTarget.bss_input.data.path
+ this.download(href, name)
+ },
+ apply : (e,d)=>{
+ e.innerHTML = `${d.path}`
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+}
+window.customElements.define('d4s-ccp-executionhistory', CCPExecutionHistory);