gitea源码

RepoActionView.vue 31KB

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