Adding Vue.js webui

Squashed commit of the following:

commit 29571182b1ec3b5be2cec3212c2bea1121a3dac2
Author: Ian Roddis <tech@kinesin.ca>
Date:   Thu Feb 24 11:29:47 2022 -0400

    Adding more elegant handling of tasks with no attempts

commit 18c8ccb0863abbf6c9cc0efe5cc68df03a9eb80d
Author: Ian Roddis <tech@kinesin.ca>
Date:   Thu Feb 24 11:18:59 2022 -0400

    Better handling of no attempts at all

commit 962f9f6e5e17f71bc3766553913774631f66e7ef
Author: Ian Roddis <tech@kinesin.ca>
Date:   Thu Feb 24 11:10:28 2022 -0400

    Adding fix for missing attempts

commit 19b8203e952b3d21f4ff3f9b97a01c4d567ff1e7
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 16:56:37 2022 -0400

    Adding webui instructions to readme

commit 81383c80f01101828c0c49868916a2712d140f42
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 16:48:31 2022 -0400

    Adding in route splatting to support static assets

commit c9b39b307916c0fb1e88769d6986ddf7c3ba183a
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 12:11:11 2022 -0400

    Cleanup

commit 177819a1439cd1a0f32c652abf670f54457e105a
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 12:09:40 2022 -0400

    Setting explicit url for extra CSS

commit 78261129511c50657e7902934cee396eb1e4e3a8
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 12:08:27 2022 -0400

    Moving webui

commit 9f8db6e2c2c8a231060217cb82f1b13aabe4eae2
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 12:06:25 2022 -0400

    Reorganizing elements, adding regex for run list

commit f114250c9a506b2c0e9d642cc75749e99cc76cef
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 10:52:41 2022 -0400

    Adding regex filtering to tasks

commit 2de2f218416210443119aa88fa49c714197f4b16
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 10:42:22 2022 -0400

    Adding in task details and getting the plumbing working

commit 660a2078e22799ba51b4b8bbe5c12cd0f9315b0a
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 09:38:13 2022 -0400

    Fixing remaining settings

commit 1aa0dfe1c971a12dfed183586ee5a3206d452409
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 09:36:25 2022 -0400

    Playing with settings

commit 84cbd11c45651c7c6c96c16714e741b6aee10bc5
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 23 08:52:52 2022 -0400

    Removing extra code

commit 6e31646b7c62368cab22b3844a70943e0149ddc7
Author: Ian Roddis <tech@kinesin.ca>
Date:   Tue Feb 22 17:29:47 2022 -0400

    Adding linter, renaming components to meet standards, fixing some mixups in settings

commit 225442ee5732d007867e485ccea05293e3e5e1b7
Author: Ian Roddis <tech@kinesin.ca>
Date:   Tue Feb 22 17:25:27 2022 -0400

    Fixing sorters

commit eb0d7a4c4c30d8e8b43b574ed0c2f97515bb9353
Author: Ian Roddis <tech@kinesin.ca>
Date:   Tue Feb 22 16:46:41 2022 -0400

    Controls are coming together

commit b1789d1cc3c0bae170e0ca1a47cccfd344197244
Author: Ian Roddis <tech@kinesin.ca>
Date:   Tue Feb 22 11:08:09 2022 -0400

    More refactoring

commit 6d0afce429aad00864482a2cc7dd731a53312e14
Author: Ian Roddis <tech@kinesin.ca>
Date:   Sun Feb 20 22:29:43 2022 -0400

    figuring out layout

commit 6af498f3aa7fe2f45121df2278cdfac297165c5c
Author: Ian Roddis <tech@kinesin.ca>
Date:   Sun Feb 20 12:30:49 2022 -0400

    Migrating to prop drilling / emiting

commit dffe7059ce01209d2def6ef7c03bc750e31fe741
Author: Ian Roddis <tech@kinesin.ca>
Date:   Fri Feb 18 17:20:46 2022 -0400

    Checkpointing work for now

commit d6428ad59c9c05ab7fba82ce3c0441ac3f568796
Author: Ian Roddis <tech@kinesin.ca>
Date:   Fri Feb 18 17:05:37 2022 -0400

    Adding in toggling for states

commit b9a4f2dc02f327d3529821e217d3b6a00a84f202
Author: Ian Roddis <tech@kinesin.ca>
Date:   Fri Feb 18 16:43:01 2022 -0400

    Reorganizing everything

commit d33691d022597d1ff8f588450e147c72555be9f4
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 17:04:54 2022 -0400

    Removing console logging

commit 4537376ccad6fc0c52f0a7cfd2b2bf23f708196c
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 17:04:27 2022 -0400

    Refresh timer working now

commit 213a3da4fd07c82cd18cd8c3b2422ddc78bd6fb4
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 16:40:45 2022 -0400

    Adding timer

commit ff495ac69563689ff4fc07119936079e57608ea7
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 16:02:53 2022 -0400

    Refactoring some code, adding in endpoint to kill a running task

commit 97ff28b9b1910e03e0f2725a3f54d2a07e53714c
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 14:56:15 2022 -0400

    Renaming UI

commit affab06ad657833b73588eac919250935b353f31
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 13:29:31 2022 -0400

    moving to bootstrap

commit c40a2e58a86362863c905470f4417753aaf0dac2
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 12:33:08 2022 -0400

    adding task button

commit 420463b8d7f964baa0dfc7c87c2e9024bc8284cc
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 10:51:11 2022 -0400

    checkpoint

commit a7aa3db731255e7e13bc58d901b8eb1e30ede39c
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 09:33:01 2022 -0400

    Fixing up state

commit 361b4cbcd8f1268eb9b494084d6862a6ab8f3a27
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 09:29:14 2022 -0400

    Fixing event callbacks

commit 388cada692dc8d7e0eff611467d4c77ce897a54c
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 09:24:39 2022 -0400

    Adding global state, task view and buttons

commit cb5a3acef0bd982621678fbd44a133db56420871
Author: Ian Roddis <tech@kinesin.ca>
Date:   Wed Feb 16 07:49:30 2022 -0400

    Adding RunView

commit 4c78ef1250709e7c8f5ef3433640fd8d1d319a8d
Author: Ian Roddis <tech@kinesin.ca>
Date:   Tue Feb 15 17:20:23 2022 -0400

    checkpoint

commit 2c5b610101e9c18ef1ad8f962d7309b63c80743c
Author: Ian Roddis <tech@kinesin.ca>
Date:   Tue Feb 15 17:10:06 2022 -0400

    Adding explicit payload headers, adding vue and react apps

commit 95ac6c05903bc83c6934db58b48649eee2038c3d
Author: Ian Roddis <tech@kinesin.ca>
Date:   Tue Feb 15 12:56:57 2022 -0400

    Adding CORS support, rough-in of webui
This commit is contained in:
Ian Roddis
2022-02-24 11:40:18 -04:00
parent 93ab2f38c9
commit 0603285c10
37 changed files with 5346 additions and 292 deletions

View File

