Add fetch wrappers, ignore network errors in actions view (#26985)

1. Introduce lightweight `fetch` wrapper functions that automatically
sets csfr token, content-type and use it in `RepoActionView.vue`.
2. Fix a specific issue on `RepoActionView.vue` where a fetch network
error is shortly visible during page reload sometimes. It can be
reproduced by F5-in in quick succession on the actions view page and was
also producing a red error box on the page.

Once approved, we can replace all current `fetch` uses in UI with this
in another PR.

---------

Co-authored-by: Giteabot <teabot@gitea.io>
This commit is contained in:
silverwind 2023-09-11 10:25:10 +02:00 committed by GitHub
parent 148c9c4b05
commit 6d96f0b0d1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 79 additions and 28 deletions

View file

@ -92,6 +92,12 @@ it's recommended to use `const _promise = asyncFoo()` to tell readers
that this is done by purpose, we want to call the async function and ignore the Promise. that this is done by purpose, we want to call the async function and ignore the Promise.
Some lint rules and IDEs also have warnings if the returned Promise is not handled. Some lint rules and IDEs also have warnings if the returned Promise is not handled.
### Fetching data
To fetch data, use the wrapper functions `GET`, `POST` etc. from `modules/fetch.js`. They
accept a `data` option for the content, will automatically set CSFR token and return a
Promise for a [Response](https://developer.mozilla.org/en-US/docs/Web/API/Response).
### HTML Attributes and `dataset` ### HTML Attributes and `dataset`
The usage of `dataset` is forbidden, its camel-casing behaviour makes it hard to grep for attributes. The usage of `dataset` is forbidden, its camel-casing behaviour makes it hard to grep for attributes.

View file

@ -5,8 +5,7 @@ import {createApp} from 'vue';
import {toggleElem} from '../utils/dom.js'; import {toggleElem} from '../utils/dom.js';
import {getCurrentLocale} from '../utils.js'; import {getCurrentLocale} from '../utils.js';
import {renderAnsi} from '../render/ansi.js'; import {renderAnsi} from '../render/ansi.js';
import {POST} from '../modules/fetch.js';
const {csrfToken} = window.config;
const sfc = { const sfc = {
name: 'RepoActionView', name: 'RepoActionView',
@ -145,11 +144,11 @@ const sfc = {
}, },
// cancel a run // cancel a run
cancelRun() { cancelRun() {
this.fetchPost(`${this.run.link}/cancel`); POST(`${this.run.link}/cancel`);
}, },
// approve a run // approve a run
approveRun() { approveRun() {
this.fetchPost(`${this.run.link}/approve`); POST(`${this.run.link}/approve`);
}, },
createLogLine(line, startTime, stepIndex) { createLogLine(line, startTime, stepIndex) {
@ -196,6 +195,11 @@ const sfc = {
} }
}, },
async fetchArtifacts() {
const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/artifacts`);
return await resp.json();
},
async fetchJob() { async fetchJob() {
const logCursors = this.currentJobStepsStates.map((it, idx) => { const logCursors = this.currentJobStepsStates.map((it, idx) => {
// cursor is used to indicate the last position of the logs // cursor is used to indicate the last position of the logs
@ -203,10 +207,9 @@ const sfc = {
// for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc // for example: make cursor=null means the first time to fetch logs, cursor=eof means no more logs, etc
return {step: idx, cursor: it.cursor, expanded: it.expanded}; return {step: idx, cursor: it.cursor, expanded: it.expanded};
}); });
const resp = await this.fetchPost( const resp = await POST(`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, {
`${this.actionsURL}/runs/${this.runIndex}/jobs/${this.jobIndex}`, data: {logCursors},
JSON.stringify({logCursors}), });
);
return await resp.json(); return await resp.json();
}, },
@ -215,16 +218,21 @@ const sfc = {
try { try {
this.loading = true; this.loading = true;
// refresh artifacts if upload-artifact step done let job, artifacts;
const resp = await this.fetchPost(`${this.actionsURL}/runs/${this.runIndex}/artifacts`); try {
const artifacts = await resp.json(); [job, artifacts] = await Promise.all([
this.fetchJob(),
this.fetchArtifacts(), // refresh artifacts if upload-artifact step done
]);
} catch (err) {
if (!(err instanceof TypeError)) throw err; // avoid network error while unloading page
}
this.artifacts = artifacts['artifacts'] || []; this.artifacts = artifacts['artifacts'] || [];
const response = await this.fetchJob();
// save the state to Vue data, then the UI will be updated // save the state to Vue data, then the UI will be updated
this.run = response.state.run; this.run = job.state.run;
this.currentJob = response.state.currentJob; this.currentJob = job.state.currentJob;
// sync the currentJobStepsStates to store the job step states // sync the currentJobStepsStates to store the job step states
for (let i = 0; i < this.currentJob.steps.length; i++) { for (let i = 0; i < this.currentJob.steps.length; i++) {
@ -234,7 +242,7 @@ const sfc = {
} }
} }
// append logs to the UI // append logs to the UI
for (const logs of response.logs.stepsLog) { for (const logs of job.logs.stepsLog) {
// save the cursor, it will be passed to backend next time // save the cursor, it will be passed to backend next time
this.currentJobStepsStates[logs.step].cursor = logs.cursor; this.currentJobStepsStates[logs.step].cursor = logs.cursor;
this.appendLogs(logs.step, logs.lines, logs.started); this.appendLogs(logs.step, logs.lines, logs.started);
@ -249,18 +257,6 @@ const sfc = {
} }
}, },
fetchPost(url, body) {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Csrf-Token': csrfToken,
},
body,
});
},
isDone(status) { isDone(status) {
return ['success', 'skipped', 'failure', 'cancelled'].includes(status); return ['success', 'skipped', 'failure', 'cancelled'].includes(status);
}, },

View file

@ -0,0 +1,38 @@
import {isObject} from '../utils.js';
const {csrfToken} = window.config;
// fetch wrapper, use below method name functions and the `data` option to pass in data
// which will automatically set an appropriate content-type header. For json content,
// only object and array types are currently supported.
function request(url, {headers, data, body, ...other} = {}) {
let contentType;
if (!body) {
if (data instanceof FormData) {
contentType = 'multipart/form-data';
body = data;
} else if (data instanceof URLSearchParams) {
contentType = 'application/x-www-form-urlencoded';
body = data;
} else if (isObject(data) || Array.isArray(data)) {
contentType = 'application/json';
body = JSON.stringify(data);
}
}
return fetch(url, {
headers: {
'x-csrf-token': csrfToken,
...(contentType && {'content-type': contentType}),
...headers,
},
...(body && {body}),
...other,
});
}
export const GET = (url, opts) => request(url, {method: 'GET', ...opts});
export const POST = (url, opts) => request(url, {method: 'POST', ...opts});
export const PATCH = (url, opts) => request(url, {method: 'PATCH', ...opts});
export const PUT = (url, opts) => request(url, {method: 'PUT', ...opts});
export const DELETE = (url, opts) => request(url, {method: 'DELETE', ...opts});

View file

@ -0,0 +1,11 @@
import {test, expect} from 'vitest';
import {GET, POST, PATCH, PUT, DELETE} from './fetch.js';
// tests here are only to satisfy the linter for unused functions
test('exports', () => {
expect(GET).toBeTruthy();
expect(POST).toBeTruthy();
expect(PATCH).toBeTruthy();
expect(PUT).toBeTruthy();
expect(DELETE).toBeTruthy();
});