[task scheduler] Add task-sk

Change-Id: Ie5d702e7abbf5104db980fd9d31f332bc4e1a8b7
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/323103
Commit-Queue: Eric Boren <borenet@google.com>
Reviewed-by: Leandro Lovisolo <lovisolo@google.com>
Reviewed-by: Weston Tracey <westont@google.com>
diff --git a/task_scheduler/modules/job-trigger-sk/job-trigger-sk-demo.ts b/task_scheduler/modules/job-trigger-sk/job-trigger-sk-demo.ts
index da44d1c..67335fb 100644
--- a/task_scheduler/modules/job-trigger-sk/job-trigger-sk-demo.ts
+++ b/task_scheduler/modules/job-trigger-sk/job-trigger-sk-demo.ts
@@ -1,5 +1,6 @@
-import { SetupMocks } from '../rpc-mock';
-
-SetupMocks();
-
 import './index';
+import { JobTriggerSk } from './job-trigger-sk';
+import { FakeTaskSchedulerService } from '../rpc-mock';
+
+const ele = <JobTriggerSk>document.querySelector("job-trigger-sk")!;
+ele.rpc = new FakeTaskSchedulerService();
diff --git a/task_scheduler/modules/job-trigger-sk/job-trigger-sk.ts b/task_scheduler/modules/job-trigger-sk/job-trigger-sk.ts
index 22c9ace..16958466 100644
--- a/task_scheduler/modules/job-trigger-sk/job-trigger-sk.ts
+++ b/task_scheduler/modules/job-trigger-sk/job-trigger-sk.ts
@@ -80,7 +80,7 @@
   `;
 
   private jobs: TriggerJob[] = [{jobName: "", commitHash: ""}];
-  private rpc: TaskSchedulerService = GetTaskSchedulerService(this);
+  private _rpc: TaskSchedulerService | null = null;
   private triggeredJobs: TriggeredJob[] = [];
 
   constructor() {
@@ -92,6 +92,13 @@
     this._render();
   }
 
+  get rpc(): TaskSchedulerService | null {
+    return this._rpc;
+  }
+  set rpc(rpc: TaskSchedulerService | null) {
+    this._rpc = rpc;
+  }
+
   private addJob() {
     this.jobs.push({jobName: "", commitHash: ""});
     this._render();
@@ -103,6 +110,9 @@
   }
 
   private triggerJobs() {
+    if (!this.rpc) {
+      return;
+    }
     const req: TriggerJobsRequest = {
       jobs: this.jobs,
     }
diff --git a/task_scheduler/modules/rpc-mock/fake-data.ts b/task_scheduler/modules/rpc-mock/fake-data.ts
index e640794..915395d 100644
--- a/task_scheduler/modules/rpc-mock/fake-data.ts
+++ b/task_scheduler/modules/rpc-mock/fake-data.ts
@@ -84,7 +84,7 @@
   finishedAt: "2019-02-19T13:27:14.669965Z",
   id: "QT5J8rNsgnumXH67JwTr",
   isolatedOutput: "f43fcadbbffe79a92f5da6792ed992581aa620bd",
-  jobs: [job1ID, job2ID],
+  jobs: [job1ID/*, job2ID*/],
   maxAttempts: 2,
   parentTaskIds: [
     task0.id,
diff --git a/task_scheduler/modules/rpc-mock/index.ts b/task_scheduler/modules/rpc-mock/index.ts
index eedc481..d812498 100644
--- a/task_scheduler/modules/rpc-mock/index.ts
+++ b/task_scheduler/modules/rpc-mock/index.ts
@@ -12,7 +12,6 @@
   GetSkipTaskRulesRequest,
   GetSkipTaskRulesResponse,
   Job,
-  MockRPCsForTesting,
   SearchJobsRequest,
   SearchJobsResponse,
   SearchTasksRequest,
@@ -22,23 +21,25 @@
   TriggerJobsRequest,
   TriggerJobsResponse,
 } from '../rpc';
+import { job1, task0, task1, task2, task3, task4 } from './fake-data';
 
 export * from './fake-data';
 
 /**
- * SetupMocks changes the rpc module to use the mocked client from this module.
- */
-export function SetupMocks() {
-  MockRPCsForTesting(new FakeTaskSchedulerService())
-}
-
-/**
  * FakeTaskSchedulerService provides a mocked implementation of
  * TaskSchedulerService.
  */
-class FakeTaskSchedulerService implements TaskSchedulerService {
-  private jobs: {[key:string]:Job} = {};
-  private tasks: {[key:string]:Task} = {};
+export class FakeTaskSchedulerService implements TaskSchedulerService {
+  private jobs: {[key:string]:Job} = {
+    [job1.id]: job1,
+  };
+  private tasks: {[key:string]:Task} = {
+    [task0.id]: task0,
+    [task1.id]: task1,
+    [task2.id]: task2,
+    [task3.id]: task3,
+    [task4.id]: task4,
+  };
   private jobID: number = 0;
 
   triggerJobs(triggerJobsRequest: TriggerJobsRequest): Promise<TriggerJobsResponse> {
@@ -59,7 +60,9 @@
     return new Promise((_, reject) => { reject("not implemented")});
   }
   getTask(getTaskRequest: GetTaskRequest): Promise<GetTaskResponse> {
-    return new Promise((_, reject) => { reject("not implemented")});
+    return Promise.resolve({
+      task: this.tasks[getTaskRequest.id],
+    });
   }
   searchTasks(searchTasksRequest: SearchTasksRequest): Promise<SearchTasksResponse> {
     return new Promise((_, reject) => { reject("not implemented")});
diff --git a/task_scheduler/modules/rpc/index.ts b/task_scheduler/modules/rpc/index.ts
index cb5195c..66a279d 100644
--- a/task_scheduler/modules/rpc/index.ts
+++ b/task_scheduler/modules/rpc/index.ts
@@ -1,48 +1,39 @@
 import {
-    TaskSchedulerService,
-    TaskSchedulerServiceClient,
-  } from "./rpc";
+  TaskSchedulerService,
+  TaskSchedulerServiceClient,
+} from "./rpc";
 
-  export * from "./rpc";
+export * from "./rpc";
 
+/**
+ * GetTaskSchedulerService returns an TaskSchedulerService implementation
+ * which dispatches events indicating when requests have started and ended.
+ *
+ * @param ele The parent element, used to dispatch events.
+ */
+export function GetTaskSchedulerService(ele: HTMLElement): TaskSchedulerService {
   const host = window.location.protocol + "//" + window.location.host;
   let rpcClient: TaskSchedulerService = new TaskSchedulerServiceClient(host, window.fetch.bind(window));
-
-  /**
-   * GetTaskSchedulerService returns an TaskSchedulerService implementation
-   * which dispatches events indicating when requests have started and ended.
-   *
-   * @param ele The parent element, used to dispatch events.
-   */
-  export function GetTaskSchedulerService(ele: HTMLElement): TaskSchedulerService {
-    const handler = {
-      get(target: any, propKey: any, receiver: any) {
-        const origMethod = target[propKey];
-        return function(...args: any[]) {
-          ele.dispatchEvent(new CustomEvent('begin-task', { bubbles: true }));
-          return origMethod.apply(rpcClient, args).then((v: any) => {
-            ele.dispatchEvent(new CustomEvent('end-task', { bubbles: true }));
-            return v;
-          }).catch((err: any) => {
-            ele.dispatchEvent(new CustomEvent('fetch-error', {
-              detail: {
-                error: err,
-                loading: propKey,
-              },
-              bubbles: true,
-            }));
-            Promise.reject(err);
-          });
-        }
+  const handler = {
+    get(target: any, propKey: any, receiver: any) {
+      const origMethod = target[propKey];
+      return function(...args: any[]) {
+        ele.dispatchEvent(new CustomEvent('begin-task', { bubbles: true }));
+        return origMethod.apply(rpcClient, args).then((v: any) => {
+          ele.dispatchEvent(new CustomEvent('end-task', { bubbles: true }));
+          return v;
+        }).catch((err: any) => {
+          ele.dispatchEvent(new CustomEvent('fetch-error', {
+            detail: {
+              error: err,
+              loading: propKey,
+            },
+            bubbles: true,
+          }));
+          Promise.reject(err);
+        });
       }
-    };
-    return new Proxy(rpcClient, handler);
-  }
-
-  /**
-   * MockRPCsForTesting switches this module to use the given
-   * TaskSchedulerService for testing purposes.
-   */
-  export function MockRPCsForTesting(repl: TaskSchedulerService) {
-    rpcClient = repl;
-  }
\ No newline at end of file
+    }
+  };
+  return new Proxy(rpcClient, handler);
+}
diff --git a/task_scheduler/modules/task-graph-sk/task-graph-sk.ts b/task_scheduler/modules/task-graph-sk/task-graph-sk.ts
index 7ea920c..c73e120 100644
--- a/task_scheduler/modules/task-graph-sk/task-graph-sk.ts
+++ b/task_scheduler/modules/task-graph-sk/task-graph-sk.ts
@@ -5,7 +5,7 @@
  * Displays a graph which shows the relationship between a set of tasks.
  */
 
-import { html, render, svg, TemplateResult } from 'lit-html';
+import { render, svg } from 'lit-html';
 import { define } from 'elements-sk/define';
 
 import {
diff --git a/task_scheduler/modules/task-sk/index.ts b/task_scheduler/modules/task-sk/index.ts
new file mode 100644
index 0000000..7ee8f70
--- /dev/null
+++ b/task_scheduler/modules/task-sk/index.ts
@@ -0,0 +1,2 @@
+import './task-sk';
+import './task-sk.scss';
diff --git a/task_scheduler/modules/task-sk/task-sk-demo.html b/task_scheduler/modules/task-sk/task-sk-demo.html
new file mode 100644
index 0000000..e5feeacf
--- /dev/null
+++ b/task_scheduler/modules/task-sk/task-sk-demo.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>task-sk</title>
+  <meta charset="utf-8" />
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body>
+  <h1>task-sk</h1>
+  <task-sk></task-sk>
+</body>
+</html>
diff --git a/task_scheduler/modules/task-sk/task-sk-demo.ts b/task_scheduler/modules/task-sk/task-sk-demo.ts
new file mode 100644
index 0000000..b11e8fe
--- /dev/null
+++ b/task_scheduler/modules/task-sk/task-sk-demo.ts
@@ -0,0 +1,7 @@
+import { task2, FakeTaskSchedulerService } from '../rpc-mock';
+import './index';
+import { TaskSk } from './task-sk';
+
+const ele = <TaskSk>document.querySelector('task-sk')!;
+ele.rpc = new FakeTaskSchedulerService();
+ele.taskID = task2.id;
diff --git a/task_scheduler/modules/task-sk/task-sk.scss b/task_scheduler/modules/task-sk/task-sk.scss
new file mode 100644
index 0000000..cde3132
--- /dev/null
+++ b/task_scheduler/modules/task-sk/task-sk.scss
@@ -0,0 +1,3 @@
+task-sk {
+  font-family: Arial;
+}
diff --git a/task_scheduler/modules/task-sk/task-sk.ts b/task_scheduler/modules/task-sk/task-sk.ts
new file mode 100644
index 0000000..83fe6df
--- /dev/null
+++ b/task_scheduler/modules/task-sk/task-sk.ts
@@ -0,0 +1,242 @@
+/**
+ * @module modules/task-sk
+ * @description <h2><code>task-sk</code></h2>
+ *
+ * Displays basic information about a task, including a graph display of its
+ * context within the job(s) which utilize it.
+ *
+ * @attr {string} swarming - URL of the Swarming server.
+ * @attr {string} taskID - Unique ID of the task to display.
+ */
+import { diffDate } from 'common-sk/modules/human';
+import { define } from 'elements-sk/define';
+import 'elements-sk/styles/table';
+import { html } from 'lit-html';
+import { ElementSk } from '../../../infra-sk/modules/ElementSk';
+import { $$ } from 'common-sk/modules/dom';
+import {
+  GetTaskSchedulerService,
+  Job,
+  Task,
+  TaskSchedulerService,
+  TaskStatus,
+  GetTaskResponse,
+  GetJobResponse,
+} from '../rpc';
+import { TaskGraphSk } from '../task-graph-sk/task-graph-sk';
+import '../task-graph-sk';
+import '../../../infra-sk/modules/human-date-sk';
+
+const taskStatusToTextColor = new Map<TaskStatus, [string, string]>();
+taskStatusToTextColor.set(TaskStatus.TASK_STATUS_PENDING, [
+  'pending',
+  'rgb(255, 255, 255)',
+]);
+taskStatusToTextColor.set(TaskStatus.TASK_STATUS_RUNNING, [
+  'running',
+  'rgb(248, 230, 180)',
+]);
+taskStatusToTextColor.set(TaskStatus.TASK_STATUS_SUCCESS, [
+  'succeeded',
+  'rgb(209, 228, 188)',
+]);
+taskStatusToTextColor.set(TaskStatus.TASK_STATUS_FAILURE, [
+  'failed',
+  'rgb(217, 95, 2)',
+]);
+taskStatusToTextColor.set(TaskStatus.TASK_STATUS_MISHAP, [
+  'mishap',
+  'rgb(117, 112, 179)',
+]);
+
+export class TaskSk extends ElementSk {
+  private static template = (ele: TaskSk) => html`
+    <div class="container">
+      <h2>Task Information</h2>
+      <table>
+        <tr>
+          <td>ID</td>
+          <td>${ele.task!.id}</td>
+        </tr>
+        <tr>
+          <td>Name</td>
+          <td>${ele.task!.taskKey!.name}</td>
+        </tr>
+        <tr>
+          <td>Status</td>
+          <td style="background-color:${ele.statusColor}">${ele.statusText}</td>
+        </tr>
+        <tr>
+          <td>Created</td>
+          <td>
+            <human-date-sk .date="${ele.task!.createdAt!}"></human-date-sk>
+          </td>
+        </tr>
+        ${ele.task!.finishedAt
+          ? html`
+              <tr>
+                <td>Finished</td>
+                <td>
+                  <human-date-sk
+                    .date="${ele.task!.finishedAt!}"
+                  ></human-date-sk>
+                </td>
+              </tr>
+            `
+          : html``}
+        <tr>
+          <td>Duration</td>
+          <td>${ele.duration}</td>
+        </tr>
+        <tr>
+          <td>Repo</td>
+          <td>
+            <a href="${ele.task!.taskKey!.repoState!.repo}" target="_blank"
+              >${ele.task!.taskKey!.repoState!.repo}</a
+            >
+          </td>
+        </tr>
+        <tr>
+          <td>Revision</td>
+          <td>
+            <a href="${ele.revisionLink}" target="_blank"
+              >${ele.task!.taskKey!.repoState!.revision}</a
+            >
+          </td>
+        </tr>
+        <tr>
+          <td>Swarming Task</td>
+          <td>
+            <a href="${ele.swarmingTaskLink}" target="_blank"
+              >${ele.task!.swarmingTaskId}</a
+            >
+          </td>
+        </tr>
+        <tr>
+          <td>Jobs</td>
+          <td>
+            ${ele.jobs.map(
+              (job: Job) => html` <a href="/job/${job.id}">${job.name}</a> `
+            )}
+          </td>
+        </tr>
+        ${ele.isTryJob
+          ? html`
+              <tr>
+                <td>Codereview Link</td>
+                <td>
+                  <a href="${ele.codereviewLink}" target="_blank"
+                    >${ele.codereviewLink}</a
+                  >
+                </td>
+              </tr>
+              <tr>
+                <td>Codereview Server</td>
+                <td>${ele.task!.taskKey!.repoState!.patch!.server}</td>
+              </tr>
+              <tr>
+                <td>Issue</td>
+                <td>${ele.task!.taskKey!.repoState!.patch!.issue}</td>
+              </tr>
+              <tr>
+                <td>Patchset</td>
+                <td>${ele.task!.taskKey!.repoState!.patch!.patchset}</td>
+              </tr>
+            `
+          : html``}
+      </table>
+    </div>
+
+    <div class="container">
+      <h2>Context</h2>
+      <task-graph-sk></task-graph-sk>
+    </div>
+  `;
+
+  private codereviewLink: string = '';
+  private duration: string = '';
+  private isTryJob: boolean = false;
+  private jobs: Job[] = [];
+  private revisionLink: string = '';
+  private _rpc: TaskSchedulerService | null = null;
+  private statusColor: string = '';
+  private statusText: string = '';
+  private swarmingTaskLink: string = '';
+  private task: Task | null = null;
+
+  constructor() {
+    super(TaskSk.template);
+  }
+
+  get taskID(): string {
+    return this.getAttribute('task') || '';
+  }
+
+  set taskID(taskID: string) {
+    this.setAttribute('task', taskID);
+    this.reload();
+  }
+
+  get swarming(): string {
+    return this.getAttribute('swarming') || '';
+  }
+
+  set swarming(swarming: string) {
+    this.setAttribute('swarming', swarming);
+  }
+
+  get rpc(): TaskSchedulerService | null {
+    return this._rpc;
+  }
+
+  set rpc(rpc: TaskSchedulerService | null) {
+    this._rpc = rpc;
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.rpc = GetTaskSchedulerService(this);
+    this.reload();
+  }
+
+  private reload() {
+    if (!this.taskID || !this.rpc) {
+      return;
+    }
+    this.rpc!.getTask({
+      id: this.taskID,
+      includeStats: false,
+    }).then((taskResp: GetTaskResponse) => {
+      this.task = taskResp.task!;
+      const start = new Date(this.task.createdAt!);
+      const end = this.task.finishedAt
+        ? new Date(this.task.finishedAt)
+        : new Date();
+      this.duration = diffDate(start.getTime(), end.getTime());
+      const rs = this.task.taskKey!.repoState!;
+      this.revisionLink = `${rs.repo}/+show/${rs.revision}`;
+      if (rs.patch) {
+        this.isTryJob = true;
+        const p = rs.patch!;
+        this.codereviewLink = `${p.server}/c/${p.issue}/${p.patchset}`;
+      }
+      [this.statusText, this.statusColor] = taskStatusToTextColor.get(
+        this.task.status
+      )!;
+      this.swarmingTaskLink = `https://${this.swarming}/task?id=${this.task.swarmingTaskId}`;
+      const jobReqs = this.task.jobs!.map((jobID: string) =>
+        this.rpc!.getJob({ id: jobID })
+      );
+      Promise.all(jobReqs).then((jobResps: GetJobResponse[]) => {
+        this.jobs = jobResps
+          .map((resp: GetJobResponse) => resp.job!)
+          .sort((a: Job, b: Job) => (a.name < b.name ? -1 : 1));
+        this._render();
+        const graph = $$<TaskGraphSk>('task-graph-sk', this);
+        graph?.draw(this.jobs, this.swarming, taskResp.task);
+      });
+    });
+  }
+}
+
+define('task-sk', TaskSk);
diff --git a/task_scheduler/modules/task-sk/task-sk_puppeteer_test.ts b/task_scheduler/modules/task-sk/task-sk_puppeteer_test.ts
new file mode 100644
index 0000000..0c2bcb0
--- /dev/null
+++ b/task_scheduler/modules/task-sk/task-sk_puppeteer_test.ts
@@ -0,0 +1,24 @@
+import * as path from 'path';
+import {
+  setUpPuppeteerAndDemoPageServer,
+  takeScreenshot,
+} from '../../../puppeteer-tests/util';
+import { TaskSk } from './task-sk';
+import { task2, FakeTaskSchedulerService } from '../rpc-mock';
+
+describe('task-sk', () => {
+  const testBed = setUpPuppeteerAndDemoPageServer(
+    path.join(__dirname, '..', '..', 'webpack.config.ts')
+  );
+
+  beforeEach(async () => {
+    await testBed.page.goto(`${testBed.baseUrl}/dist/task-sk.html`);
+    await testBed.page.setViewport({ width: 1429, height: 836 });
+  });
+
+  describe('screenshots', () => {
+    it('shows the default view', async () => {
+      await takeScreenshot(testBed.page, 'task-scheduler', 'task-sk');
+    });
+  });
+});