@@ -44,9 +44,10 @@ Building
- cmake >= 3.14
- gcc >= 8
- npm (if you want the webui)
- libslurm (if needed)
```
```sh
git clone https://gitlab.com/iroddis/daggy
cd daggy
mkdir build
@@ -55,6 +56,14 @@ cmake [-DDAGGY_ENABLE_SLURM=ON] ..
make
tests/tests # for unit tests
# Web UI
cd webui
npm install
npm run build
# Lauching daggyd with the web UI
build/bin/daggyd -v --assets-dir webui/dist
```
DAG Run Definition

View File

@@ -264,6 +264,7 @@ int main(int argc, char **argv)
.default_value(false)
.implicit_value(true);
args.add_argument("-d", "--daemon").default_value(false).implicit_value(true);
args.add_argument("--assets-dir").default_value(std::string{});
args.add_argument("--config").default_value(std::string{});
args.add_argument("--ip").default_value(std::string{"127.0.0.1"});
args.add_argument("--port").default_value(2503u).action(
@@ -281,6 +282,7 @@ int main(int argc, char **argv)
bool verbose = args.get<bool>("--verbose");
bool asDaemon = args.get<bool>("--daemon");
auto configFile = args.get<std::string>("--config");
auto staticAssetsDir = args.get<std::string>("--assets-dir");
std::string listenIP = args.get<std::string>("--ip");
auto listenPort = args.get<unsigned>("--port");
size_t webThreads = 50;
@@ -301,6 +303,8 @@ int main(int argc, char **argv)
if (doc.HasMember("ip"))
listenIP = doc["ip"].GetString();
if (doc.HasMember("assets-dir"))
staticAssetsDir = doc["assets-dir"].GetString();
if (doc.HasMember("port"))
listenPort = doc["port"].GetInt();
if (doc.HasMember("web-threads"))
@@ -331,7 +335,8 @@ int main(int argc, char **argv)
Pistache::Address listenSpec(listenIP, listenPort);
daggy::daggyd::Server server(listenSpec, *logger, *executor, dagThreads);
daggy::daggyd::Server server(listenSpec, *logger, *executor, dagThreads,
staticAssetsDir);
server.init(webThreads);
server.start();

View File

@@ -22,7 +22,8 @@ namespace daggy::daggyd {
public:
Server(const Pistache::Address &listenSpec,
loggers::dag_run::DAGRunLogger &logger,
executors::task::TaskExecutor &executor, size_t nDAGRunners);
executors::task::TaskExecutor &executor, size_t nDAGRunners,
const fs::path &staticAssetsDir);
~Server();
Server &setSSLCertificates(const fs::path &cert, const fs::path &key);
@@ -40,7 +41,7 @@ namespace daggy::daggyd {
void queueDAG_(DAGRunID runID, const TaskDAG &dag,
const TaskParameters &taskParameters);
DAGGY_REST_HANDLER(handleRoot); // X
DAGGY_REST_HANDLER(handleStatic); // X
DAGGY_REST_HANDLER(handleReady); // X
DAGGY_REST_HANDLER(handleQueryDAGs); // X
DAGGY_REST_HANDLER(handleRunDAG); // X
@@ -53,6 +54,7 @@ namespace daggy::daggyd {
DAGGY_REST_HANDLER(handleStopTask); // X
DAGGY_REST_HANDLER(handleGetTaskState); // X
DAGGY_REST_HANDLER(handleSetTaskState); // X
DAGGY_REST_HANDLER(handleCORS);
bool handleAuth(const Pistache::Rest::Request &request);
@@ -63,6 +65,7 @@ namespace daggy::daggyd {
loggers::dag_run::DAGRunLogger &logger_;
executors::task::TaskExecutor &executor_;
ThreadPool runnerPool_;
fs::path staticAssetsDir_;
std::mutex runnerGuard_;
std::unordered_map<DAGRunID, std::shared_ptr<DAGRunner>> runners_;

View File

@@ -3,6 +3,7 @@
#include <daggy/Serialization.hpp>
#include <daggy/Utilities.hpp>
#include <daggyd/Server.hpp>
#include <fstream>
#include <iomanip>
#include <mutex>
#include <numeric>
@@ -20,14 +21,20 @@ using namespace Pistache;
namespace daggy::daggyd {
bool requestIsForJSON(const Pistache::Rest::Request &request)
void addResponseHeaders(Pistache::Http::ResponseWriter &response)
{
auto acceptedMimeTypes =
request.headers().get<Pistache::Http::Header::Accept>()->media();
auto fit =
std::find(acceptedMimeTypes.begin(), acceptedMimeTypes.end(),
Pistache::Http::Mime::MediaType::fromString("text/html"));
return fit == acceptedMimeTypes.end();
response.headers().add(
std::make_shared<Pistache::Http::Header::AccessControlAllowOrigin>(
"*"));
response.headers().add(
std::make_shared<Pistache::Http::Header::AccessControlAllowHeaders>(
"content-type"));
response.headers().add(
std::make_shared<Pistache::Http::Header::AccessControlAllowMethods>(
"PUT, OPTIONS, PATCH, GET, HEAD, CONNECT, DELETE, POST, TRACE"));
response.headers().add(
std::make_shared<Pistache::Http::Header::ContentType>(
"application/javascript"));
}
void Server::init(size_t threads)
@@ -44,12 +51,14 @@ namespace daggy::daggyd {
Server::Server(const Pistache::Address &listenSpec,
loggers::dag_run::DAGRunLogger &logger,
executors::task::TaskExecutor &executor, size_t nDAGRunners)
executors::task::TaskExecutor &executor, size_t nDAGRunners,
const fs::path &staticAssetsDir)
: endpoint_(listenSpec)
, desc_("Daggy API", "0.1")
, logger_(logger)
, executor_(executor)
, runnerPool_(nDAGRunners)
, staticAssetsDir_(staticAssetsDir)
{
}
@@ -91,6 +100,19 @@ namespace daggy::daggyd {
desc_.response(Http::Code::Internal_Server_Error,
R"({"error": "An error occurred with the backend"})");
desc_.route(desc_.get("/"))
.bind(&Server::handleStatic, this)
.response(Http::Code::Ok, "Serve static assets")
.hide();
desc_.route(desc_.get("/*"))
.bind(&Server::handleStatic, this)
.response(Http::Code::Ok, "Serve static assets")
.hide();
desc_.route(desc_.get("/*/*"))
.bind(&Server::handleStatic, this)
.response(Http::Code::Ok, "Serve static assets")
.hide();
desc_.schemes(Rest::Scheme::Http)
.basePath("/v1")
.produces(MIME(Application, Json))
@@ -101,11 +123,6 @@ namespace daggy::daggyd {
.response(Http::Code::Ok, "Response to the /ready call")
.hide();
desc_.route(desc_.get("/"))
.bind(&Server::handleRoot, this)
.response(Http::Code::Ok, "Response to the /ready call")
.hide();
auto versionPath = desc_.path("/v1");
/*
@@ -113,6 +130,7 @@ namespace daggy::daggyd {
*/
auto dagRunsPath = versionPath.path("/dagruns");
dagRunsPath.route(desc_.options("/")).bind(&Server::handleCORS, this);
dagRunsPath.route(desc_.get("/"))
.bind(&Server::handleQueryDAGs, this)
.produces(MIME(Application, Json))
@@ -123,11 +141,14 @@ namespace daggy::daggyd {
*/
auto dagRunPath = versionPath.path("/dagrun");
dagRunPath.route(desc_.options("/")).bind(&Server::handleCORS, this);
dagRunPath.route(desc_.post("/"))
.bind(&Server::handleRunDAG, this)
.produces(MIME(Application, Json))
.response(Http::Code::Ok, "Run a DAG");
dagRunPath.route(desc_.options("/validate"))
.bind(&Server::handleCORS, this);
dagRunPath.route(desc_.post("/validate"))
.bind(&Server::handleValidateDAG, this)
.produces(MIME(Application, Json))
@@ -138,6 +159,8 @@ namespace daggy::daggyd {
*/
auto specificDAGRunPath = dagRunPath.path("/:runID");
specificDAGRunPath.route(desc_.options("/"))
.bind(&Server::handleCORS, this);
specificDAGRunPath.route(desc_.del("/"))
.bind(&Server::handleStopDAGRun, this)
.produces(MIME(Application, Json))
@@ -148,12 +171,16 @@ namespace daggy::daggyd {
.produces(MIME(Application, Json))
.response(Http::Code::Ok, "Full DAG Run");
specificDAGRunPath.route(desc_.options("/state"))
.bind(&Server::handleCORS, this);
specificDAGRunPath.route(desc_.get("/state"))
.bind(&Server::handleGetDAGRunState, this)
.produces(MIME(Application, Json))
.response(Http::Code::Ok,
"Structure of a DAG and DAG and Task run states");
specificDAGRunPath.route(desc_.options("/state/:state"))
.bind(&Server::handleCORS, this);
specificDAGRunPath.route(desc_.patch("/state/:state"))
.bind(&Server::handleSetDAGRunState, this)
.produces(MIME(Application, Json))
@@ -163,6 +190,7 @@ namespace daggy::daggyd {
Task paths
*/
auto taskPath = specificDAGRunPath.path("/task/:taskName");
taskPath.route(desc_.options("/")).bind(&Server::handleCORS, this);
taskPath.route(desc_.get("/"))
.bind(&Server::handleGetTask, this)
.produces(MIME(Application, Json))
@@ -177,22 +205,33 @@ namespace daggy::daggyd {
*/
auto taskStatePath = taskPath.path("/state");
taskStatePath.route(desc_.options("/")).bind(&Server::handleCORS, this);
taskStatePath.route(desc_.get("/"))
.bind(&Server::handleGetTaskState, this)
.produces(MIME(Application, Json))
.response(Http::Code::Ok, "Get a task state");
taskStatePath.route(desc_.options("/:state"))
.bind(&Server::handleCORS, this);
taskStatePath.route(desc_.patch("/:state"))
.bind(&Server::handleSetTaskState, this)
.produces(MIME(Application, Json))
.response(Http::Code::Ok, "Set a task state");
}
void Server::handleCORS(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
addResponseHeaders(response);
response.send(Pistache::Http::Code::Ok, "");
}
void Server::handleRunDAG(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
if (!handleAuth(request))
return;
addResponseHeaders(response);
DAGRunID runID = 0;
try {
@@ -216,6 +255,7 @@ namespace daggy::daggyd {
void Server::handleValidateDAG(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
addResponseHeaders(response);
try {
dagFromJSON(request.body());
response.send(Pistache::Http::Code::Ok, R"({"valid": true}\n)");
@@ -231,6 +271,7 @@ namespace daggy::daggyd {
void Server::handleQueryDAGs(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
addResponseHeaders(response);
if (!handleAuth(request))
return;
@@ -241,21 +282,15 @@ namespace daggy::daggyd {
tag = request.query().get("tag").value();
}
bool isJSON = requestIsForJSON(request);
if (request.hasParam(":all")) {
auto val = request.query().get(":all").value();
if (request.query().has("all")) {
auto val = request.query().get("all").value();
if (val == "true" or val == "1") {
all = true;
}
}
else if (!isJSON) {
all = true;
}
auto dagRuns = logger_.queryDAGRuns(tag, all);
std::stringstream ss;
if (isJSON) {
// default to json
ss << '[';
@@ -271,9 +306,9 @@ namespace daggy::daggyd {
ss << " {"
<< R"("runID": )" << run.runID << ',' << R"("tag": )"
<< std::quoted(run.tag) << ","
<< R"("startTime": )"
<< std::quoted(timePointToString(run.startTime)) << ','
<< R"("lastUpdate": )"
<< R"("state": )" << std::quoted(run.runState._to_string()) << ","
<< R"("startTime": )" << std::quoted(timePointToString(run.startTime))
<< ',' << R"("lastUpdate": )"
<< std::quoted(timePointToString(run.lastUpdate)) << ','
<< R"("taskCounts": {)";
bool firstState = true;
@@ -290,59 +325,13 @@ namespace daggy::daggyd {
<< '}'; // end of item
}
ss << "]\n";
}
else {
// HTML
ss << "<html><head><title>Daggy "
"Runs</title><meta http-equiv=\"refresh\" "
"content=\"10\"></head><body><center><h2>Current Runs</h2><br>";
if (!dagRuns.empty()) {
std::sort(dagRuns.begin(), dagRuns.end(),
[](const auto &a, const auto &b) {
return a.startTime > b.startTime;
});
ss << "<table><tr><th>Run ID</th><th>Tag</th><th>State</th><th># "
"Tasks</th><th>Start Time</th><th>Last "
"Update</th><th>Queued</th><th>Running</th><th>Retry</"
"th><th>Errored</th><th>Completed</th></tr>";
for (auto &ds : dagRuns) {
size_t nTasks = 0;
for (const auto &[k, cnt] : ds.taskStateCounts)
nTasks += cnt;
auto stateURL = [&](RunState state) {
std::stringstream ss;
ss << R"(<a href="/v1/dagrun/)" << ds.runID
<< "/?state=" << state._to_string() << "\">"
<< ds.taskStateCounts[state] << "</a>";
return ss.str();
};
ss << "<tr>"
<< R"(<td><a href="/v1/dagrun/)" << ds.runID << R"(">)" << ds.runID
<< "</a></td>"
<< "<td>" << ds.tag << "</td>"
<< "<td>" << ds.runState << "</td>"
<< "<td>" << nTasks << "</td>"
<< "<td>" << timePointToString(ds.startTime) << "</td>"
<< "<td>" << timePointToString(ds.lastUpdate) << "</td>"
<< "<td>" << stateURL(RunState::QUEUED) << "</td>"
<< "<td>" << stateURL(RunState::RUNNING) << "</td>"
<< "<td>" << stateURL(RunState::RETRY) << "</td>"
<< "<td>" << stateURL(RunState::ERRORED) << "</td>"
<< "<td>" << stateURL(RunState::COMPLETED) << "</td>"
<< "</tr>";
}
ss << "</table>";
}
ss << "</body></html>\n";
}
response.send(Pistache::Http::Code::Ok, ss.str());
}
void Server::handleGetDAGRun(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
addResponseHeaders(response);
if (!handleAuth(request))
return;
if (!request.hasParam(":runID")) {
@@ -350,7 +339,6 @@ namespace daggy::daggyd {
}
auto runID = request.param(":runID").as<size_t>();
auto run = logger_.getDAGRun(runID);
bool isJSON = requestIsForJSON(request);
std::optional<RunState> filterState;
if (request.query().has("state")) {
@@ -359,7 +347,6 @@ namespace daggy::daggyd {
}
std::stringstream ss;
if (isJSON) {
bool first = true;
ss << "{"
<< R"("runID": )" << runID << ',' << R"("tag": )"
@@ -419,76 +406,13 @@ namespace daggy::daggyd {
}
ss << "]";
ss << "}\n";
}
else {
std::unordered_map<RunState, size_t> stateCounts;
for (const auto &[_, state] : run.taskRunStates) {
stateCounts[state]++;
}
ss << R"(<html>
<head>
<title>Details for RunID )"
<< runID << R"(</title>
<meta http-equiv="refresh" "content="10">
<script>
function resubmit(run_id, task_name) {
let url = "/v1/dagrun/" + run_id + "/task/" + task_name + "/state/QUEUED";
fetch(url, { method: 'PATCH' })
.then(res => console.log('Resubmitted task'))
}
</script>
</head>
<body>
<center>
<h2>Summary</h2>
<table><tr><th>Run ID</th><th>Tag</th><th>State</th>
<th>#Tasks</th>
<th>Queued</th><th>Running</th><th>Retry</th>
<th>Errored</th><th>Completed</th></tr>
<tr>)"
<< "<td>" << runID << "</td>"
<< "<td>" << run.dagSpec.tag << "</td>"
<< "<td>" << run.dagStateChanges.back().state << "</td>"
<< "<td>" << run.dagSpec.tasks.size() << "</td>"
<< "<td>" << stateCounts[RunState::QUEUED] << "</td>"
<< "<td>" << stateCounts[RunState::RUNNING] << "</td>"
<< "<td>" << stateCounts[RunState::RETRY] << "</td>"
<< "<td>" << stateCounts[RunState::ERRORED] << "</td>"
<< "<td>" << stateCounts[RunState::COMPLETED] << "</td>"
<< "</tr></table>"
<< "<h2>Task Details</h2>"
<< "<table><tr><th>Task Name</th><th> State</th><th>Last "
"Update</th><th> Logs</th></tr>";
for (const auto &[taskName, task] : run.dagSpec.tasks) {
auto taskState = run.taskRunStates.at(taskName);
if (filterState and taskState != *filterState)
continue;
std::string retryButton = "";
if (taskState == +RunState::ERRORED) {
retryButton = " <a href=\"#\" onclick=\"resubmit(" +
std::to_string(runID) + ", '" + taskName +
"');\">Retry</a>";
}
ss << "<tr>"
<< "<td>" << taskName << "</td>"
<< "<td>" << run.taskRunStates.at(taskName) << "</td>"
<< "<td>"
<< timePointToString(run.taskStateChanges.at(taskName).back().time)
<< "</td>"
<< "<td><a href=\"/v1/dagrun/" << runID << "/task/" << taskName
<< "\">Logs</a>" << retryButton << "</td>"
<< "</tr>";
}
ss << "</table></center></body></html>";
}
response.send(Pistache::Http::Code::Ok, ss.str());
}
void Server::handleStopDAGRun(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
addResponseHeaders(response);
if (!handleAuth(request))
return;
if (!request.hasParam(":runID")) {
@@ -556,6 +480,7 @@ namespace daggy::daggyd {
{
if (!handleAuth(request))
return;
addResponseHeaders(response);
// TODO handle state transition
DAGRunID runID = request.param(":runID").as<DAGRunID>();
@@ -609,15 +534,14 @@ namespace daggy::daggyd {
void Server::handleGetTask(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
addResponseHeaders(response);
if (!handleAuth(request))
return;
auto runID = request.param(":runID").as<DAGRunID>();
auto taskName = request.param(":taskName").as<std::string>();
bool isJSON = requestIsForJSON(request);
std::stringstream ss;
if (isJSON) {
Task task;
try {
task = logger_.getTask(runID, taskName);
@@ -626,46 +550,38 @@ namespace daggy::daggyd {
REQ_RESPONSE(Not_Found, e.what());
}
ss << taskToJSON(task);
}
else {
std::optional<loggers::dag_run::TaskRecord> tr;
try {
tr.emplace(logger_.getTaskRecord(runID, taskName));
}
catch (std::exception &e) {
REQ_RESPONSE(Not_Found, e.what());
}
ss << "<html><title>Task Details for " << runID << " / " << taskName
<< "</title><body>"
<< "<table>"
<< "<tr><th>Name</th><td>" << taskName << "</td></tr>"
<< "<tr><th>State</th><td>" << tr->state << "</td></tr>"
<< "<tr><th>Definition</th><td>" << taskToJSON(tr->task)
<< "</td></tr>"
<< "<tr><th colspan=2>Attempts</th></tr>";
std::sort(tr->attempts.begin(), tr->attempts.end(),
[](const auto &a, const auto &b) {
return a.startTime < b.startTime;
});
for (const auto &attempt : tr->attempts) {
ss << "<tr><td valign=top>" << timePointToString(attempt.startTime)
<< "</td><td><pre>rc: " << attempt.rc
<< "\n\nstdout:\n--------------\n"
<< attempt.outputLog << "\n\nstderr:\n--------------\n"
<< attempt.errorLog << "\n\nexecutor:\n--------------\n"
<< attempt.executorLog << "</pre></td></tr>";
}
ss << "</table></body></html>\n";
}
response.send(Pistache::Http::Code::Ok, ss.str());
}
void Server::handleStopTask(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
addResponseHeaders(response);
if (!handleAuth(request))
return;
auto runID = request.param(":runID").as<DAGRunID>();
auto taskName = request.param(":taskName").as<std::string>();
std::shared_ptr<DAGRunner> runner{nullptr};
{
std::lock_guard<std::mutex> lock(runnerGuard_);
auto it = runners_.find(runID);
if (runners_.find(runID) != runners_.end()) {
runner = it->second;
}
}
if (runner) {
runner->stopTask(taskName);
}
REQ_RESPONSE(Ok, "");
}
void Server::handleGetTaskState(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
addResponseHeaders(response);
if (!handleAuth(request))
return;
@@ -685,28 +601,10 @@ namespace daggy::daggyd {
}
}
void Server::handleStopTask(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
if (!handleAuth(request))
return;
auto runID = request.param(":runID").as<DAGRunID>();
auto taskName = request.param(":taskName").as<std::string>();
{
std::lock_guard<std::mutex> lock(runnerGuard_);
auto it = runners_.find(runID);
if (runners_.find(runID) != runners_.end()) {
it->second->stopTask(taskName);
}
}
response.send(Pistache::Http::Code::Ok, "");
}
void Server::handleSetTaskState(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
addResponseHeaders(response);
if (!handleAuth(request))
return;
@@ -728,16 +626,59 @@ namespace daggy::daggyd {
void Server::handleReady(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
response.send(Pistache::Http::Code::Ok, R"({ "msg": "Ya like DAGs?"}\n)");
addResponseHeaders(response);
REQ_RESPONSE(Ok, "Ya like DAGs?");
}
void Server::handleRoot(const Pistache::Rest::Request &request,
void Server::handleStatic(const Pistache::Rest::Request &request,
Pistache::Http::ResponseWriter response)
{
addResponseHeaders(response);
std::string file = "index.html";
auto splats = request.splat();
if (!splats.empty()) {
file = splats[0].as<std::string>();
for (size_t i = 1; i < splats.size(); ++i)
file += "/" + splats[i].as<std::string>();
}
auto fn = staticAssetsDir_ / file;
auto ext = fn.extension();
if (!fs::exists(fn)) {
std::cout << "Can't find " << fn << std::endl;
REQ_RESPONSE(Not_Found, "");
}
std::string contentType;
if (ext == ".svg") {
contentType = "image/svg+xml";
}
else if (ext == ".html") {
contentType = "text/html";
}
else if (ext == ".css") {
contentType = "text/css";
}
else if (ext == ".js") {
contentType = "text/javascript";
}
else {
REQ_RESPONSE(Bad_Request, "I don't know how to serve that kind of file");
}
response.headers().remove<Pistache::Http::Header::ContentType>();
response.headers().add(
std::make_shared<Pistache::Http::Header::Location>("/v1/dagruns"));
response.send(Pistache::Http::Code::Moved_Permanently,
R"({ "msg": "These are the dags you are looking for"}\n)");
std::make_shared<Pistache::Http::Header::ContentType>(contentType));
std::stringstream ss;
std::ifstream ifh;
ifh.open(fn, std::ios::binary);
ss << ifh.rdbuf();
response.send(Pistache::Http::Code::Ok, ss.str());
}
/*

View File

@@ -26,7 +26,8 @@ TEST_CASE("rest_endpoint", "[server_basic]")
const size_t nDAGRunners = 10, nWebThreads = 10;
daggy::daggyd::Server server(listenSpec, logger, executor, nDAGRunners);
daggy::daggyd::Server server(listenSpec, logger, executor, nDAGRunners,
"/dev/null");
server.init(nWebThreads);
server.start();
@@ -165,12 +166,13 @@ TEST_CASE("Server cancels and resumes execution", "[server_resume]")
{
std::stringstream ss;
daggy::executors::task::ForkingTaskExecutor executor(10);
daggy::loggers::dag_run::OStreamLogger logger(ss);
daggy::loggers::dag_run::OStreamLogger logger(std::cout);
Pistache::Address listenSpec("localhost", Pistache::Port(0));
const size_t nDAGRunners = 10, nWebThreads = 10;
daggy::daggyd::Server server(listenSpec, logger, executor, nDAGRunners);
daggy::daggyd::Server server(listenSpec, logger, executor, nDAGRunners,
"/dev/null");
server.init(nWebThreads);
server.start();
@@ -249,7 +251,7 @@ TEST_CASE("Server cancels and resumes execution", "[server_resume]")
"PATCH");
// Wait for run to complete
std::this_thread::sleep_for(5s);
std::this_thread::sleep_for(3s);
REQUIRE(logger.getDAGRunState(runID) == +daggy::RunState::COMPLETED);
REQUIRE(fs::exists("resume_touch_c"));

View File

@@ -70,7 +70,6 @@ namespace daggy::daggyr {
runningTasks_;
std::mutex resultsGuard_;
std::unordered_map<TaskID, Future<std::string>>
results_;
std::unordered_map<TaskID, Future<std::string>> results_;
};
} // namespace daggy::daggyr

View File

@@ -29,9 +29,9 @@ namespace daggy {
~DAGRunner();
TaskDAG run();
void stopTask(const std::string &taskName);
void resetRunning();
void stop(bool kill = false, bool blocking = false);
void stopTask(const std::string &taskName);
private:
void collectFinished();

28
webui/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
webui/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["johnsoncodehk.volar", "johnsoncodehk.vscode-typescript-vue-plugin"]
}

19
webui/README.md Normal file
View File

@@ -0,0 +1,19 @@
# Daggy Web UI
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev -- --host
```
### Compile and Minify for Production
```sh
npm run build
```

