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
11
README.md
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["johnsoncodehk.volar", "johnsoncodehk.vscode-typescript-vue-plugin"]
|
||||
}
|
||||
19
webui/README.md
Normal 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
@@ -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
@@ -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
@@ -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
21
webui/package.json
Normal 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
@@ -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
|
After Width: | Height: | Size: 4.2 KiB |
1
webui/public/icon-launch.svg
Normal 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 |
1
webui/public/icon-order-vertical.svg
Normal 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 |
1
webui/public/icon-search.svg
Normal 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 |
1
webui/public/icon-sort-ascending.svg
Normal 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 |
1
webui/public/icon-sort-decending.svg
Normal 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 |
1
webui/public/icon-trash.svg
Normal 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
50
webui/src/App.vue
Normal 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
@@ -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;
|
||||
}
|
||||
1
webui/src/assets/logo.svg
Normal 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 |
19
webui/src/components/.eslintrc.js
Normal 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": {
|
||||
}
|
||||
}
|
||||
40
webui/src/components/GlobalSettings.vue
Normal 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>
|
||||
245
webui/src/components/RunDetails.vue
Normal 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>
|
||||
38
webui/src/components/RunExplorer.vue
Normal 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>
|
||||
208
webui/src/components/RunList.vue
Normal 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>
|
||||
43
webui/src/components/SortableTableHeader.vue
Normal 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>
|
||||
60
webui/src/components/TaskDetails.vue
Normal 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
@@ -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
@@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
14
webui/vite.config.js
Normal 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))
|
||||
}
|
||||
}
|
||||
})
|
||||