Commit d74a24be authored by Miguel Rincon's avatar Miguel Rincon

Remove environment_logs_use_vue_ui feature flag

The Pods logs page was refactored to VueJS for the "Pod Logs" page.
Previously, the Logs page was done using HAML and jQuery.

The rewrite work was done behind feature flag
environment_logs_use_vue_ui.

This commit is the cleanup step in which the feature
flag is dropped, and the VueJS counterpart is set as the
default for users, so old unused code is removed:

- Remove old haml and jquery based feature files
- Update spec to use new js-* classes
- Extend feature spec to include the environments dropdown
- Remove unused scrolling class
- Remove unused static fixture
- Remove unused locale strings
parent ad290106
import $ from 'jquery';
import {
canScroll,
isScrolledToBottom,
isScrolledToTop,
isScrolledToMiddle,
toggleDisableButton,
} from './scroll_utils';
export default class LogOutputBehaviours {
constructor() {
// Scroll buttons
this.$scrollTopBtn = $('.js-scroll-up');
this.$scrollBottomBtn = $('.js-scroll-down');
this.$scrollTopBtn.off('click').on('click', this.scrollToTop.bind(this));
this.$scrollBottomBtn.off('click').on('click', this.scrollToBottom.bind(this));
}
toggleScroll() {
if (canScroll()) {
if (isScrolledToMiddle()) {
// User is in the middle of the log
toggleDisableButton(this.$scrollTopBtn, false);
toggleDisableButton(this.$scrollBottomBtn, false);
} else if (isScrolledToTop()) {
// User is at Top of Log
toggleDisableButton(this.$scrollTopBtn, true);
toggleDisableButton(this.$scrollBottomBtn, false);
} else if (isScrolledToBottom()) {
// User is at the bottom of the build log.
toggleDisableButton(this.$scrollTopBtn, false);
toggleDisableButton(this.$scrollBottomBtn, true);
}
} else {
toggleDisableButton(this.$scrollTopBtn, true);
toggleDisableButton(this.$scrollBottomBtn, true);
}
}
toggleScrollAnimation(toggle) {
this.$scrollBottomBtn.toggleClass('animate', toggle);
}
}
import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import { getParameterValues, redirectTo } from '~/lib/utils/url_utility';
import { isScrolledToBottom, scrollDown, toggleDisableButton } from '~/lib/utils/scroll_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import LogOutputBehaviours from '~/lib/utils/logoutput_behaviours';
import flash from '~/flash';
import { s__, sprintf } from '~/locale';
import _ from 'underscore';
import { backOff } from '~/lib/utils/common_utils';
import Api from 'ee/api';
const TWO_MINUTES = 120000;
const requestWithBackoff = (projectPath, environmentId, podName, containerName) =>
backOff((next, stop) => {
Api.getPodLogs({ projectPath, environmentId, podName, containerName })
.then(res => {
if (!res.data) {
next();
return;
}
stop(res);
})
.catch(err => {
stop(err);
});
}, TWO_MINUTES);
export default class KubernetesPodLogs extends LogOutputBehaviours {
constructor(container) {
super();
this.options = $(container).data();
const {
currentEnvironmentName,
environmentsPath,
projectFullPath,
environmentId,
} = this.options;
this.environmentName = currentEnvironmentName;
this.environmentsPath = environmentsPath;
this.projectFullPath = projectFullPath;
this.environmentId = environmentId;
[this.podName] = getParameterValues('pod_name');
if (this.podName) {
this.podName = _.escape(this.podName);
}
this.$window = $(window);
this.$buildOutputContainer = $(container).find('.js-build-output');
this.$refreshLogBtn = $(container).find('.js-refresh-log');
this.$buildRefreshAnimation = $(container).find('.js-build-refresh');
this.$podDropdown = $(container).find('.js-pod-dropdown');
this.$envDropdown = $(container).find('.js-environment-dropdown');
this.isLogComplete = false;
this.scrollThrottled = _.throttle(this.toggleScroll.bind(this), 100);
this.$window.off('scroll').on('scroll', () => {
if (!isScrolledToBottom()) {
this.toggleScrollAnimation(false);
} else if (isScrolledToBottom() && !this.isLogComplete) {
this.toggleScrollAnimation(true);
}
this.scrollThrottled();
});
this.$refreshLogBtn.off('click').on('click', this.getData.bind(this));
}
scrollToBottom() {
scrollDown();
this.toggleScroll();
}
scrollToTop() {
$(document).scrollTop(0);
this.toggleScroll();
}
getData() {
this.scrollToTop();
this.$buildOutputContainer.empty();
this.$buildRefreshAnimation.show();
toggleDisableButton(this.$refreshLogBtn, 'true');
return Promise.all([this.getEnvironments(), this.getLogs()]);
}
getEnvironments() {
return axios
.get(this.environmentsPath)
.then(res => {
const { environments } = res.data;
this.setupEnvironmentsDropdown(environments);
})
.catch(() => flash(s__('Environments|An error occurred while fetching the environments.')));
}
getLogs() {
return requestWithBackoff(this.projectFullPath, this.environmentId, this.podName)
.then(res => {
const { logs, pods } = res.data;
this.setupPodsDropdown(pods);
this.displayLogs(logs);
})
.catch(err => {
const { response } = err;
if (response && response.status === httpStatusCodes.BAD_REQUEST) {
if (response.data && response.data.message) {
flash(
sprintf(
s__('Environments|An error occurred while fetching the logs - Error: %{message}'),
{
message: response.data.message,
},
),
'notice',
);
} else {
flash(
s__(
'Environments|An error occurred while fetching the logs for this environment or pod. Please try again',
),
'notice',
);
}
} else {
flash(s__('Environments|An error occurred while fetching the logs'));
}
})
.finally(() => {
this.$buildRefreshAnimation.hide();
});
}
setupEnvironmentsDropdown(environments) {
this.setupDropdown(
this.$envDropdown,
this.environmentName,
environments.map(({ name, logs_path }) => ({ name, value: logs_path })),
el => {
const url = el.currentTarget.value;
redirectTo(url);
},
);
}
setupPodsDropdown(pods) {
// Show first pod, it is selected by default
this.podName = this.podName || pods[0];
this.setupDropdown(
this.$podDropdown,
this.podName,
pods.map(podName => ({ name: podName, value: podName })),
el => {
const selectedPodName = el.currentTarget.value;
if (selectedPodName !== this.podName) {
this.podName = selectedPodName;
this.getData();
}
},
);
}
displayLogs(logs) {
const formattedLogs = logs.map(logEntry => `${_.escape(logEntry)} <br />`);
this.$buildOutputContainer.append(formattedLogs);
scrollDown();
this.isLogComplete = true;
toggleDisableButton(this.$refreshLogBtn, false);
}
setupDropdown($dropdown, activeOption = '', options, onSelect) {
const $dropdownMenu = $dropdown.find('.dropdown-menu');
$dropdown
.find('.dropdown-menu-toggle')
.html(
`<span class="dropdown-toggle-text text-truncate">${activeOption}</span><i class="fa fa-chevron-down"></i>`,
);
$dropdownMenu.off('click');
$dropdownMenu.empty();
options.forEach(option => {
$dropdownMenu.append(`
<button class='dropdown-item' value='${option.value}'>
${_.escape(option.name)}
</button>
`);
});
$dropdownMenu.find('button').on('click', onSelect.bind(this));
}
}
......@@ -69,7 +69,7 @@ export default {
</script>
<template>
<div class="build-page-pod-logs mt-3">
<div class="top-bar d-flex">
<div class="top-bar js-top-bar d-flex">
<div class="row">
<gl-form-group
id="environments-dropdown-fg"
......@@ -125,7 +125,7 @@ export default {
@refresh="showPodLogs(pods.current)"
/>
</div>
<pre class="build-trace js-log-trace"><code class="bash">{{trace}}
<pre class="build-trace js-log-trace"><code class="bash js-build-output">{{trace}}
<div v-if="showLoader" class="build-loader-animation js-build-loader-animation">
<div class="dot"></div>
<div class="dot"></div>
......
import logsBundle from 'ee/logs';
import KubernetesLogs from '../../../../kubernetes_logs';
if (gon.features.environmentLogsUseVueUi) {
document.addEventListener('DOMContentLoaded', logsBundle);
} else {
document.addEventListener('DOMContentLoaded', () => {
const kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
const kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog.getData();
});
}
document.addEventListener('DOMContentLoaded', logsBundle);
......@@ -9,9 +9,6 @@ module EE
before_action :authorize_read_pod_logs!, only: [:k8s_pod_logs, :logs]
before_action :environment_ee, only: [:k8s_pod_logs, :logs]
before_action :authorize_create_environment_terminal!, only: [:terminal]
before_action do
push_frontend_feature_flag(:environment_logs_use_vue_ui)
end
end
def logs_redirect
......
- if Feature.enabled?('environment_logs_use_vue_ui')
#environment-logs{ data: environment_logs_data(@project, @environment) }
- else
.js-kubernetes-logs{ data: environment_logs_data(@project, @environment) }
.build-page-pod-logs
.build-trace-container.prepend-top-default
.top-bar.js-top-bar.d-flex
.row
.form-group.col-6{ role: 'group' }
%label.d-block.pt-0.col-form-label-sm.col-form-label
= s_('Environments|Environment')
.dropdown.js-environment-dropdown.d-flex
%button.dropdown-menu-toggle.d-flex.align-content-center.align-self-center{ type: 'button', data: { toggle: 'dropdown' }, 'aria-expanded': false }
= icon('chevron-down')
.dropdown-toggle-text
= "&nbsp;".html_safe
.dropdown-menu.dropdown-menu-selectable.dropdown-menu-drop-up
.form-group.col-6{ role: 'group' }
%label.d-block.pt-0.col-form-label-sm.col-form-label
= s_('Environments|Pod logs from')
.dropdown.js-pod-dropdown.d-flex
%button.dropdown-menu-toggle.d-flex.align-content-center.align-self-center{ type: 'button', data: { toggle: 'dropdown' }, 'aria-expanded': false }
= icon('chevron-down')
.dropdown-toggle-text
= "&nbsp;".html_safe
.dropdown-menu.dropdown-menu-selectable.dropdown-menu-drop-up
.controllers.align-self-end
.has-tooltip.controllers-buttons{ title: _('Scroll to top'), data: { placement: 'top', container: 'body'} }
%button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
= custom_icon('scroll_up')
.has-tooltip.controllers-buttons{ title: _('Scroll to bottom'), data: { placement: 'top', container: 'body'} }
%button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true }
= custom_icon('scroll_down')
.refresh-control
.has-tooltip.controllers-buttons{ title: _('Refresh'), data: { placement: 'top', container: 'body'} }
%button.js-refresh-log.btn.btn-default.btn-refresh.h-32-px{ type: 'button', disabled: true }
= sprite_icon('retry')
= render 'shared/builds/build_output'
......@@ -15,7 +15,6 @@ describe 'Environment > Pod Logs', :js do
before do
stub_licensed_features(pod_logs: true)
stub_feature_flags(environment_logs_use_vue_ui: false)
create(:cluster, :provided_by_gcp, environment_scope: '*', projects: [project])
create(:deployment, :success, environment: environment)
......@@ -35,14 +34,34 @@ describe 'Environment > Pod Logs', :js do
sign_in(project.owner)
end
it "shows environments in dropdown" do
create(:environment, project: project)
visit logs_project_environment_path(environment.project, environment, pod_name: pod_name)
wait_for_requests
page.within('.js-environments-dropdown') do
toggle = find(".dropdown-menu-toggle:not([disabled])")
expect(toggle).to have_content(environment.name)
toggle.click
dropdown_items = find(".dropdown-menu").all(".dropdown-item")
expect(dropdown_items.first).to have_content(environment.name)
expect(dropdown_items.size).to eq(2)
end
end
context 'with logs', :use_clean_rails_memory_store_caching do
it "shows pod logs", :sidekiq_might_not_need_inline do
visit logs_project_environment_path(environment.project, environment, pod_name: pod_name)
wait_for_requests
page.within('.js-pod-dropdown') do
find(".dropdown-menu-toggle").click
page.within('.js-pods-dropdown') do
find(".dropdown-menu-toggle:not([disabled])").click
dropdown_items = find(".dropdown-menu").all(".dropdown-item")
expect(dropdown_items.size).to eq(2)
......
import $ from 'jquery';
import KubernetesLogs from 'ee/kubernetes_logs';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import { logMockData, podMockData, mockEnvironmentData } from './kubernetes_mock_data';
describe('Kubernetes Logs', () => {
const fixtureTemplate = 'static/environments_logs.html';
let mockDataset;
let kubernetesLogContainer;
let kubernetesLog;
let mock;
let mockFlash;
let podLogsAPIPath;
preloadFixtures(fixtureTemplate);
describe('When data is requested correctly', () => {
beforeEach(() => {
loadFixtures(fixtureTemplate);
spyOnDependency(KubernetesLogs, 'getParameterValues').and.callFake(() => []);
mockFlash = spyOnDependency(KubernetesLogs, 'flash').and.callFake(() => []);
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
mockDataset = kubernetesLogContainer.dataset;
podLogsAPIPath = `/${mockDataset.projectFullPath}/environments/${mockDataset.environmentId}/pods/containers/logs.json`;
mock = new MockAdapter(axios);
mock.onGet(mockDataset.environmentsPath).reply(200, { environments: mockEnvironmentData });
mock.onGet(podLogsAPIPath).reply(200, { logs: logMockData, pods: podMockData });
});
afterEach(() => {
mock.restore();
});
it('has the environment name placed on the dropdown', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
const dropdown = document
.querySelector('.js-environment-dropdown')
.querySelector('.dropdown-menu-toggle');
expect(dropdown.textContent).toContain(mockDataset.currentEnvironmentName);
done();
})
.catch(done.fail);
});
it('loads all environments as options of their dropdown', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
const options = document
.querySelector('.js-environment-dropdown')
.querySelectorAll('.dropdown-item');
expect(options.length).toEqual(mockEnvironmentData.length);
options.forEach((item, i) => {
expect(item.textContent.trim()).toBe(mockEnvironmentData[i].name);
});
done();
})
.catch(done.fail);
});
it('loads all pod names as options of their dropdown', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
const options = document
.querySelector('.js-pod-dropdown')
.querySelectorAll('.dropdown-item');
expect(options.length).toEqual(podMockData.length);
options.forEach((item, i) => {
expect(item.textContent.trim()).toBe(podMockData[i]);
});
done();
})
.catch(done.fail);
});
it('has the pod name placed on the dropdown', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
const podDropdown = document
.querySelector('.js-pod-dropdown')
.querySelector('.dropdown-menu-toggle');
expect(podDropdown.textContent).toContain(podMockData[0]);
done();
})
.catch(done.fail);
});
it('queries the pod log data and sets the dom elements', done => {
const scrollSpy = spyOnDependency(KubernetesLogs, 'scrollDown').and.callThrough();
const toggleDisableSpy = spyOnDependency(KubernetesLogs, 'toggleDisableButton').and.stub();
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
expect(kubernetesLog.isLogComplete).toEqual(false);
kubernetesLog
.getData()
.then(() => {
expect(kubernetesLog.isLogComplete).toEqual(true);
expect(document.querySelector('.js-build-output').textContent).toContain(
logMockData[0].trim(),
);
expect(scrollSpy).toHaveBeenCalled();
expect(toggleDisableSpy).toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('asks for the pod logs from another pod', done => {
const changePodLogSpy = spyOn(KubernetesLogs.prototype, 'getData').and.callThrough();
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
const podDropdown = document.querySelectorAll('.js-pod-dropdown .dropdown-menu button');
const anotherPod = podDropdown[podDropdown.length - 1];
anotherPod.click();
expect(changePodLogSpy.calls.count()).toEqual(2);
done();
})
.catch(done.fail);
});
it('clears the pod dropdown contents when pod logs are requested', done => {
const emptySpy = spyOn($.prototype, 'empty').and.callThrough();
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
// 3 elems should be emptied:
// 1. the environment dropdown items
// 2. the pods dropdown items
// 3. the job log contents
expect(emptySpy.calls.count()).toEqual(3);
done();
})
.catch(done.fail);
});
describe('shows an alert', () => {
it('with an error', done => {
mock.onGet(podLogsAPIPath).reply(400);
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
expect(mockFlash.calls.count()).toEqual(1);
done();
})
.catch(done.fail);
});
it('with some explicit error', done => {
const errorMsg = 'Some k8s error';
mock.onGet(podLogsAPIPath).reply(400, {
message: errorMsg,
});
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
expect(mockFlash.calls.count()).toEqual(1);
expect(mockFlash.calls.argsFor(0).join()).toContain(errorMsg);
done();
})
.catch(done.fail);
});
});
});
describe('XSS Protection', () => {
const hackyPodName = '">&lt;img src=x onerror=alert(document.domain)&gt; production';
beforeEach(() => {
loadFixtures(fixtureTemplate);
spyOnDependency(KubernetesLogs, 'getParameterValues').and.callFake(() => [hackyPodName]);
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
mock = new MockAdapter(axios);
mock.onGet(podLogsAPIPath).reply(200, { logs: logMockData, pods: [hackyPodName] });
});
afterEach(() => {
mock.restore();
});
it('escapes the pod name', () => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
expect(kubernetesLog.podName).toContain(
'&quot;&gt;&amp;lt;img src=x onerror=alert(document.domain)&amp;gt; production',
);
});
});
describe('When data is not yet loaded into cache', () => {
beforeEach(() => {
loadFixtures(fixtureTemplate);
spyOnDependency(KubernetesLogs, 'getParameterValues').and.callFake(() => [podMockData[1]]);
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
// override setTimeout, to simulate polling
const origSetTimeout = window.setTimeout;
spyOn(window, 'setTimeout').and.callFake(cb => origSetTimeout(cb, 0));
mockDataset = kubernetesLogContainer.dataset;
podLogsAPIPath = `/${mockDataset.projectFullPath}/environments/${
mockDataset.environmentId
}/pods/${podMockData[1]}/containers/logs.json`;
mock = new MockAdapter(axios);
mock.onGet(mockDataset.environmentsPath).reply(200, { environments: mockEnvironmentData });
// Simulate reactive cache, 2 tries needed
mock.onGet(podLogsAPIPath).replyOnce(202);
mock.onGet(podLogsAPIPath).reply(200, { logs: logMockData, pods: podMockData });
});
it('queries the pod log data polling for reactive cache', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
expect(kubernetesLog.isLogComplete).toEqual(false);
kubernetesLog
.getData()
.then(() => {
const calls = mock.history.get.filter(r => r.url === podLogsAPIPath);
// expect 2 tries
expect(calls.length).toEqual(2);
expect(document.querySelector('.js-build-output').textContent).toContain(
logMockData[0].trim(),
);
done();
})
.catch(done.fail);
});
afterEach(() => {
mock.restore();
});
});
describe('When data is requested with a pod name', () => {
beforeEach(() => {
loadFixtures(fixtureTemplate);
spyOnDependency(KubernetesLogs, 'getParameterValues').and.callFake(() => [podMockData[2]]);
kubernetesLogContainer = document.querySelector('.js-kubernetes-logs');
podLogsAPIPath = `/${mockDataset.projectFullPath}/environments/${
mockDataset.environmentId
}/pods/${podMockData[2]}/containers/logs.json`;
mock = new MockAdapter(axios);
});
it('logs are loaded with the correct pod_name parameter', done => {
kubernetesLog = new KubernetesLogs(kubernetesLogContainer);
kubernetesLog
.getData()
.then(() => {
const logsCall = mock.history.get.filter(call => call.url === podLogsAPIPath);
expect(logsCall.length).toBe(1);
done();
})
.catch(done.fail);
});
afterEach(() => {
mock.restore();
});
});
});
......@@ -6524,15 +6524,6 @@ msgstr ""
msgid "Environments|An error occurred while fetching the environments."
msgstr ""
msgid "Environments|An error occurred while fetching the logs"
msgstr ""
msgid "Environments|An error occurred while fetching the logs - Error: %{message}"
msgstr ""
msgid "Environments|An error occurred while fetching the logs for this environment or pod. Please try again"
msgstr ""
msgid "Environments|An error occurred while making the request."
msgstr ""
......
<div
class="js-kubernetes-logs"
data-current-environment-name="production"
data-environments-path="/root/my-project/environments.json"
data-project-full-path="root/my-project"
data-environment-id=1
>
<div class="build-page-pod-logs">
<div class="build-trace-container prepend-top-default">
<div class="top-bar js-top-bar d-flex">
<div class="row">
<div class="form-group col-6" role="group">
<label class="d-block col-form-label-sm col-form-label">
Environment
</label>
<div class="dropdown js-environment-dropdown d-flex">
<button
aria-expanded="false"
class="dropdown-menu-toggle d-flex align-content-center align-self-center"
data-toggle="dropdown"
type="button"
>
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
<div class="dropdown-toggle-text">
&nbsp;
</div>
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"></div>
</div>
</div>
<div class="form-group col-6" role="group">
<label class="d-block col-form-label-sm col-form-label">
Pod logs from
</label>
<div class="dropdown js-pod-dropdown d-flex">
<button
aria-expanded="false"
class="dropdown-menu-toggle d-flex align-content-center align-self-center"
data-toggle="dropdown"
type="button"
>
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
<div class="dropdown-toggle-text">
&nbsp;
</div>
</button>
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"></div>
</div>
</div>
</div>
<div class="controllers align-self-end">
<div
class="has-tooltip controllers-buttons"
data-container="body"
data-placement="top"
title="Scroll to top"
>
<button
class="js-scroll-up btn-scroll btn-transparent btn-blank"
disabled
type="button"
></button>
</div>
<div
class="has-tooltip controllers-buttons"
data-container="body"
data-placement="top"
title="Scroll to bottom"
>
<button
class="js-scroll-down btn-scroll btn-transparent btn-blank"
disabled
type="button"
></button>
</div>
<div class="refresh-control">
<div
class="has-tooltip controllers-buttons"
data-container="body"
data-placement="top"
title="Refresh"
>
<button
class="js-refresh-log btn btn-default btn-refresh h-32-px"
disabled
type="button"
></button>
</div>
</div>
</div>
</div>
<pre class="build-trace" id="build-trace">
<code class="bash js-build-output"></code>
<div class="build-loader-animation js-build-refresh">
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</pre>
</div>
</div>
</div>
......@@ -16,6 +16,7 @@ JSConsoleError = Class.new(StandardError)
JS_CONSOLE_FILTER = Regexp.union([
'"[HMR] Waiting for update signal from WDS..."',
'"[WDS] Hot Module Replacement enabled."',
'"[WDS] Live Reloading enabled."',
"Download the Vue Devtools extension"
])
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment