diff --git a/azkaban-web-server/src/main/java/azkaban/webapp/AzkabanWebServer.java b/azkaban-web-server/src/main/java/azkaban/webapp/AzkabanWebServer.java
index 3cfb8af..f1cfc51 100644
--- a/azkaban-web-server/src/main/java/azkaban/webapp/AzkabanWebServer.java
+++ b/azkaban-web-server/src/main/java/azkaban/webapp/AzkabanWebServer.java
@@ -54,6 +54,7 @@ import azkaban.webapp.plugin.TriggerPlugin;
import azkaban.webapp.plugin.ViewerPlugin;
import azkaban.webapp.servlet.AbstractAzkabanServlet;
import azkaban.webapp.servlet.ExecutorServlet;
+import azkaban.webapp.servlet.FlowTriggerInstanceServlet;
import azkaban.webapp.servlet.HistoryServlet;
import azkaban.webapp.servlet.IndexRedirectServlet;
import azkaban.webapp.servlet.JMXHttpServlet;
@@ -506,6 +507,7 @@ public class AzkabanWebServer extends AzkabanServer {
root.addServlet(new ServletHolder(new StatsServlet()), "/stats");
root.addServlet(new ServletHolder(new StatusServlet(this.statusService)), "/status");
root.addServlet(new ServletHolder(new NoteServlet()), "/notes");
+ root.addServlet(new ServletHolder(new FlowTriggerInstanceServlet()), "/flowtriggerinstance");
final ServletHolder restliHolder = new ServletHolder(new RestliServlet());
restliHolder.setInitParameter("resourcePackages", "azkaban.restli");
diff --git a/azkaban-web-server/src/main/java/azkaban/webapp/servlet/FlowTriggerInstanceServlet.java b/azkaban-web-server/src/main/java/azkaban/webapp/servlet/FlowTriggerInstanceServlet.java
new file mode 100644
index 0000000..aa6caf2
--- /dev/null
+++ b/azkaban-web-server/src/main/java/azkaban/webapp/servlet/FlowTriggerInstanceServlet.java
@@ -0,0 +1,211 @@
+/*
+ * Copyright 2018 LinkedIn Corp.
+ *
+ * Licensed 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.
+ */
+
+package azkaban.webapp.servlet;
+
+import azkaban.flowtrigger.CancellationCause;
+import azkaban.flowtrigger.DependencyInstance;
+import azkaban.flowtrigger.FlowTriggerService;
+import azkaban.flowtrigger.TriggerInstance;
+import azkaban.project.Project;
+import azkaban.project.ProjectManager;
+import azkaban.server.session.Session;
+import azkaban.user.Permission.Type;
+import azkaban.webapp.AzkabanWebServer;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import javax.servlet.ServletConfig;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.log4j.Logger;
+import org.joda.time.DateTime;
+import org.joda.time.DateTimeZone;
+
+public class FlowTriggerInstanceServlet extends LoginAbstractAzkabanServlet {
+
+ private static final long serialVersionUID = 1L;
+ private static final Logger logger = Logger.getLogger(FlowTriggerInstanceServlet.class);
+ private FlowTriggerService triggerService;
+ private ProjectManager projectManager;
+
+ @Override
+ public void init(final ServletConfig config) throws ServletException {
+ super.init(config);
+ final AzkabanWebServer server = (AzkabanWebServer) getApplication();
+ this.triggerService = server.getFlowTriggerService();
+ this.projectManager = server.getProjectManager();
+ }
+
+ @Override
+ protected void handleGet(final HttpServletRequest req, final HttpServletResponse resp,
+ final Session session) throws ServletException, IOException {
+ if (hasParam(req, "ajax")) {
+ handleAJAXAction(req, resp, session);
+ } else {
+ handlePage(req, resp, session);
+ }
+ }
+
+ private void handlePage(final HttpServletRequest req,
+ final HttpServletResponse resp, final Session session) {
+ final Page page =
+ newPage(req, resp, session,
+ "azkaban/webapp/servlet/velocity/executingflowtriggerspage.vm");
+
+ page.add("runningTriggers", this.triggerService.getRunningTriggers());
+ page.add("recentTriggers", this.triggerService.getRecentlyFinished());
+
+ page.add("vmutils", new ExecutorVMHelper());
+ page.render();
+ }
+
+ private void handleAJAXAction(final HttpServletRequest req,
+ final HttpServletResponse resp, final Session session) throws ServletException,
+ IOException {
+ final HashMap<String, Object> ret = new HashMap<>();
+ final String ajaxName = getParam(req, "ajax");
+
+ if (ajaxName.equals("fetchRunningTriggers")) {
+ ajaxFetchRunningTriggerInstances(ret);
+ } else if (ajaxName.equals("killRunningTrigger")) {
+ if (hasParam(req, "id")) {
+ final String triggerInstanceId = getParam(req, "id");
+ ajaxKillTriggerInstance(triggerInstanceId, session, ret);
+ } else {
+ ret.put("error", "please specify a valid running trigger instance id");
+ }
+ } else if (ajaxName.equals("showTriggerProperties")) {
+ if (hasParam(req, "id")) {
+ final String triggerInstanceId = getParam(req, "id");
+ loadTriggerProperties(triggerInstanceId, ret);
+ } else {
+ ret.put("error", "please specify a valid running trigger instance id");
+ }
+ }
+
+ if (ret != null) {
+ this.writeJSON(resp, ret);
+ }
+ }
+
+ private void loadTriggerProperties(final String triggerInstanceId,
+ final HashMap<String, Object> ret) {
+ final TriggerInstance triggerInstance = this.triggerService
+ .findTriggerInstanceById(triggerInstanceId);
+ if (triggerInstance != null) {
+ ret.put("triggerProperties", triggerInstance.getFlowTrigger().toString());
+ } else {
+ ret.put("error", "the trigger instance doesn't exist");
+ }
+ }
+
+ private void ajaxKillTriggerInstance(final String triggerInstanceId, final Session session,
+ final HashMap<String, Object> ret) {
+ final TriggerInstance triggerInst = this.triggerService
+ .findRunningTriggerInstById(triggerInstanceId);
+ if (triggerInst != null) {
+ if (hasPermission(triggerInst.getProject(), session.getUser(), Type.EXECUTE)) {
+ this.triggerService.cancel(triggerInst, CancellationCause.MANUAL);
+ } else {
+ ret.put("error", "no permission to kill the trigger");
+ }
+ } else {
+ ret.put("error", "the trigger doesn't exist, might already finished or cancelled");
+ }
+ }
+
+ private void ajaxFetchRunningTriggerInstances(final HashMap<String, Object> ret) throws
+ ServletException {
+ final Collection<TriggerInstance> triggerInstanceList = this.triggerService
+ .getRunningTriggers();
+
+ final List<HashMap<String, Object>> output = new ArrayList<>();
+ ret.put("items", output);
+
+ for (final TriggerInstance triggerInstance : triggerInstanceList) {
+ writeTriggerInstancesData(output, triggerInstance);
+ }
+ }
+
+ private void writeTriggerInstancesData(final List<HashMap<String, Object>> output,
+ final TriggerInstance triggerInst) {
+
+ final HashMap<String, Object> data = new HashMap<>();
+ data.put("id", triggerInst.getId());
+ data.put("starttime", triggerInst.getStartTime());
+ data.put("endtime", triggerInst.getEndTime());
+ data.put("status", triggerInst.getStatus());
+ data.put("flowExecutionId", triggerInst.getFlowExecId());
+ data.put("submitUser", triggerInst.getSubmitUser());
+ data.put("flowTriggerConfig", triggerInst.getFlowTrigger());
+ final List<Map<String, Object>> dependencyOutput = new ArrayList<>();
+ for (final DependencyInstance depInst : triggerInst.getDepInstances()) {
+ final Map<String, Object> depMap = new HashMap<>();
+ depMap.put("dependencyName", depInst.getDepName());
+ depMap.put("dependencyStarttime", depInst.getStartTime());
+ depMap.put("dependencyEndtime", depInst.getEndTime());
+ depMap.put("dependencyStatus", depInst.getStatus());
+ depMap.put("dependencyConfig", depInst.getTriggerInstance().getFlowTrigger()
+ .getDependencyByName
+ (depInst.getDepName()));
+ dependencyOutput.add(depMap);
+ }
+ data.put("dependencies", dependencyOutput);
+ output.add(data);
+ }
+
+ @Override
+ protected void handlePost(final HttpServletRequest req, final HttpServletResponse resp,
+ final Session session) throws ServletException, IOException {
+ if (hasParam(req, "ajax")) {
+ handleAJAXAction(req, resp, session);
+ }
+ }
+
+ /**
+ * @param cronTimezone represents the timezone from remote API call
+ * @return if the string is equal to UTC, we return UTC; otherwise, we always return default
+ * timezone.
+ */
+ private DateTimeZone parseTimeZone(final String cronTimezone) {
+ if (cronTimezone != null && cronTimezone.equals("UTC")) {
+ return DateTimeZone.UTC;
+ }
+
+ return DateTimeZone.getDefault();
+ }
+
+ private DateTime getPresentTimeByTimezone(final DateTimeZone timezone) {
+ return new DateTime(timezone);
+ }
+
+ public class ExecutorVMHelper {
+
+ public String getProjectName(final int id) {
+ final Project project = FlowTriggerInstanceServlet.this.projectManager.getProject(id);
+ if (project == null) {
+ return String.valueOf(id);
+ }
+
+ return project.getName();
+ }
+ }
+}
diff --git a/azkaban-web-server/src/main/resources/azkaban/webapp/servlet/velocity/executingflowtriggerspage.vm b/azkaban-web-server/src/main/resources/azkaban/webapp/servlet/velocity/executingflowtriggerspage.vm
new file mode 100644
index 0000000..5d503f2
--- /dev/null
+++ b/azkaban-web-server/src/main/resources/azkaban/webapp/servlet/velocity/executingflowtriggerspage.vm
@@ -0,0 +1,397 @@
+#*
+ * Copyright 2018 LinkedIn Corp.
+ *
+ * Licensed 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 lang="en">
+<head>
+
+ #parse("azkaban/webapp/servlet/velocity/style.vm")
+ #parse("azkaban/webapp/servlet/velocity/javascript.vm")
+ <script type="text/javascript" src="${context}/js/azkaban/util/ajax.js"></script>
+ #
+ <script type="text/javascript" src="${context}/js/azkaban/view/executions.js"></script>
+ <script type="text/javascript" src="${context}/js/jquery/jquery.tablesorter.js"></script>
+ <script type="text/javascript">
+ var contextURL = "${context}";
+ var currentTime = ${currentTime};
+ var timezone = "${timezone}";
+ var errorMessage = null;
+ var successMessage = null;
+
+ $(document).ready(function () {
+ var jobTable = $("#executingJobs");
+ jobTable.tablesorter();
+ });
+ </script>
+ <script>
+ $(function () {
+ $('tr.parent')
+ .css("cursor", "pointer")
+ .attr("title", "Click to expand/collapse")
+ .click(function () {
+ $(this).siblings('.child-' + this.id).toggle();
+ });
+ $('tr[class^=child-]').hide().children('tr');
+
+ });
+ </script>
+ <script type="text/javascript">
+ function killTrigger(id) {
+ var requestURL = document.location.href.replace("#currently-running", "");
+ var requestData = {"id": id, "ajax": "killRunningTrigger"};
+ var successHandler = function (data) {
+ console.log("cancel clicked");
+ if (data.error) {
+ showDialog("Error", data.error);
+ }
+ else {
+ showDialog("Killed", "Trigger has been killed.");
+
+ }
+ };
+ ajaxCall(requestURL, requestData, successHandler);
+ }
+
+ function showTriggerProperties(id) {
+ var requestURL = document.location.href.replace("#currently-running", "");
+ var requestData = {"id": id, "ajax": "showTriggerProperties"};
+ var successHandler = function (data) {
+ console.log("cancel clicked");
+ if (data.error) {
+ showDialog("Error", data.error);
+ }
+ else {
+ //showDialog("flow trigger properties", data.triggerProperties);
+ alert(data.triggerProperties);
+ }
+ };
+ //alert(requestURL);
+ ajaxCall(requestURL, requestData, successHandler);
+ }
+ </script>
+</head>
+<body>
+
+ #set ($current_page="executing")
+ #parse ("azkaban/webapp/servlet/velocity/nav.vm")
+
+## Page header.
+
+<div class="az-page-header">
+ <div class="container-full">
+ <h1><a href="${context}/executor">Executing Triggers</a></h1>
+ </div>
+</div>
+
+<div class="container-full">
+
+ #parse ("azkaban/webapp/servlet/velocity/alerts.vm")
+
+## Page Content
+
+ <ul class="nav nav-tabs nav-sm" id="header-tabs">
+ <li id="currently-running-view-link"><a href="#currently-running">Trigger Executions</a></li>
+ <li id="recently-finished-view-link"><a href="#recently-finished">Recently Finished</a></li>
+ </ul>
+
+ <div class="row" id="currently-running-view">
+ <div class="col-xs-12">
+ <table id="executingJobs"
+ class="table table-striped table-bordered table-hover table-condensed executions-table">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th class="execid">Trigger Instance Id</th>
+ <th>Flow</th>
+ <th>Project</th>
+ <th>Submitted by</th>
+ <th class="date">Start time</th>
+ <th class="date">End time</th>
+ <th class="status">Status</th>
+ <th class="elapse">Elapsed</th>
+ <th class="flowExecId">Triggered Flow Execution Id</th>
+ <th class="config">Trigger Properties</th>
+ <th class="action">Action</th>
+ </tr>
+ </thead>
+ <tbody>
+
+ #if ( !$null.isNull(${runningTriggers}))
+ #foreach ($trigger in $runningTriggers)
+ <tr class="parent" id=${trigger.getId()}>
+ <td class="tb-name">
+ $velocityCount
+ </td>
+ <td>
+ #if (${trigger.getId()})
+ ${trigger.getId()}
+ #else
+ -
+ #end
+ </td>
+ #*todo chengren311: keep result of vmutils.getProjectName as a variable *#
+ <td><a
+ href="${context}/manager?project=$vmutils.getProjectName(${trigger.getProject().getId()})&flow=${trigger.getFlowId()}">${trigger.getFlowId()}</a>
+ </td>
+ <td>
+ <a href="${context}/manager?project=$vmutils.getProjectName(${trigger.getProject().getId()})">$vmutils.getProjectName(${trigger.getProject().getId()})</a>
+ </td>
+
+ <td>${trigger.getSubmitUser()}</td>
+ #*todo chengren311: verify utils.formatDate will convert to user's timezone *#
+ <td>$utils.formatDate(${trigger.getStartTime().getTime()})</td>
+ #if (${trigger.getEndTime()})
+ <td>${trigger.getEndTime()}</td>
+ #else
+ <td>-</td>
+ #end
+ <td>${trigger.getStatus()}</td>
+ #if (${trigger.getStatus()} != "RUNNING" && ${trigger.getStatus()} != "CANCELLING")
+ <td>$utils.formatDuration(${trigger.getStartTime().getTime()}, ${trigger.getEndTime().getTime()})
+ </td>
+ #else
+ <td>$utils.formatDuration(${trigger.getStartTime().getTime()}, ${utils.currentTimestamp()})
+ </td>
+ #end
+ #if (${trigger.getFlowExecId()} != "-1")
+ <td><a
+ href="${context}/executor?execid=${trigger.getFlowExecId()}">${trigger.getFlowExecId()}</a>
+ </td>
+ #else
+ <td>-</td>
+ #end
+
+ <td>
+ <button type="button" id="showTriggerProperties" class="btn btn-danger btn-sm"
+ onclick="showTriggerProperties('${trigger.getId()}')">
+ Trigger Config
+ </button>
+ </td>
+
+ <td>
+ #if (${trigger.getStatus()} == "RUNNING")
+ <button type="button" id="cancelbtn" class="btn btn-danger btn-sm"
+ onclick="killTrigger('${trigger.getId()}')">Kill
+ </button>
+ #else
+ -
+ #end
+ </td>
+
+ <tr class="child-${trigger.getId()}">
+ <td> </td>
+ <td>Name</td>
+ <td>Start time</td>
+ <td>End time</td>
+ <td>Status</td>
+ <td>Elapsed time</td>
+ </tr>
+ #foreach ($dep in $trigger.getDepInstances())
+ <tr class="child-${trigger.getId()}">
+ <td> </td>
+ <td>${dep.getDepName()}</td>
+ <td>$utils.formatDate(${dep.getStartTime().getTime()})</td>
+ #if (${dep.getEndTime()})
+ <td>${dep.getEndTime()}</td>
+ #else
+ <td>-</td>
+ #end
+ <td>${dep.getStatus()}</td>
+ #if (${dep.getEndTime()})
+ <td>$utils.formatDuration(${dep.getStartTime().getTime()}, ${dep.getEndTime().getTime()})
+ </td>
+ #else
+ <td>$utils.formatDuration(${dep.getStartTime().getTime()}, ${utils.currentTimestamp()})
+ </td>
+ #end
+ </tr>
+ #end
+
+ #*
+ <td>
+ <a href="${context}/manager?project=$vmutils.getProjectName(${flow.getFirst().projectId})"
+ >$vmutils.getProjectName(${flow.getFirst().projectId})</a>
+ </td>
+ <td>${flow.getFirst().submitUser}</td>
+ <td>${flow.getFirst().proxyUsers}</td>
+ <td>$utils.formatDate(${flow.getFirst().startTime})</td>
+ <td>$utils.formatDate(${flow.getFirst().endTime})</td>
+ <td>$utils.formatDuration(${flow.getFirst().startTime}, ${flow.getFirst().endTime})</td>
+ <td>
+ <div
+ class="status ${flow.getFirst().status}">$utils.formatStatus(${flow.getFirst().status})</div>
+ </td>
+ <td>
+ <button type="button" id="cancelbtn" class="btn btn-danger btn-sm"
+ onclick="killFlow(${flow.getFirst().executionId})">Kill
+ </button>
+ </td>
+ *#
+ </tr>
+ #end
+ #else
+ <tr>
+ <td colspan="10">No Executing Flows</td>
+ </tr>
+ #end
+ </tbody>
+ </table>
+ </div><!-- /col-xs-12 -->
+ </div><!-- /row -->
+
+ <div class="row" id="recently-finished-view">
+ <div class="col-xs-12">
+ <table id="recentlyFinished"
+ class="table table-striped table-bordered table-hover table-condensed executions-table">
+ <thead>
+ <tr>
+ <th>#</th>
+ <th class="execid">Trigger Instance Id</th>
+ <th>Flow</th>
+ <th>Project</th>
+ <th>Submitted by</th>
+ <th class="date">Start time</th>
+ <th class="date">End time</th>
+ <th class="status">Status</th>
+ <th class="elapse">Elapsed</th>
+ <th class="flowExecId">Triggered Flow Execution Id</th>
+ <th class="config">Trigger Properties</th>
+ <th class="action">Action</th>
+ </tr>
+ </thead>
+ <tbody>
+
+ #if ( !$null.isNull(${recentTriggers}))
+ #foreach ($trigger in $recentTriggers)
+ <tr class="parent" id=${trigger.getId()}>
+ <td class="tb-name">
+ $velocityCount
+ </td>
+ <td>
+ #if (${trigger.getId()})
+ ${trigger.getId()}
+ #else
+ -
+ #end
+ </td>
+ #*todo chengren311: keep result of vmutils.getProjectName as a variable *#
+ <td><a
+ href="${context}/manager?project=$vmutils.getProjectName(${trigger.getProject().getId()})&flow=${trigger.getFlowId()}">${trigger.getFlowId()}</a>
+ </td>
+ <td>
+ <a href="${context}/manager?project=$vmutils.getProjectName(${trigger.getProject().getId()})">$vmutils.getProjectName(${trigger.getProject().getId()})</a>
+ </td>
+
+ <td>${trigger.getSubmitUser()}</td>
+ #*todo chengren311: verify utils.formatDate will convert to user's timezone *#
+ <td>$utils.formatDate(${trigger.getStartTime().getTime()})</td>
+ #if (${trigger.getEndTime()})
+ <td>${trigger.getEndTime()}</td>
+ #else
+ <td>-</td>
+ #end
+ <td>${trigger.getStatus()}</td>
+ #if (${trigger.getStatus()} != "RUNNING" && ${trigger.getStatus()} != "CANCELLING")
+ <td>$utils.formatDuration(${trigger.getStartTime().getTime()}, ${trigger.getEndTime().getTime()})
+ </td>
+ #else
+ <td>$utils.formatDuration(${trigger.getStartTime().getTime()}, ${utils.currentTimestamp()})
+ </td>
+ #end
+ #if (${trigger.getFlowExecId()} != "-1")
+ <td><a
+ href="${context}/executor?execid=${trigger.getFlowExecId()}">${trigger.getFlowExecId()}</a>
+ </td>
+ #else
+ <td>-</td>
+ #end
+
+ <td>
+ <button type="button" id="showTriggerProperties" class="btn btn-danger btn-sm"
+ onclick="showTriggerProperties('${trigger.getId()}')">
+ Trigger Config
+ </button>
+ </td>
+
+ <td>
+ -
+ </td>
+
+ <tr class="child-${trigger.getId()}">
+ <td> </td>
+ <td>Name</td>
+ <td>Start time</td>
+ <td>End time</td>
+ <td>Status</td>
+ <td>Cancellation Cause</td>
+ <td>Elapsed time</td>
+ </tr>
+ #foreach ($dep in $trigger.getDepInstances())
+ <tr class="child-${trigger.getId()}">
+ <td> </td>
+ <td>${dep.getDepName()}</td>
+ <td>$utils.formatDate(${dep.getStartTime().getTime()})</td>
+ #if (${dep.getEndTime()})
+ <td>${dep.getEndTime()}</td>
+ #else
+ <td>-</td>
+ #end
+ <td>${dep.getStatus()}</td>
+ <td>${dep.getCancellationCause()}</td>
+ #if (${dep.getEndTime()})
+ <td>$utils.formatDuration(${dep.getStartTime().getTime()}, ${dep.getEndTime().getTime()})
+ </td>
+ #else
+ <td>$utils.formatDuration(${dep.getStartTime().getTime()}, ${utils.currentTimestamp()})
+ </td>
+ #end
+ </tr>
+ #end
+
+ </tr>
+ #end
+ #else
+ <tr>
+ <td colspan="10">No Executing Flows</td>
+ </tr>
+ #end
+ </tbody>
+ </table>
+ </div><!-- /col-xs-12 -->
+ </div><!-- /row -->
+
+ <div class="modal" id="messageDialog">
+ <div class="modal-dialog">
+ <div class="modal-content">
+ <div class="modal-header" id="messageTitle">
+ <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×
+ </button>
+ <h4 class="modal-title">Error</h4>
+ </div>
+ <div class="modal-body" id="messageDiv">
+ <p id="messageBox"></p>
+ </div>
+ <div class="modal-footer">
+ <button type="button" class="btn btn-primary" data-dismiss="modal"
+ onclick="window.location.reload(true);">Dismiss
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+</div><!-- /container-full -->
+</body>
+</html>