Commit 3c86cc65 authored by Stan Hu's avatar Stan Hu

Merge branch '34306-remove-environment_logs_use_vue_ui-2' into 'master'

Remove environment_logs_use_vue_ui feature flag and corresponding code

Closes #34306

See merge request gitlab-org/gitlab!20274
parents d7efcb63 e5cf4878
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'
#environment-logs{ data: environment_logs_data(@project, @environment) }
......@@ -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