3
webui/TODO.otl Normal file
View File

@@ -0,0 +1,3 @@
- Change the daggyd return so that everything related to a task is at a single location
- Add RunViewSortIndicators
- Add TaskView logic

20
webui/index.html Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Daggy</title>
<!--<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous"> -->
<!--<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous"> -->
<link rel="stylesheet" href="/simple.min.css">
<link rel="stylesheet" href="/daggyd.css">
</head>
<body width="100%">
<!--<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" crossorigin="anonymous"></script>-->
<main>
<div id="app"></div>
</main>
<script type="module" src="/src/main.js"></script>
</body>
</html>

35
webui/layout.otl Normal file
View File

@@ -0,0 +1,35 @@
App
: daggydURL
: refreshInterval
Settings
: -> daggydURL
: -> refreshInterval
: <- update-refresh-interval (newInterval)
: <- update-daggyd-url (newURL)
Explorer
: activeRunID
RunListFilter
: selectedStates
: minTime
: maxTime
: <- update-active-runid
RunList
: -> selectedStates
: -> minTime
: -> maxTime
: <- update-active-runid
: runs
SortedTable
: -> tables
: sortColumn
: sortDirection
RunButton
RunViewPanel
: -> activeRunID
: activeRun
RunViewFilter
RunView
TaskButton
TaskViewPanel
TaskView
TaskButton

