Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

RepoActionView.vue 27KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895
  1. <script>
  2. import {SvgIcon} from '../svg.js';
  3. import ActionRunStatus from './ActionRunStatus.vue';
  4. import {createApp} from 'vue';
  5. import {toggleElem} from '../utils/dom.js';
  6. import {formatDatetime} from '../utils/time.js';
  7. import {renderAnsi} from '../render/ansi.js';
  8. import {GET, POST, DELETE} from '../modules/fetch.js';
  9. const sfc = {
  10. name: 'RepoActionView',
  11. components: {
  12. SvgIcon,
  13. ActionRunStatus,
  14. },
  15. props: {
  16. runIndex: String,
  17. jobIndex: String,
  18. actionsURL: String,
  19. locale: Object,
  20. },
  21. data() {
  22. return {
  23. // internal state
  24. loading: false,
  25. intervalID: null,
  26. currentJobStepsStates: [],
  27. artifacts: [],
  28. onHoverRerunIndex: -1,
  29. menuVisible: false,
  30. isFullScreen: false,
  31. timeVisible: {
  32. 'log-time-stamp': false,
  33. 'log-time-seconds': false,
  34. },
  35. // provided by backend
  36. run: {
  37. link: '',
  38. title: '',
  39. status: '',
  40. canCancel: false,
  41. canApprove: false,
  42. canRerun: false,
  43. done: false,
  44. workflowID: '',
  45. workflowLink: '',
  46. isSchedule: false,
  47. jobs: [
  48. // {
  49. // id: 0,
  50. // name: '',
  51. // status: '',
  52. // canRerun: false,
  53. // duration: '',
  54. // },
  55. ],
  56. commit: {
  57. localeCommit: '',
  58. localePushedBy: '',
  59. shortSHA: '',
  60. link: '',
  61. pusher: {
  62. displayName: '',
  63. link: '',
  64. },
  65. branch: {
  66. name: '',
  67. link: '',
  68. },
  69. },
  70. },
  71. currentJob: {
  72. title: '',
  73. detail: '',
  74. steps: [
  75. // {
  76. // summary: '',
  77. // duration: '',
  78. // status: '',
  79. // }
  80. ],
  81. },
  82. };
  83. },
  84. async mounted() {
  85. // load job data and then auto-reload periodically
  86. // need to await first loadJob so this.currentJobStepsStates is initialized and can be used in hashChangeListener
  87. await this.loadJob();
  88. this.intervalID = setInterval(this.loadJob, 1000);
  89. document.body.addEventListener('click', this.closeDropdown);
  90. this.hashChangeListener();
  91. window.addEventListener('hashchange', this.hashChangeListener);
  92. },
  93. beforeUnmount() {
  94. document.body.removeEventListener('click', this.closeDropdown);
  95. window.removeEventListener('hashchange', this.hashChangeListener);
  96. },
  97. unmounted() {
  98. // clear the interval timer when the component is unmounted
  99. // even our page is rendered once, not spa style
  100. if (this.intervalID) {
  101. clearInterval(this.intervalID);
  102. this.intervalID = null;
  103. }
  104. },
  105. methods: {
  106. // get the active container element, either the `job-step-logs` or the `job-log-list` in the `job-log-group`
  107. getLogsContainer(idx) {
  108. const el = this.$refs.logs[idx];
  109. return el._stepLogsActiveContainer ?? el;
  110. },
  111. // begin a log group
  112. beginLogGroup(idx) {
  113. const el = this.$refs.logs[idx];
  114. const elJobLogGroup = document.createElement('div');
  115. elJobLogGroup.classList.add('job-log-group');
  116. const elJobLogGroupSummary = document.createElement('div');
  117. elJobLogGroupSummary.classList.add('job-log-group-summary');
  118. const elJobLogList = document.createElement('div');
  119. elJobLogList.classList.add('job-log-list');
  120. elJobLogGroup.append(elJobLogGroupSummary);
  121. elJobLogGroup.append(elJobLogList);
  122. el._stepLogsActiveContainer = elJobLogList;
  123. },
  124. // end a log group
  125. endLogGroup(idx) {
  126. const el = this.$refs.logs[idx];
  127. el._stepLogsActiveContainer = null;
  128. },
  129. // show/hide the step logs for a step
  130. toggleStepLogs(idx) {
  131. this.currentJobStepsStates[idx].expanded = !this.currentJobStepsStates[idx].expanded;
  132. if (this.currentJobStepsStates[idx].expanded) {
  133. this.loadJob(); // try to load the data immediately instead of waiting for next timer interval
  134. }
  135. },
  136. // cancel a run
  137. cancelRun() {
  138. POST(`${this.run.link}/cancel`);
  139. },
  140. // approve a run
  141. approveRun() {
  142. POST(`${this.run.link}/approve`);
  143. },
  144. createLogLine(line, startTime, stepIndex) {
  145. const div = document.createElement('div');
  146. div.classList.add('job-log-line');
  147. div.setAttribute('id', `jobstep-${stepIndex}-${line.index}`);
  148. div._jobLogTime = line.timestamp;
  149. const lineNumber = document.createElement('a');
  150. lineNumber.classList.add('line-num', 'muted');
  151. lineNumber.textContent = line.index;
  152. lineNumber.setAttribute('href', `#jobstep-${stepIndex}-${line.index}`);
  153. div.append(lineNumber);
  154. // for "Show timestamps"
  155. const logTimeStamp = document.createElement('span');
  156. logTimeStamp.className = 'log-time-stamp';
  157. const date = new Date(parseFloat(line.timestamp * 1000));
  158. const timeStamp = formatDatetime(date);
  159. logTimeStamp.textContent = timeStamp;
  160. toggleElem(logTimeStamp, this.timeVisible['log-time-stamp']);
  161. // for "Show seconds"
  162. const logTimeSeconds = document.createElement('span');
  163. logTimeSeconds.className = 'log-time-seconds';
  164. const seconds = Math.floor(parseFloat(line.timestamp) - parseFloat(startTime));
  165. logTimeSeconds.textContent = `${seconds}s`;
  166. toggleElem(logTimeSeconds, this.timeVisible['log-time-seconds']);
  167. const logMessage = document.createElement('span');
  168. logMessage.className = 'log-msg';
  169. logMessage.innerHTML = renderAnsi(line.message);
  170. div.append(logTimeStamp);
  171. div.append(logMessage);
  172. div.append(logTimeSeconds);
  173. return div;
  174. },
  175. appendLogs(stepIndex, logLines, startTime) {
  176. for (const line of logLines) {
  177. // TODO: group support: ##[group]GroupTitle , ##[endgroup]
  178. const el = this.getLogsContainer(stepIndex);
  179. el.append(this.createLogLine(line, startTime, stepIndex));
  180. }
  181. },
  182. async fetchArtifacts() {
  183. const resp = await GET(`${this.actionsURL}/runs/${this.runIndex}/artifacts`);
  184. return await resp.json();
  185. },
  186. async deleteArtifact(name) {
  187. if (!window.confirm(this.locale.confirmDeleteArtifact.replace('%s', name))) return;
  188. await DELETE(`${this.run.link}/artifacts/${name}`);
  189. await this.loadJob();
  190. },
  191. async fetchJob() {
  192. const logCursors = this.currentJobStepsStates.map((it, idx) => {
  193. // cursor is used to indicate the last position of the logs
  194. // it's only used by backend, frontend just reads it and passes it back, it and can be any type.
  195. // for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
  196. return {step: idx, cursor: it.cursor, expanded: it.expanded};
  197. });
  198. const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, {
  199. data: {logCursors},
  200. });
  201. return await resp.json();
  202. },
  203. async loadJob() {
  204. if (this.loading) return;
  205. try {
  206. this.loading = true;
  207. let job, artifacts;
  208. try {
  209. [job, artifacts] = await Promise.all([
  210. this.fetchJob(),
  211. this.fetchArtifacts(), // refresh artifacts if upload-artifact step done
  212. ]);
  213. } catch (err) {
  214. if (err instanceof TypeError) return; // avoid network error while unloading page
  215. throw err;
  216. }
  217. this.artifacts = artifacts['artifacts'] || [];
  218. // save the state to Vue data, then the UI will be updated
  219. this.run = job.state.run;
  220. this.currentJob = job.state.currentJob;
  221. // sync the currentJobStepsStates to store the job step states
  222. for (let i = 0; i < this.currentJob.steps.length; i++) {
  223. if (!this.currentJobStepsStates[i]) {
  224. // initial states for job steps
  225. this.currentJobStepsStates[i] = {cursor: null, expanded: false};
  226. }
  227. }
  228. // append logs to the UI
  229. for (const logs of job.logs.stepsLog) {
  230. // save the cursor, it will be passed to backend next time
  231. this.currentJobStepsStates[logs.step].cursor = logs.cursor;
  232. this.appendLogs(logs.step, logs.lines, logs.started);
  233. }
  234. if (this.run.done && this.intervalID) {
  235. clearInterval(this.intervalID);
  236. this.intervalID = null;
  237. }
  238. } finally {
  239. this.loading = false;
  240. }
  241. },
  242. isDone(status) {
  243. return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
  244. },
  245. isExpandable(status) {
  246. return ['success', 'running', 'failure', 'cancelled'].includes(status);
  247. },
  248. closeDropdown() {
  249. if (this.menuVisible) this.menuVisible = false;
  250. },
  251. toggleTimeDisplay(type) {
  252. this.timeVisible[`log-time-${type}`] = !this.timeVisible[`log-time-${type}`];
  253. for (const el of this.$refs.steps.querySelectorAll(`.log-time-${type}`)) {
  254. toggleElem(el, this.timeVisible[`log-time-${type}`]);
  255. }
  256. },
  257. toggleFullScreen() {
  258. this.isFullScreen = !this.isFullScreen;
  259. const fullScreenEl = document.querySelector('.action-view-right');
  260. const outerEl = document.querySelector('.full.height');
  261. const actionBodyEl = document.querySelector('.action-view-body');
  262. const headerEl = document.querySelector('#navbar');
  263. const contentEl = document.querySelector('.page-content.repository');
  264. const footerEl = document.querySelector('.page-footer');
  265. toggleElem(headerEl, !this.isFullScreen);
  266. toggleElem(contentEl, !this.isFullScreen);
  267. toggleElem(footerEl, !this.isFullScreen);
  268. // move .action-view-right to new parent
  269. if (this.isFullScreen) {
  270. outerEl.append(fullScreenEl);
  271. } else {
  272. actionBodyEl.append(fullScreenEl);
  273. }
  274. },
  275. async hashChangeListener() {
  276. const selectedLogStep = window.location.hash;
  277. if (!selectedLogStep) return;
  278. const [_, step, _line] = selectedLogStep.split('-');
  279. if (!this.currentJobStepsStates[step]) return;
  280. if (!this.currentJobStepsStates[step].expanded && this.currentJobStepsStates[step].cursor === null) {
  281. this.currentJobStepsStates[step].expanded = true;
  282. // need to await for load job if the step log is loaded for the first time
  283. // so logline can be selected by querySelector
  284. await this.loadJob();
  285. }
  286. const logLine = this.$refs.steps.querySelector(selectedLogStep);
  287. if (!logLine) return;
  288. logLine.querySelector('.line-num').click();
  289. },
  290. },
  291. };
  292. export default sfc;
  293. export function initRepositoryActionView() {
  294. const el = document.getElementById('repo-action-view');
  295. if (!el) return;
  296. // TODO: the parent element's full height doesn't work well now,
  297. // but we can not pollute the global style at the moment, only fix the height problem for pages with this component
  298. const parentFullHeight = document.querySelector('body > div.full.height');
  299. if (parentFullHeight) parentFullHeight.style.paddingBottom = '0';
  300. const view = createApp(sfc, {
  301. runIndex: el.getAttribute('data-run-index'),
  302. jobIndex: el.getAttribute('data-job-index'),
  303. actionsURL: el.getAttribute('data-actions-url'),
  304. locale: {
  305. approve: el.getAttribute('data-locale-approve'),
  306. cancel: el.getAttribute('data-locale-cancel'),
  307. rerun: el.getAttribute('data-locale-rerun'),
  308. rerun_all: el.getAttribute('data-locale-rerun-all'),
  309. scheduled: el.getAttribute('data-locale-runs-scheduled'),
  310. commit: el.getAttribute('data-locale-runs-commit'),
  311. pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
  312. artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
  313. areYouSure: el.getAttribute('data-locale-are-you-sure'),
  314. confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
  315. showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
  316. showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
  317. showFullScreen: el.getAttribute('data-locale-show-full-screen'),
  318. downloadLogs: el.getAttribute('data-locale-download-logs'),
  319. status: {
  320. unknown: el.getAttribute('data-locale-status-unknown'),
  321. waiting: el.getAttribute('data-locale-status-waiting'),
  322. running: el.getAttribute('data-locale-status-running'),
  323. success: el.getAttribute('data-locale-status-success'),
  324. failure: el.getAttribute('data-locale-status-failure'),
  325. cancelled: el.getAttribute('data-locale-status-cancelled'),
  326. skipped: el.getAttribute('data-locale-status-skipped'),
  327. blocked: el.getAttribute('data-locale-status-blocked'),
  328. },
  329. },
  330. });
  331. view.mount(el);
  332. }
  333. </script>
  334. <template>
  335. <div class="ui container action-view-container">
  336. <div class="action-view-header">
  337. <div class="action-info-summary">
  338. <div class="action-info-summary-title">
  339. <ActionRunStatus :locale-status="locale.status[run.status]" :status="run.status" :size="20"/>
  340. <h2 class="action-info-summary-title-text">
  341. {{ run.title }}
  342. </h2>
  343. </div>
  344. <button class="ui basic small compact button primary" @click="approveRun()" v-if="run.canApprove">
  345. {{ locale.approve }}
  346. </button>
  347. <button class="ui basic small compact button red" @click="cancelRun()" v-else-if="run.canCancel">
  348. {{ locale.cancel }}
  349. </button>
  350. <button class="ui basic small compact button tw-mr-0 tw-whitespace-nowrap link-action" :data-url="`${run.link}/rerun`" v-else-if="run.canRerun">
  351. {{ locale.rerun_all }}
  352. </button>
  353. </div>
  354. <div class="action-commit-summary">
  355. <span><a class="muted" :href="run.workflowLink"><b>{{ run.workflowID }}</b></a>:</span>
  356. <template v-if="run.isSchedule">
  357. {{ locale.scheduled }}
  358. </template>
  359. <template v-else>
  360. {{ locale.commit }}
  361. <a class="muted" :href="run.commit.link">{{ run.commit.shortSHA }}</a>
  362. {{ locale.pushedBy }}
  363. <a class="muted" :href="run.commit.pusher.link">{{ run.commit.pusher.displayName }}</a>
  364. </template>
  365. <span class="ui label tw-max-w-full" v-if="run.commit.shortSHA">
  366. <a class="gt-ellipsis" :href="run.commit.branch.link">{{ run.commit.branch.name }}</a>
  367. </span>
  368. </div>
  369. </div>
  370. <div class="action-view-body">
  371. <div class="action-view-left">
  372. <div class="job-group-section">
  373. <div class="job-brief-list">
  374. <a class="job-brief-item" :href="run.link+'/jobs/'+index" :class="parseInt(jobIndex) === index ? 'selected' : ''" v-for="(job, index) in run.jobs" :key="job.id" @mouseenter="onHoverRerunIndex = job.id" @mouseleave="onHoverRerunIndex = -1">
  375. <div class="job-brief-item-left">
  376. <ActionRunStatus :locale-status="locale.status[job.status]" :status="job.status"/>
  377. <span class="job-brief-name tw-mx-2 gt-ellipsis">{{ job.name }}</span>
  378. </div>
  379. <span class="job-brief-item-right">
  380. <SvgIcon name="octicon-sync" role="button" :data-tooltip-content="locale.rerun" class="job-brief-rerun tw-mx-2 link-action" :data-url="`${run.link}/jobs/${index}/rerun`" v-if="job.canRerun && onHoverRerunIndex === job.id"/>
  381. <span class="step-summary-duration">{{ job.duration }}</span>
  382. </span>
  383. </a>
  384. </div>
  385. </div>
  386. <div class="job-artifacts" v-if="artifacts.length > 0">
  387. <div class="job-artifacts-title">
  388. {{ locale.artifactsTitle }}
  389. </div>
  390. <ul class="job-artifacts-list">
  391. <li class="job-artifacts-item" v-for="artifact in artifacts" :key="artifact.name">
  392. <a class="job-artifacts-link" target="_blank" :href="run.link+'/artifacts/'+artifact.name">
  393. <SvgIcon name="octicon-file" class="ui text black job-artifacts-icon"/>{{ artifact.name }}
  394. </a>
  395. <a v-if="run.canDeleteArtifact" @click="deleteArtifact(artifact.name)" class="job-artifacts-delete">
  396. <SvgIcon name="octicon-trash" class="ui text black job-artifacts-icon"/>
  397. </a>
  398. </li>
  399. </ul>
  400. </div>
  401. </div>
  402. <div class="action-view-right">
  403. <div class="job-info-header">
  404. <div class="job-info-header-left gt-ellipsis">
  405. <h3 class="job-info-header-title gt-ellipsis">
  406. {{ currentJob.title }}
  407. </h3>
  408. <p class="job-info-header-detail">
  409. {{ currentJob.detail }}
  410. </p>
  411. </div>
  412. <div class="job-info-header-right">
  413. <div class="ui top right pointing dropdown custom jump item" @click.stop="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
  414. <button class="btn gt-interact-bg tw-p-2">
  415. <SvgIcon name="octicon-gear" :size="18"/>
  416. </button>
  417. <div class="menu transition action-job-menu" :class="{visible: menuVisible}" v-if="menuVisible" v-cloak>
  418. <a class="item" @click="toggleTimeDisplay('seconds')">
  419. <i class="icon"><SvgIcon :name="timeVisible['log-time-seconds'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
  420. {{ locale.showLogSeconds }}
  421. </a>
  422. <a class="item" @click="toggleTimeDisplay('stamp')">
  423. <i class="icon"><SvgIcon :name="timeVisible['log-time-stamp'] ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
  424. {{ locale.showTimeStamps }}
  425. </a>
  426. <a class="item" @click="toggleFullScreen()">
  427. <i class="icon"><SvgIcon :name="isFullScreen ? 'octicon-check' : 'gitea-empty-checkbox'"/></i>
  428. {{ locale.showFullScreen }}
  429. </a>
  430. <div class="divider"/>
  431. <a :class="['item', !currentJob.steps.length ? 'disabled' : '']" :href="run.link+'/jobs/'+jobIndex+'/logs'" target="_blank">
  432. <i class="icon"><SvgIcon name="octicon-download"/></i>
  433. {{ locale.downloadLogs }}
  434. </a>
  435. </div>
  436. </div>
  437. </div>
  438. </div>
  439. <div class="job-step-container" ref="steps" v-if="currentJob.steps.length">
  440. <div class="job-step-section" v-for="(jobStep, i) in currentJob.steps" :key="i">
  441. <div class="job-step-summary" @click.stop="isExpandable(jobStep.status) && toggleStepLogs(i)" :class="[currentJobStepsStates[i].expanded ? 'selected' : '', isExpandable(jobStep.status) && 'step-expandable']">
  442. <!-- If the job is done and the job step log is loaded for the first time, show the loading icon
  443. currentJobStepsStates[i].cursor === null means the log is loaded for the first time
  444. -->
  445. <SvgIcon v-if="isDone(run.status) && currentJobStepsStates[i].expanded && currentJobStepsStates[i].cursor === null" name="octicon-sync" class="tw-mr-2 job-status-rotate"/>
  446. <SvgIcon v-else :name="currentJobStepsStates[i].expanded ? 'octicon-chevron-down': 'octicon-chevron-right'" :class="['tw-mr-2', !isExpandable(jobStep.status) && 'tw-invisible']"/>
  447. <ActionRunStatus :status="jobStep.status" class="tw-mr-2"/>
  448. <span class="step-summary-msg gt-ellipsis">{{ jobStep.summary }}</span>
  449. <span class="step-summary-duration">{{ jobStep.duration }}</span>
  450. </div>
  451. <!-- the log elements could be a lot, do not use v-if to destroy/reconstruct the DOM,
  452. use native DOM elements for "log line" to improve performance, Vue is not suitable for managing so many reactive elements. -->
  453. <div class="job-step-logs" ref="logs" v-show="currentJobStepsStates[i].expanded"/>
  454. </div>
  455. </div>
  456. </div>
  457. </div>
  458. </div>
  459. </template>
  460. <style scoped>
  461. .action-view-body {
  462. padding-top: 12px;
  463. padding-bottom: 12px;
  464. display: flex;
  465. gap: 12px;
  466. }
  467. /* ================ */
  468. /* action view header */
  469. .action-view-header {
  470. margin-top: 8px;
  471. }
  472. .action-info-summary {
  473. display: flex;
  474. align-items: center;
  475. justify-content: space-between;
  476. gap: 8px;
  477. }
  478. .action-info-summary-title {
  479. display: flex;
  480. }
  481. .action-info-summary-title-text {
  482. font-size: 20px;
  483. margin: 0 0 0 8px;
  484. flex: 1;
  485. overflow-wrap: anywhere;
  486. }
  487. .action-commit-summary {
  488. display: flex;
  489. flex-wrap: wrap;
  490. gap: 5px;
  491. margin-left: 28px;
  492. }
  493. @media (max-width: 767.98px) {
  494. .action-commit-summary {
  495. margin-left: 0;
  496. margin-top: 8px;
  497. }
  498. }
  499. /* ================ */
  500. /* action view left */
  501. .action-view-left {
  502. width: 30%;
  503. max-width: 400px;
  504. position: sticky;
  505. top: 12px;
  506. max-height: 100vh;
  507. overflow-y: auto;
  508. background: var(--color-body);
  509. z-index: 2; /* above .job-info-header */
  510. }
  511. @media (max-width: 767.98px) {
  512. .action-view-left {
  513. position: static; /* can not sticky because multiple jobs would overlap into right view */
  514. }
  515. }
  516. .job-artifacts-title {
  517. font-size: 18px;
  518. margin-top: 16px;
  519. padding: 16px 10px 0 20px;
  520. border-top: 1px solid var(--color-secondary);
  521. }
  522. .job-artifacts-item {
  523. margin: 5px 0;
  524. padding: 6px;
  525. display: flex;
  526. justify-content: space-between;
  527. }
  528. .job-artifacts-list {
  529. padding-left: 12px;
  530. list-style: none;
  531. }
  532. .job-artifacts-icon {
  533. padding-right: 3px;
  534. }
  535. .job-brief-list {
  536. display: flex;
  537. flex-direction: column;
  538. gap: 8px;
  539. }
  540. .job-brief-item {
  541. padding: 10px;
  542. border-radius: var(--border-radius);
  543. text-decoration: none;
  544. display: flex;
  545. flex-wrap: nowrap;
  546. justify-content: space-between;
  547. align-items: center;
  548. color: var(--color-text);
  549. }
  550. .job-brief-item:hover {
  551. background-color: var(--color-hover);
  552. }
  553. .job-brief-item.selected {
  554. font-weight: var(--font-weight-bold);
  555. background-color: var(--color-active);
  556. }
  557. .job-brief-item:first-of-type {
  558. margin-top: 0;
  559. }
  560. .job-brief-item .job-brief-rerun {
  561. cursor: pointer;
  562. transition: transform 0.2s;
  563. }
  564. .job-brief-item .job-brief-rerun:hover {
  565. transform: scale(130%);
  566. }
  567. .job-brief-item .job-brief-item-left {
  568. display: flex;
  569. width: 100%;
  570. min-width: 0;
  571. }
  572. .job-brief-item .job-brief-item-left span {
  573. display: flex;
  574. align-items: center;
  575. }
  576. .job-brief-item .job-brief-item-left .job-brief-name {
  577. display: block;
  578. width: 70%;
  579. }
  580. .job-brief-item .job-brief-item-right {
  581. display: flex;
  582. align-items: center;
  583. }
  584. /* ================ */
  585. /* action view right */
  586. .action-view-right {
  587. flex: 1;
  588. color: var(--color-console-fg-subtle);
  589. max-height: 100%;
  590. width: 70%;
  591. display: flex;
  592. flex-direction: column;
  593. border: 1px solid var(--color-console-border);
  594. border-radius: var(--border-radius);
  595. background: var(--color-console-bg);
  596. align-self: flex-start;
  597. }
  598. /* begin fomantic button overrides */
  599. .action-view-right .ui.button,
  600. .action-view-right .ui.button:focus {
  601. background: transparent;
  602. color: var(--color-console-fg-subtle);
  603. }
  604. .action-view-right .ui.button:hover {
  605. background: var(--color-console-hover-bg);
  606. color: var(--color-console-fg);
  607. }
  608. .action-view-right .ui.button:active {
  609. background: var(--color-console-active-bg);
  610. color: var(--color-console-fg);
  611. }
  612. /* end fomantic button overrides */
  613. /* begin fomantic dropdown menu overrides */
  614. .action-view-right .ui.dropdown .menu {
  615. background: var(--color-console-menu-bg);
  616. border-color: var(--color-console-menu-border);
  617. }
  618. .action-view-right .ui.dropdown .menu > .item {
  619. color: var(--color-console-fg);
  620. }
  621. .action-view-right .ui.dropdown .menu > .item:hover {
  622. color: var(--color-console-fg);
  623. background: var(--color-console-hover-bg);
  624. }
  625. .action-view-right .ui.dropdown .menu > .item:active {
  626. color: var(--color-console-fg);
  627. background: var(--color-console-active-bg);
  628. }
  629. .action-view-right .ui.dropdown .menu > .divider {
  630. border-top-color: var(--color-console-menu-border);
  631. }
  632. .action-view-right .ui.pointing.dropdown > .menu:not(.hidden)::after {
  633. background: var(--color-console-menu-bg);
  634. box-shadow: -1px -1px 0 0 var(--color-console-menu-border);
  635. }
  636. /* end fomantic dropdown menu overrides */
  637. .job-info-header {
  638. display: flex;
  639. justify-content: space-between;
  640. align-items: center;
  641. padding: 0 12px;
  642. position: sticky;
  643. top: 0;
  644. height: 60px;
  645. z-index: 1; /* above .job-step-container */
  646. background: var(--color-console-bg);
  647. border-radius: 3px;
  648. }
  649. .job-info-header:has(+ .job-step-container) {
  650. border-radius: var(--border-radius) var(--border-radius) 0 0;
  651. }
  652. .job-info-header .job-info-header-title {
  653. color: var(--color-console-fg);
  654. font-size: 16px;
  655. margin: 0;
  656. }
  657. .job-info-header .job-info-header-detail {
  658. color: var(--color-console-fg-subtle);
  659. font-size: 12px;
  660. }
  661. .job-info-header-left {
  662. flex: 1;
  663. }
  664. .job-step-container {
  665. max-height: 100%;
  666. border-radius: 0 0 var(--border-radius) var(--border-radius);
  667. border-top: 1px solid var(--color-console-border);
  668. z-index: 0;
  669. }
  670. .job-step-container .job-step-summary {
  671. padding: 5px 10px;
  672. display: flex;
  673. align-items: center;
  674. border-radius: var(--border-radius);
  675. }
  676. .job-step-container .job-step-summary.step-expandable {
  677. cursor: pointer;
  678. }
  679. .job-step-container .job-step-summary.step-expandable:hover {
  680. color: var(--color-console-fg);
  681. background: var(--color-console-hover-bg);
  682. }
  683. .job-step-container .job-step-summary .step-summary-msg {
  684. flex: 1;
  685. }
  686. .job-step-container .job-step-summary .step-summary-duration {
  687. margin-left: 16px;
  688. }
  689. .job-step-container .job-step-summary.selected {
  690. color: var(--color-console-fg);
  691. background-color: var(--color-console-active-bg);
  692. position: sticky;
  693. top: 60px;
  694. }
  695. @media (max-width: 767.98px) {
  696. .action-view-body {
  697. flex-direction: column;
  698. }
  699. .action-view-left, .action-view-right {
  700. width: 100%;
  701. }
  702. .action-view-left {
  703. max-width: none;
  704. }
  705. }
  706. </style>
  707. <style>
  708. /* some elements are not managed by vue, so we need to use global style */
  709. .job-status-rotate {
  710. animation: job-status-rotate-keyframes 1s linear infinite;
  711. }
  712. @keyframes job-status-rotate-keyframes {
  713. 100% {
  714. transform: rotate(-360deg);
  715. }
  716. }
  717. .job-step-section {
  718. margin: 10px;
  719. }
  720. .job-step-section .job-step-logs {
  721. font-family: var(--fonts-monospace);
  722. margin: 8px 0;
  723. font-size: 12px;
  724. }
  725. .job-step-section .job-step-logs .job-log-line {
  726. display: flex;
  727. }
  728. .job-log-line:hover,
  729. .job-log-line:target {
  730. background-color: var(--color-console-hover-bg);
  731. }
  732. .job-log-line:target {
  733. scroll-margin-top: 95px;
  734. }
  735. /* class names 'log-time-seconds' and 'log-time-stamp' are used in the method toggleTimeDisplay */
  736. .job-log-line .line-num, .log-time-seconds {
  737. width: 48px;
  738. color: var(--color-text-light-3);
  739. text-align: right;
  740. user-select: none;
  741. }
  742. .job-log-line:target > .line-num {
  743. color: var(--color-primary);
  744. text-decoration: underline;
  745. }
  746. .log-time-seconds {
  747. padding-right: 2px;
  748. }
  749. .job-log-line .log-time,
  750. .log-time-stamp {
  751. color: var(--color-text-light-3);
  752. margin-left: 10px;
  753. white-space: nowrap;
  754. }
  755. .job-step-section .job-step-logs .job-log-line .log-msg {
  756. flex: 1;
  757. word-break: break-all;
  758. white-space: break-spaces;
  759. margin-left: 10px;
  760. }
  761. /* selectors here are intentionally exact to only match fullscreen */
  762. .full.height > .action-view-right {
  763. width: 100%;
  764. height: 100%;
  765. padding: 0;
  766. border-radius: 0;
  767. }
  768. .full.height > .action-view-right > .job-info-header {
  769. border-radius: 0;
  770. }
  771. .full.height > .action-view-right > .job-step-container {
  772. height: calc(100% - 60px);
  773. border-radius: 0;
  774. }
  775. /* TODO: group support
  776. .job-log-group {
  777. }
  778. .job-log-group-summary {
  779. }
  780. .job-log-list {
  781. } */
  782. </style>