mirror of
https://github.com/ansible/awx.git
synced 2026-03-06 11:11:07 -03:30
Merge pull request #4816 from marshmalien/add-missing-job-detail-fields
Add missing job detail fields Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
@@ -11,7 +11,7 @@ const DetailList = ({ children, stacked, ...props }) => (
|
|||||||
export default styled(DetailList)`
|
export default styled(DetailList)`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-gap: 20px;
|
grid-gap: 20px;
|
||||||
align-items: baseline;
|
align-items: flex-start;
|
||||||
${props =>
|
${props =>
|
||||||
props.stacked
|
props.stacked
|
||||||
? `
|
? `
|
||||||
|
|||||||
@@ -75,11 +75,12 @@ const SkippedBottom = styled.div`
|
|||||||
|
|
||||||
const StatusIcon = ({ status, ...props }) => {
|
const StatusIcon = ({ status, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<div {...props}>
|
<div className="at-c-statusIcon" {...props}>
|
||||||
{status === 'running' && <RunningJob />}
|
{status === 'running' && <RunningJob />}
|
||||||
{(status === 'new' || status === 'pending' || status === 'waiting') && (
|
{(status === 'new' ||
|
||||||
<WaitingJob />
|
status === 'pending' ||
|
||||||
)}
|
status === 'waiting' ||
|
||||||
|
status === 'never updated') && <WaitingJob />}
|
||||||
{(status === 'failed' || status === 'error' || status === 'canceled') && (
|
{(status === 'failed' || status === 'error' || status === 'canceled') && (
|
||||||
<FinishedJob>
|
<FinishedJob>
|
||||||
<FailedTop />
|
<FailedTop />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DetailList, Detail } from '@components/DetailList';
|
|||||||
import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
|
import { ChipGroup, Chip, CredentialChip } from '@components/Chip';
|
||||||
import { VariablesInput as _VariablesInput } from '@components/CodeMirrorInput';
|
import { VariablesInput as _VariablesInput } from '@components/CodeMirrorInput';
|
||||||
import ErrorDetail from '@components/ErrorDetail';
|
import ErrorDetail from '@components/ErrorDetail';
|
||||||
|
import { StatusIcon } from '@components/Sparkline';
|
||||||
import { toTitleCase } from '@util/strings';
|
import { toTitleCase } from '@util/strings';
|
||||||
import { Job } from '../../../types';
|
import { Job } from '../../../types';
|
||||||
import {
|
import {
|
||||||
@@ -37,6 +38,14 @@ const VariablesInput = styled(_VariablesInput)`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const StatusDetailValue = styled.div`
|
||||||
|
align-items: center;
|
||||||
|
display: inline-flex;
|
||||||
|
.at-c-statusIcon {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const VERBOSITY = {
|
const VERBOSITY = {
|
||||||
0: '0 (Normal)',
|
0: '0 (Normal)',
|
||||||
1: '1 (Verbose)',
|
1: '1 (Verbose)',
|
||||||
@@ -45,18 +54,50 @@ const VERBOSITY = {
|
|||||||
4: '4 (Connection Debug)',
|
4: '4 (Connection Debug)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLaunchedByDetails = ({ summary_fields = {}, related = {} }) => {
|
||||||
|
const {
|
||||||
|
created_by: createdBy,
|
||||||
|
job_template: jobTemplate,
|
||||||
|
schedule,
|
||||||
|
} = summary_fields;
|
||||||
|
const { schedule: relatedSchedule } = related;
|
||||||
|
|
||||||
|
if (!createdBy && !schedule) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let link;
|
||||||
|
let value;
|
||||||
|
|
||||||
|
if (createdBy) {
|
||||||
|
link = `/users/${createdBy.id}`;
|
||||||
|
value = createdBy.username;
|
||||||
|
} else if (relatedSchedule && jobTemplate) {
|
||||||
|
link = `/templates/job_template/${jobTemplate.id}/schedules/${schedule.id}`;
|
||||||
|
value = schedule.name;
|
||||||
|
} else {
|
||||||
|
link = null;
|
||||||
|
value = schedule.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { link, value };
|
||||||
|
};
|
||||||
|
|
||||||
function JobDetail({ job, i18n, history }) {
|
function JobDetail({ job, i18n, history }) {
|
||||||
const {
|
const {
|
||||||
job_template: jobTemplate,
|
|
||||||
project,
|
|
||||||
inventory,
|
|
||||||
instance_group: instanceGroup,
|
|
||||||
credentials,
|
credentials,
|
||||||
|
instance_group: instanceGroup,
|
||||||
|
inventory,
|
||||||
|
job_template: jobTemplate,
|
||||||
labels,
|
labels,
|
||||||
|
project,
|
||||||
} = job.summary_fields;
|
} = job.summary_fields;
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
const [errorMsg, setErrorMsg] = useState();
|
const [errorMsg, setErrorMsg] = useState();
|
||||||
|
|
||||||
|
const { value: launchedByValue, link: launchedByLink } =
|
||||||
|
getLaunchedByDetails(job) || {};
|
||||||
|
|
||||||
const deleteJob = async () => {
|
const deleteJob = async () => {
|
||||||
try {
|
try {
|
||||||
switch (job.type) {
|
switch (job.type) {
|
||||||
@@ -84,11 +125,20 @@ function JobDetail({ job, i18n, history }) {
|
|||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<DetailList>
|
<DetailList>
|
||||||
{/* TODO: add status icon? */}
|
{/* TODO: hookup status to websockets */}
|
||||||
<Detail label={i18n._(t`Status`)} value={toTitleCase(job.status)} />
|
<Detail
|
||||||
|
label={i18n._(t`Status`)}
|
||||||
|
value={
|
||||||
|
<StatusDetailValue>
|
||||||
|
{job.status && <StatusIcon status={job.status} />}
|
||||||
|
{toTitleCase(job.status)}
|
||||||
|
</StatusDetailValue>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Detail label={i18n._(t`Started`)} value={job.started} />
|
<Detail label={i18n._(t`Started`)} value={job.started} />
|
||||||
<Detail label={i18n._(t`Finished`)} value={job.finished} />
|
<Detail label={i18n._(t`Finished`)} value={job.finished} />
|
||||||
{jobTemplate && (
|
{jobTemplate && (
|
||||||
@@ -102,6 +152,16 @@ function JobDetail({ job, i18n, history }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Detail label={i18n._(t`Job Type`)} value={toTitleCase(job.job_type)} />
|
<Detail label={i18n._(t`Job Type`)} value={toTitleCase(job.job_type)} />
|
||||||
|
<Detail
|
||||||
|
label={i18n._(t`Launched By`)}
|
||||||
|
value={
|
||||||
|
launchedByLink ? (
|
||||||
|
<Link to={`${launchedByLink}`}>{launchedByValue}</Link>
|
||||||
|
) : (
|
||||||
|
launchedByValue
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
{inventory && (
|
{inventory && (
|
||||||
<Detail
|
<Detail
|
||||||
label={i18n._(t`Inventory`)}
|
label={i18n._(t`Inventory`)}
|
||||||
@@ -110,19 +170,23 @@ function JobDetail({ job, i18n, history }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* TODO: show project status icon */}
|
|
||||||
{project && (
|
{project && (
|
||||||
<Detail
|
<Detail
|
||||||
label={i18n._(t`Project`)}
|
label={i18n._(t`Project`)}
|
||||||
value={<Link to={`/projects/${project.id}`}>{project.name}</Link>}
|
value={
|
||||||
|
<StatusDetailValue>
|
||||||
|
{project.status && <StatusIcon status={project.status} />}
|
||||||
|
<Link to={`/projects/${project.id}`}>{project.name}</Link>
|
||||||
|
</StatusDetailValue>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Detail label={i18n._(t`Revision`)} value={job.scm_revision} />
|
<Detail label={i18n._(t`Revision`)} value={job.scm_revision} />
|
||||||
<Detail label={i18n._(t`Playbook`)} value={null} />
|
<Detail label={i18n._(t`Playbook`)} value={job.playbook} />
|
||||||
<Detail label={i18n._(t`Limit`)} value={job.limit} />
|
<Detail label={i18n._(t`Limit`)} value={job.limit} />
|
||||||
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[job.verbosity]} />
|
<Detail label={i18n._(t`Verbosity`)} value={VERBOSITY[job.verbosity]} />
|
||||||
<Detail label={i18n._(t`Environment`)} value={null} />
|
<Detail label={i18n._(t`Environment`)} value={job.custom_virtualenv} />
|
||||||
<Detail label={i18n._(t`Execution Node`)} value={job.exucution_node} />
|
<Detail label={i18n._(t`Execution Node`)} value={job.execution_node} />
|
||||||
{instanceGroup && (
|
{instanceGroup && (
|
||||||
<Detail
|
<Detail
|
||||||
label={i18n._(t`Instance Group`)}
|
label={i18n._(t`Instance Group`)}
|
||||||
|
|||||||
@@ -4,78 +4,77 @@ import { mountWithContexts } from '@testUtils/enzymeHelpers';
|
|||||||
import { sleep } from '@testUtils/testUtils';
|
import { sleep } from '@testUtils/testUtils';
|
||||||
import JobDetail from './JobDetail';
|
import JobDetail from './JobDetail';
|
||||||
import { JobsAPI, ProjectUpdatesAPI } from '@api';
|
import { JobsAPI, ProjectUpdatesAPI } from '@api';
|
||||||
|
import mockJobData from '../shared/data.job.json';
|
||||||
|
|
||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
|
||||||
describe('<JobDetail />', () => {
|
describe('<JobDetail />', () => {
|
||||||
let job;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
job = {
|
|
||||||
name: 'Foo',
|
|
||||||
summary_fields: {},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
test('initially renders succesfully', () => {
|
test('initially renders succesfully', () => {
|
||||||
mountWithContexts(<JobDetail job={job} />);
|
mountWithContexts(<JobDetail job={mockJobData} />);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display a Close button', () => {
|
test('should display a Close button', () => {
|
||||||
const wrapper = mountWithContexts(<JobDetail job={job} />);
|
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
|
||||||
|
|
||||||
expect(wrapper.find('Button[aria-label="close"]').length).toBe(1);
|
expect(wrapper.find('Button[aria-label="close"]').length).toBe(1);
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display details', () => {
|
test('should display details', () => {
|
||||||
job.status = 'Successful';
|
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
|
||||||
job.started = '2019-07-02T17:35:22.753817Z';
|
|
||||||
job.finished = '2019-07-02T17:35:34.910800Z';
|
|
||||||
|
|
||||||
const wrapper = mountWithContexts(<JobDetail job={job} />);
|
function assertDetail(label, value) {
|
||||||
const details = wrapper.find('Detail');
|
expect(wrapper.find(`Detail[label="${label}"] dt`).text()).toBe(label);
|
||||||
|
expect(wrapper.find(`Detail[label="${label}"] dd`).text()).toBe(value);
|
||||||
function assertDetail(detail, label, value) {
|
|
||||||
expect(detail.prop('label')).toEqual(label);
|
|
||||||
expect(detail.prop('value')).toEqual(value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
assertDetail(details.at(0), 'Status', 'Successful');
|
assertDetail('Status', 'Successful');
|
||||||
assertDetail(details.at(1), 'Started', job.started);
|
assertDetail('Started', mockJobData.started);
|
||||||
assertDetail(details.at(2), 'Finished', job.finished);
|
assertDetail('Finished', mockJobData.finished);
|
||||||
|
assertDetail('Template', mockJobData.summary_fields.job_template.name);
|
||||||
|
assertDetail('Job Type', 'Run');
|
||||||
|
assertDetail('Launched By', mockJobData.summary_fields.created_by.username);
|
||||||
|
assertDetail('Inventory', mockJobData.summary_fields.inventory.name);
|
||||||
|
assertDetail('Project', mockJobData.summary_fields.project.name);
|
||||||
|
assertDetail('Revision', mockJobData.scm_revision);
|
||||||
|
assertDetail('Playbook', mockJobData.playbook);
|
||||||
|
assertDetail('Verbosity', '0 (Normal)');
|
||||||
|
assertDetail('Environment', mockJobData.custom_virtualenv);
|
||||||
|
assertDetail('Execution Node', mockJobData.execution_node);
|
||||||
|
assertDetail(
|
||||||
|
'Instance Group',
|
||||||
|
mockJobData.summary_fields.instance_group.name
|
||||||
|
);
|
||||||
|
assertDetail('Job Slice', '0/1');
|
||||||
|
assertDetail('Credentials', 'SSH: Demo Credential');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display credentials', () => {
|
test('should display credentials', () => {
|
||||||
job.summary_fields.credentials = [
|
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Foo',
|
|
||||||
cloud: false,
|
|
||||||
kind: 'ssh',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const wrapper = mountWithContexts(<JobDetail job={job} />);
|
|
||||||
const credentialChip = wrapper.find('CredentialChip');
|
const credentialChip = wrapper.find('CredentialChip');
|
||||||
|
|
||||||
expect(credentialChip.prop('credential')).toEqual(
|
expect(credentialChip.prop('credential')).toEqual(
|
||||||
job.summary_fields.credentials[0]
|
mockJobData.summary_fields.credentials[0]
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should display successful job status icon', () => {
|
||||||
|
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
|
||||||
|
const statusDetail = wrapper.find('Detail[label="Status"]');
|
||||||
|
expect(statusDetail.find('StatusIcon__SuccessfulTop')).toHaveLength(1);
|
||||||
|
expect(statusDetail.find('StatusIcon__SuccessfulBottom')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display successful project status icon', () => {
|
||||||
|
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
|
||||||
|
const statusDetail = wrapper.find('Detail[label="Project"]');
|
||||||
|
expect(statusDetail.find('StatusIcon__SuccessfulTop')).toHaveLength(1);
|
||||||
|
expect(statusDetail.find('StatusIcon__SuccessfulBottom')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
test('should properly delete job', async () => {
|
test('should properly delete job', async () => {
|
||||||
job = {
|
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
|
||||||
name: 'Rage',
|
wrapper.find('button[aria-label="Delete"]').simulate('click');
|
||||||
id: 1,
|
|
||||||
type: 'job',
|
|
||||||
summary_fields: {
|
|
||||||
job_template: { name: 'Spud' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const wrapper = mountWithContexts(<JobDetail job={job} />);
|
|
||||||
wrapper
|
|
||||||
.find('button')
|
|
||||||
.at(0)
|
|
||||||
.simulate('click');
|
|
||||||
await sleep(1);
|
await sleep(1);
|
||||||
wrapper.update();
|
wrapper.update();
|
||||||
const modal = wrapper.find('Modal');
|
const modal = wrapper.find('Modal');
|
||||||
@@ -83,17 +82,12 @@ describe('<JobDetail />', () => {
|
|||||||
modal.find('button[aria-label="Delete"]').simulate('click');
|
modal.find('button[aria-label="Delete"]').simulate('click');
|
||||||
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
|
expect(JobsAPI.destroy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
// The test below is skipped until react can be upgraded to at least 16.9.0. An upgrade to
|
/*
|
||||||
// react - router will likely be necessary also.
|
The test below is skipped until react can be upgraded to at least 16.9.0. An upgrade to
|
||||||
|
react - router will likely be necessary also.
|
||||||
|
See: https://github.com/ansible/awx/issues/4817
|
||||||
|
*/
|
||||||
test.skip('should display error modal when a job does not delete properly', async () => {
|
test.skip('should display error modal when a job does not delete properly', async () => {
|
||||||
job = {
|
|
||||||
name: 'Angry',
|
|
||||||
id: 'a',
|
|
||||||
type: 'project_update',
|
|
||||||
summary_fields: {
|
|
||||||
job_template: { name: 'Peanut' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
ProjectUpdatesAPI.destroy.mockRejectedValue(
|
ProjectUpdatesAPI.destroy.mockRejectedValue(
|
||||||
new Error({
|
new Error({
|
||||||
response: {
|
response: {
|
||||||
@@ -106,7 +100,7 @@ describe('<JobDetail />', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const wrapper = mountWithContexts(<JobDetail job={job} />);
|
const wrapper = mountWithContexts(<JobDetail job={mockJobData} />);
|
||||||
|
|
||||||
wrapper
|
wrapper
|
||||||
.find('button')
|
.find('button')
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const Modal = styled(PFModal)`
|
|||||||
const HostNameDetailValue = styled.div`
|
const HostNameDetailValue = styled.div`
|
||||||
align-items: center;
|
align-items: center;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
> div {
|
.at-c-statusIcon {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers';
|
||||||
import JobOutput from './JobOutput';
|
import JobOutput from './JobOutput';
|
||||||
import { JobsAPI } from '@api';
|
import { JobsAPI } from '@api';
|
||||||
import mockJobData from './data.job.json';
|
import mockJobData from '../shared/data.job.json';
|
||||||
import mockJobEventsData from './data.job_events.json';
|
import mockJobEventsData from './data.job_events.json';
|
||||||
|
|
||||||
jest.mock('@api');
|
jest.mock('@api');
|
||||||
|
|||||||
@@ -321,9 +321,9 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
|
|||||||
"componentStyle": ComponentStyle {
|
"componentStyle": ComponentStyle {
|
||||||
"componentId": "DetailList-sc-12g7m4-0",
|
"componentId": "DetailList-sc-12g7m4-0",
|
||||||
"isStatic": false,
|
"isStatic": false,
|
||||||
"lastClassName": "gmERnX",
|
"lastClassName": "eYaZBv",
|
||||||
"rules": Array [
|
"rules": Array [
|
||||||
"display:grid;grid-gap:20px;align-items:baseline;",
|
"display:grid;grid-gap:20px;align-items:flex-start;",
|
||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -341,15 +341,15 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
|
|||||||
stacked={true}
|
stacked={true}
|
||||||
>
|
>
|
||||||
<DetailList
|
<DetailList
|
||||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
className="DetailList-sc-12g7m4-0 eYaZBv"
|
||||||
stacked={true}
|
stacked={true}
|
||||||
>
|
>
|
||||||
<TextList
|
<TextList
|
||||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
className="DetailList-sc-12g7m4-0 eYaZBv"
|
||||||
component="dl"
|
component="dl"
|
||||||
>
|
>
|
||||||
<dl
|
<dl
|
||||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
className="DetailList-sc-12g7m4-0 eYaZBv"
|
||||||
data-pf-content={true}
|
data-pf-content={true}
|
||||||
>
|
>
|
||||||
<Detail
|
<Detail
|
||||||
@@ -484,9 +484,9 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
|
|||||||
"componentStyle": ComponentStyle {
|
"componentStyle": ComponentStyle {
|
||||||
"componentId": "DetailList-sc-12g7m4-0",
|
"componentId": "DetailList-sc-12g7m4-0",
|
||||||
"isStatic": false,
|
"isStatic": false,
|
||||||
"lastClassName": "gmERnX",
|
"lastClassName": "eYaZBv",
|
||||||
"rules": Array [
|
"rules": Array [
|
||||||
"display:grid;grid-gap:20px;align-items:baseline;",
|
"display:grid;grid-gap:20px;align-items:flex-start;",
|
||||||
[Function],
|
[Function],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -504,15 +504,15 @@ exports[`<OrganizationAccessItem /> initially renders succesfully 1`] = `
|
|||||||
stacked={true}
|
stacked={true}
|
||||||
>
|
>
|
||||||
<DetailList
|
<DetailList
|
||||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
className="DetailList-sc-12g7m4-0 eYaZBv"
|
||||||
stacked={true}
|
stacked={true}
|
||||||
>
|
>
|
||||||
<TextList
|
<TextList
|
||||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
className="DetailList-sc-12g7m4-0 eYaZBv"
|
||||||
component="dl"
|
component="dl"
|
||||||
>
|
>
|
||||||
<dl
|
<dl
|
||||||
className="DetailList-sc-12g7m4-0 gmERnX"
|
className="DetailList-sc-12g7m4-0 eYaZBv"
|
||||||
data-pf-content={true}
|
data-pf-content={true}
|
||||||
>
|
>
|
||||||
<Detail
|
<Detail
|
||||||
|
|||||||
Reference in New Issue
Block a user