4131
webui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
webui/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "daggyvue",
"version": "0.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5050"
},
"dependencies": {
"axios": "^0.26.0",
"vue": "^3.2.29"
},
"devDependencies": {
"@vitejs/plugin-vue": "^2.2.2",
"eslint": "^8.9.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-vue": "^8.5.0",
"vite": "^2.7.13"
}
}

15
webui/public/daggyd.css Normal file
View File

@@ -0,0 +1,15 @@
body {
max-width: 100%;
padding: 0px;
margin: 0px;
}
#app {
flex-flow: column wrap;
}
.svgicon {
height: 1em;
width: auto;
padding: 2px;
}

BIN
webui/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="icon-launch"><g><path class="primary" d="M14.57 6.96a2 2 0 0 1 2.47 2.47c.29.17.5.47.5.86v7.07a1 1 0 0 1-.3.71L13 22.31a1 1 0 0 1-1.7-.7v-3.58l-.49.19a1 1 0 0 1-1.17-.37 14.1 14.1 0 0 0-3.5-3.5 1 1 0 0 1-.36-1.16l.19-.48H2.39A1 1 0 0 1 1.7 11l4.24-4.24a1 1 0 0 1 .7-.3h7.08c.39 0 .7.21.86.5zM13.19 9.4l-2.15 2.15a3 3 0 0 1 .84.57 3 3 0 0 1 .57.84l2.15-2.15A2 2 0 0 1 13.2 9.4zm6.98-6.61a1 1 0 0 1 1.04 1.04c-.03.86-.13 1.71-.3 2.55-.47-.6-1.99-.19-2.55-.74-.55-.56-.14-2.08-.74-2.55.84-.17 1.7-.27 2.55-.3z"/><path class="secondary" d="M7.23 10.26A16.05 16.05 0 0 1 17.62 3.1a19.2 19.2 0 0 1 3.29 3.29 15.94 15.94 0 0 1-7.17 10.4 19.05 19.05 0 0 0-6.51-6.52zm-.86 5.5a16.2 16.2 0 0 1 1.87 1.87 1 1 0 0 1-.47 1.6c-.79.25-1.6.42-2.4.54a1 1 0 0 1-1.14-1.13c.12-.82.3-1.62.53-2.41a1 1 0 0 1 1.6-.47zm7.34-5.47a2 2 0 1 0 2.83-2.83 2 2 0 0 0-2.83 2.83z"/></g></svg>

After

Width:  |  Height:  |  Size: 926 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="icon-order-vertical"><path class="secondary" d="M7 18.59V9a1 1 0 0 1 2 0v9.59l2.3-2.3a1 1 0 0 1 1.4 1.42l-4 4a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.42L7 18.6z"/><path class="primary" d="M17 5.41V15a1 1 0 1 1-2 0V5.41l-2.3 2.3a1 1 0 1 1-1.4-1.42l4-4a1 1 0 0 1 1.4 0l4 4a1 1 0 0 1-1.4 1.42L17 5.4z"/></svg>

After

Width:  |  Height:  |  Size: 370 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="icon-search"><circle cx="10" cy="10" r="7" class="primary"/><path class="secondary" d="M16.32 14.9l1.1 1.1c.4-.02.83.13 1.14.44l3 3a1.5 1.5 0 0 1-2.12 2.12l-3-3a1.5 1.5 0 0 1-.44-1.14l-1.1-1.1a8 8 0 1 1 1.41-1.41zM10 16a6 6 0 1 0 0-12 6 6 0 0 0 0 12z"/></svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="icon-sort-ascending"><path class="secondary" d="M18 13v7a1 1 0 0 1-2 0v-7h-3a1 1 0 0 1-.7-1.7l4-4a1 1 0 0 1 1.4 0l4 4A1 1 0 0 1 21 13h-3z"/><path class="primary" d="M3 3h13a1 1 0 0 1 0 2H3a1 1 0 1 1 0-2zm0 4h9a1 1 0 0 1 0 2H3a1 1 0 1 1 0-2zm0 4h5a1 1 0 0 1 0 2H3a1 1 0 0 1 0-2z"/></svg>

After

Width:  |  Height:  |  Size: 353 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="icon-sort-decending"><path class="secondary" d="M6 11V4a1 1 0 1 1 2 0v7h3a1 1 0 0 1 .7 1.7l-4 4a1 1 0 0 1-1.4 0l-4-4A1 1 0 0 1 3 11h3z"/><path class="primary" d="M21 21H8a1 1 0 0 1 0-2h13a1 1 0 0 1 0 2zm0-4h-9a1 1 0 0 1 0-2h9a1 1 0 0 1 0 2zm0-4h-5a1 1 0 0 1 0-2h5a1 1 0 0 1 0 2z"/></svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="icon-trash"><path class="primary" d="M5 5h14l-.89 15.12a2 2 0 0 1-2 1.88H7.9a2 2 0 0 1-2-1.88L5 5zm5 5a1 1 0 0 0-1 1v6a1 1 0 0 0 2 0v-6a1 1 0 0 0-1-1zm4 0a1 1 0 0 0-1 1v6a1 1 0 0 0 2 0v-6a1 1 0 0 0-1-1z"/><path class="secondary" d="M8.59 4l1.7-1.7A1 1 0 0 1 11 2h2a1 1 0 0 1 .7.3L15.42 4H19a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2h3.59z"/></svg>

After

Width:  |  Height:  |  Size: 402 B

1
webui/public/simple.min.css vendored Normal file

File diff suppressed because one or more lines are too long

50
webui/src/App.vue Normal file
View File

@@ -0,0 +1,50 @@
<script>
import RunExplorer from './components/RunExplorer.vue'
import GlobalSettings from './components/GlobalSettings.vue'
export default {
data() {
return {
refreshSeconds: 15, // How often to refresh
daggydURL: window.location.origin,
}
},
methods: {
updateURL(url) {
this.daggydURL = url;
},
updateRefreshInterval(interval) {
this.refreshSeconds = interval;
},
},
components: {
GlobalSettings,
RunExplorer
}
}
</script>
<style>
select { max-width: 25%; }
input { max-width: 25%; }
</style>
<template>
<div id="settings">
<GlobalSettings
:daggydURL="daggydURL"
:refreshSeconds="refreshSeconds"
@update-refresh-interval="(interval) => this.updateRefreshInterval(interval)"
@update-daggyd-url="(url) => this.updateURL(url)"
/>
</div>
<div id="explorer">
<RunExplorer
:refreshSeconds="refreshSeconds"
:daggydURL="daggydURL"
@new-active-run="(runID) => this.activeRunID = runID"
/>
</div>
</template>

74
webui/src/assets/base.css Normal file
View File

@@ -0,0 +1,74 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
position: relative;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition: color 0.5s, background-color 0.5s;
line-height: 1.6;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69" xmlns:v="https://vecta.io/nano"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 308 B

View File

@@ -0,0 +1,19 @@
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"plugin:vue/essential",
"airbnb-base"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"vue"
],
"rules": {
}
}

View File

@@ -0,0 +1,40 @@
<script>
export default {
props: ['refreshSeconds', 'daggydURL'],
data() {
return {
interval: this.refreshSeconds,
url: this.daggydURL,
};
},
emits: ['update-refresh-interval', 'update-daggyd-url'],
computed: {
validRefreshIntervals() {
return [5, 10, 15, 30, 60, 300, 600];
},
isSelected(interval) {
return (interval === this.refreshSeconds ? 'selected' : 'unselected');
},
},
};
</script>
<template>
<details>
<summary>Global Settings</summary>
<label>
Daggy Base URL
<input @change="$emit('update-daggyd-url', url)" v-model="url"/>
</label>
<label>Refresh Interval (seconds)
<select @change="$emit('update-refresh-interval', interval)" v-model="interval">
<option v-for="interval in validRefreshIntervals"
:key="interval"
:value="interval"
>
{{ interval }} Seconds
</option>
</select>
</label>
</details>
</template>

View File

@@ -0,0 +1,245 @@
<script>
import { ALL_STATES } from '../defs.js'
import SortableTableHeader from './SortableTableHeader.vue';
import TaskDetails from './TaskDetails.vue';
export default {
props: ['daggydURL', 'refreshSeconds', 'activeRunID'],
components: { SortableTableHeader, TaskDetails },
data() {
return {
sortCol: 'lastUpdate',
sortAscending: false,
run: null,
activeTaskName: null,
filterStates: ALL_STATES.map((x) => x.name),
filterMinTime: 0,
filterMaxTime: 2000000000000000000,
filterRegex: '.*',
columns: [
{ name: 'taskName', title: 'Name', sortable: true },
{ name: 'taskState', title: 'State', sortable: true },
{ name: 'startTime', title: 'Last Update', sortable: true },
{ name: 'duration', title: 'Duration (s)', sortable: true },
{ name: 'attempts', title: '# of Attempts', sortable: true },
{ name: 'controls', title: 'Controls', sortable: false },
],
};
},
watch: {
activeRunID() {
this.fetchRun();
},
},
computed: {
tasks() {
if (this.run === null) {
return [];
}
const tasks = Object.keys(this.run.tasks)
.map((taskName) => {
let startTime = 0;
let stopTime = 0;
let duration = 0;
const attempts = (taskName in this.run.taskAttempts
? this.run.taskAttempts[taskName]
: []);
if (attempts.length > 0) {
const firstAttempt = attempts[0];
const lastAttempt = attempts[attempts.length - 1];
startTime = firstAttempt.startTime;
stopTime = lastAttempt.stopTime;
duration = lastAttempt.stopTime - firstAttempt.startTime;
}
return {
name: taskName,
state: this.run.taskStates[taskName],
startTime,
lastUpdate: stopTime,
attempts: attempts.length,
duration: (duration / 1e9).toFixed(2),
};
});
return tasks
.filter(this.filter)
.sort(this.sorter);
},
allStates() {
return ALL_STATES;
},
activeTask() {
if (this.activeTaskName === null) {
return null;
}
const name = this.activeTaskName;
const attempts = (name in this.run.taskAttempts ? this.run.taskAttempts[name] : []);
const augAttempts = attempts
.sort((a, b) => a.startTime - b.startTime)
.map((a, i) => {
a.id = i + 1;
return a;
});
const obj = {
name,
task: this.run.tasks[name],
attempts: augAttempts,
state: this.run.taskStates[name],
};
return obj;
},
},
methods: {
isNumeric(x) {
const p = parseFloat(x);
return !Number.isNaN(p) && Number.isFinite(p);
},
sorter(a, b) {
const aa = a[this.sortCol];
const bb = b[this.sortCol];
let ret = 0;
if (this.isNumeric(aa) && this.isNumeric(bb)) {
ret = aa - bb;
} else if (aa < bb) {
ret = -1;
} else if (bb === aa) {
ret = 0;
} else {
ret = 1;
}
if (!this.sortAscending) {
ret *= -1;
}
return ret;
},
filter(task) {
const reFilter = new RegExp(this.filterRegex, '');
return (this.filterStates.indexOf(task.state) > -1)
&& (task.startTime >= this.filterMinTime)
&& (task.lastUpdate <= this.filterMaxTime)
&& (reFilter.test(task.name));
},
setSortCol(name) {
if (this.sortCol === name) {
this.sortAscending = !this.sortAscending;
} else {
this.sortCol = name;
this.sortAscending = true;
}
},
// Root tags
// runID, tag, tasks, taskStates, taskAttempts
async fetchRun() {
if (this.activeRunID === null) { return; }
const resp = await fetch(`${this.daggydURL}/v1/dagrun/${this.activeRunID}`);
this.run = await resp.json();
},
killTask(taskName) {
fetch(`${this.daggydURL}/v1/dagrun/${this.activeRunID}/task/${taskName}`, { method: 'delete' });
},
retryTask(taskName) {
fetch(`${this.daggydURL}/v1/dagrun/${this.activeRunID}/task/${taskName}/state/QUEUED`, { method: 'patch' });
},
update() {
this.fetchRun();
setTimeout(() => {
this.update();
}, this.refreshSeconds * 1000);
},
setActiveTask(taskName) {
this.activeTaskName = taskName;
},
},
mounted() {
this.update();
},
};
</script>
<style>
input {
max-width: 25%;
}
label {
margin: 5px;
}
</style>
<template>
<div class="run-view">
<TaskDetails :task="activeTask" />
<div id="run-view-filter">
<details>
<summary>Task Filter</summary>
<div>
<label>
Min Time
<input v-model.lazy="filterMinTime"/>
</label>
<label>
Max Time
<input v-model.lazy="filterMaxTime"/>
</label>
<label>
Task Name Regex
<input v-model.lazy="filterRegex"/>
</label>
</div>
<div>
<label v-for="state in allStates" :key="state.name">
{{ state.display }}
<input type="checkbox" :value="state.name" v-model="filterStates">
</label>
</div>
</details>
</div>
<div id="run-view-data">
<table>
<thead>
<tr>
<th v-for="col in columns" :key="col.name">
<SortableTableHeader
:title="col.title"
:sorted="col.name == this.sortCol"
:ascending="sortAscending"
:sortable="col.sortable"
@update-sort-column="setSortCol(col.name)"
/>
</th>
</tr>
</thead>
<tbody>
<tr v-for="task in tasks" :key="task.name">
<td>{{task.name}}</td>
<td>{{task.state}}</td>
<td>{{task.startTime}}</td>
<td>{{task.duration}}</td>
<td>{{task.attempts}}</td>
<td>
<img class='svgicon'
src='/icon-search.svg'
@click="setActiveTask(task.name)"/>
<img class='svgicon' src='/icon-trash.svg' @click="killTask(task.name)"/>
<img class='svgicon' src='/icon-launch.svg' @click="retryTask(task.name)"/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script>
import RunList from './RunList.vue';
import RunDetails from './RunDetails.vue';
export default {
props: ['refreshSeconds', 'daggydURL'],
data() {
return {
activeRunID: null,
activeTaskName: null,
};
},
components: {
RunList,
RunDetails,
},
methods: {
setActiveRunID(runID) {
this.activeRunID = runID;
},
},
};
</script>
<template>
<div id="explorer">
<RunList
:daggydURL="daggydURL"
:refreshSeconds="refreshSeconds"
@update-active-runid="(runID) => setActiveRunID(runID)"
/>
<RunDetails
:daggydURL="daggydURL"
:refreshSeconds="refreshSeconds"
:activeRunID="activeRunID"
/>
</div>
</template>

View File

@@ -0,0 +1,208 @@
<script>
import { ALL_STATES, defaultCountHandler } from '../defs.js'
import SortableTableHeader from './SortableTableHeader.vue';
// import SortIndicator from './SortIndicator.vue';
// import RunButton from './RunButton.vue';
// components: { RunButton, SortIndicator },
export default {
props: ['daggydURL', 'refreshSeconds'],
components: { SortableTableHeader },
data() {
return {
sortCol: 'lastUpdate', // Which column to sort view on
sortAscending: false,
runs: [],
filterStates: ALL_STATES.map((x) => x.name),
filterMinTime: 0,
filterMaxTime: 2000000000000000000,
filterRegex: '.*',
columns: [
{ name: 'runID', title: 'Run ID', sortable: true },
{ name: 'tag', title: 'Tag', sortable: true },
{ name: 'state', title: 'State', sortable: true },
{ name: 'progress', title: 'Progress', sortable: true },
{ name: 'startTime', title: 'Start Time', sortable: true },
{ name: 'lastUpdate', title: 'LastUpdate', sortable: true },
{ name: 'queued', title: 'Queued', sortable: true },
{ name: 'running', title: 'Running', sortable: true },
{ name: 'errored', title: 'Errored', sortable: true },
{ name: 'completed', title: 'Completed', sortable: true },
{ name: 'controls', title: 'Controls', sortable: false },
],
};
},
computed: {
runList() {
return this.runs
.filter((run) => this.runFilter(run))
.map((r) => {
const run = r;
run.nTasks = Object
.values(run.taskCounts)
.reduce((prev, cur) => prev + cur, 0);
run.task_states = new Proxy(run.taskCounts, defaultCountHandler);
run.progress = run.task_states.COMPLETED / run.nTasks;
return run;
})
.sort((a, b) => this.sorter(a, b));
},
allStates() {
return ALL_STATES;
},
},
methods: {
isNumeric(x) {
const p = parseFloat(x);
return !Number.isNaN(p) && Number.isFinite(p);
},
sorter(a, b) {
const aa = a[this.sortCol];
const bb = b[this.sortCol];
let ret = 0;
if (this.isNumeric(aa) && this.isNumeric(bb)) {
ret = aa - bb;
} else if (aa < bb) {
ret = -1;
} else if (bb === aa) {
ret = 0;
} else {
ret = 1;
}
if (!this.sortAscending) {
ret *= -1;
}
return ret;
},
runFilter(run) {
const reFilter = new RegExp(this.filterRegex, '');
return (this.filterStates.indexOf(run.state) > -1)
&& (run.startTime >= this.filterMinTime)
&& (run.lastUpdate <= this.filterMaxTime)
&& (reFilter.test(run.tag));
},
setSortCol(name) {
if (this.sortCol === name) {
this.sortAscending = !this.sortAscending;
} else {
this.sortCol = name;
this.sortAscending = true;
}
},
killRun(runID) {
fetch(`${this.daggydURL}/v1/dagrun/${runID}`, { method: 'delete' });
},
retryRun(runID) {
fetch(`${this.daggydURL}/v1/dagrun/${runID}/state/QUEUED`, { method: 'patch' });
},
async fetchRuns() {
const res = await fetch(`${this.daggydURL}/v1/dagruns?all=1`);
this.runs = await res.json();
},
update() {
this.fetchRuns();
setTimeout(() => {
this.update();
}, this.refreshSeconds * 1000);
},
},
mounted() {
this.update();
},
};
</script>
<template>
<div id="run-list">
<div id="run-list-filter">
<details>
<summary>Run Filter</summary>
<div>
<label>
Start Time
<input v-model.lazy="filterMinTime"/>
</label>
<label>
Time
<input v-model.lazy="filterMaxTime"/>
</label>
<label>
Task Name Regex
<input v-model.lazy="filterRegex"/>
</label>
</div>
<div>
<label v-for="state in allStates" :key="state.name">
{{ state.display }}
<input type="checkbox" :value="state.name" v-model="filterStates">
</label>
</div>
</details>
</div>
<div id="run-list-data">
<table class="table">
<thead>
<tr>
<th v-for="col in columns" :key="col.name">
<SortableTableHeader
:title="col.title"
:sorted="col.name == this.sortCol"
:ascending="sortAscending"
:sortable="col.sortable"
@update-sort-column="setSortCol(col.name)"
/>
</th>
</tr>
</thead>
<tbody>
<tr v-for="run in runList" :key="run.runID">
<td>{{run.runID}}</td>
<td>{{run.tag}}</td>
<td>{{run.state}}</td>
<td><progress :value="run.progress"></progress></td>
<td>{{run.startTime}}</td>
<td>{{run.lastUpdate}}</td>
<td>{{run.task_states["QUEUED"]}}</td>
<td>{{run.task_states["RUNNING"]}}</td>
<td>{{run.task_states["ERRORED"]}}</td>
<td>{{run.task_states["COMPLETED"]}}</td>
<td>
<a href="#">
<img
class='svgicon'
src='/icon-search.svg'
@click="$emit('update-active-runid', run.runID)"/>
</a>
<a href="#">
<img class='svgicon' src='/icon-trash.svg' @click="killRun(run.runID)"/>
</a>
<a href="#">
<img class='svgicon' src='/icon-launch.svg' @click="retryRun(run.runID)"/>
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<style>
.svgicon {
height: 1em;
width: auto;
}
</style>

View File

@@ -0,0 +1,43 @@
<script>
export default {
props: ['title', 'sorted', 'ascending', 'sortable'],
emits: ['update-sort-column'],
computed: {
src() {
let loc = '/icon-';
if (this.sorted) {
if (this.ascending) {
loc += 'sort-ascending';
} else {
loc += 'sort-decending';
}
} else {
loc += 'order-vertical';
}
loc += '.svg';
return loc;
},
},
};
</script>
<style>
.svgicon {
height: 1em;
width: auto;
}
</style>
<template>
<div>
{{ title }}
<a href="#">
<img
v-if="sortable"
class='svgicon'
:src="src"
:alt="title"
@click="$emit('update-sort-column')"/>
</a>
</div>
</template>

View File

@@ -0,0 +1,60 @@
<script>
export default {
props: ['task'],
};
</script>
<template>
<div id="task-details" v-if="task !== null">
<details open>
<summary>Task Details for {{ task.name }}</summary>
<table>
<tbody>
<tr>
<th>Name</th>
<td>{{ task.name }}</td>
</tr>
<tr>
<th>State</th>
<td>{{ task.state }}</td>
</tr>
<tr>
<th>Definition</th>
<td><pre>{{ JSON.stringify(task.task, null, 2) }}</pre></td>
</tr>
</tbody>
</table>
<details v-for="attempt in task.attempts" :key="attempt.id" open>
<summary>Attempt {{attempt.id}}</summary>
<table>
<tbody>
<tr>
<th>Start Time</th>
<td>{{ attempt.startTime }}</td>
</tr>
<tr>
<th>Stop Time</th>
<td>{{ attempt.stopTime }}</td>
</tr>
<tr>
<th>Return Code</th>
<td>{{ attempt.rc }}</td>
</tr>
<tr>
<th>Standard Out</th>
<td><pre>{{ attempt.outputLog }}</pre></td>
</tr>
<tr>
<th>Standard Error</th>
<td><pre>{{ attempt.errorLog }}</pre></td>
</tr>
<tr>
<th>Executor Log</th>
<td><pre>{{ attempt.ExecutorLog }}</pre></td>
</tr>
</tbody>
</table>
</details>
</details>
</div>
</template>

17
webui/src/defs.js Normal file
View File

@@ -0,0 +1,17 @@
import { reactive } from 'vue';
export const ALL_STATES = [
{ name: 'QUEUED', display: 'Queued' },
{ name: 'RUNNING', display: 'Running' },
{ name: 'ERRORED', display: 'Errored' },
{ name: 'COMPLETED', display: 'Completed' },
{ name: 'KILLED', display: 'Killed' },
];
export const defaultCountHandler = {
get(target, name) {
return name in target ? target[name] : 0;
},
};

4
webui/src/main.js Normal file
View File

@@ -0,0 +1,4 @@
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).mount('#app')

14
webui/vite.config.js Normal file
View File

@@ -0,0 +1,14 @@
import { fileURLToPath, URL } from 'url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
}
})