mirror of
https://github.com/Fishwaldo/rundeck-api-java-client.git
synced 2025-07-08 14:08:33 +00:00
Add bulk Delete executions endpoint
This commit is contained in:
parent
bbe599b915
commit
a7c0c22699
8 changed files with 361 additions and 1 deletions
|
@ -23,6 +23,7 @@ import org.rundeck.api.RundeckApiException.RundeckApiLoginException;
|
||||||
import org.rundeck.api.RundeckApiException.RundeckApiTokenException;
|
import org.rundeck.api.RundeckApiException.RundeckApiTokenException;
|
||||||
import org.rundeck.api.domain.*;
|
import org.rundeck.api.domain.*;
|
||||||
import org.rundeck.api.domain.RundeckExecution.ExecutionStatus;
|
import org.rundeck.api.domain.RundeckExecution.ExecutionStatus;
|
||||||
|
import org.rundeck.api.generator.DeleteExecutionsGenerator;
|
||||||
import org.rundeck.api.generator.ProjectConfigGenerator;
|
import org.rundeck.api.generator.ProjectConfigGenerator;
|
||||||
import org.rundeck.api.generator.ProjectConfigPropertyGenerator;
|
import org.rundeck.api.generator.ProjectConfigPropertyGenerator;
|
||||||
import org.rundeck.api.generator.ProjectGenerator;
|
import org.rundeck.api.generator.ProjectGenerator;
|
||||||
|
@ -95,6 +96,7 @@ public class RundeckClient implements Serializable {
|
||||||
V9(9),
|
V9(9),
|
||||||
V10(10),
|
V10(10),
|
||||||
V11(11),
|
V11(11),
|
||||||
|
V12(12),
|
||||||
;
|
;
|
||||||
|
|
||||||
private int versionNumber;
|
private int versionNumber;
|
||||||
|
@ -108,7 +110,7 @@ public class RundeckClient implements Serializable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/** Version of the API supported */
|
/** Version of the API supported */
|
||||||
public static final transient int API_VERSION = Version.V11.getVersionNumber();
|
public static final transient int API_VERSION = Version.V12.getVersionNumber();
|
||||||
|
|
||||||
private static final String API = "/api/";
|
private static final String API = "/api/";
|
||||||
|
|
||||||
|
@ -1643,6 +1645,30 @@ public class RundeckClient implements Serializable {
|
||||||
return new ApiCall(this).get(apiPath, new AbortParser(rootXpath()+"/abort"));
|
return new ApiCall(this).get(apiPath, new AbortParser(rootXpath()+"/abort"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a set of executions, identified by the given IDs
|
||||||
|
*
|
||||||
|
* @param executionIds set of identifiers for the executions - mandatory
|
||||||
|
* @return a {@link RundeckExecution} instance - won't be null
|
||||||
|
* @throws RundeckApiException in case of error when calling the API (non-existent execution with this ID)
|
||||||
|
* @throws RundeckApiLoginException if the login fails (in case of login-based authentication)
|
||||||
|
* @throws RundeckApiTokenException if the token is invalid (in case of token-based authentication)
|
||||||
|
* @throws IllegalArgumentException if the executionId is null
|
||||||
|
*/
|
||||||
|
public DeleteExecutionsResponse deleteExecutions(Set<Long> executionIds)
|
||||||
|
throws RundeckApiException, RundeckApiLoginException,
|
||||||
|
RundeckApiTokenException, IllegalArgumentException
|
||||||
|
{
|
||||||
|
AssertUtil.notNull(executionIds, "executionIds is mandatory to abort an execution !");
|
||||||
|
final ApiPathBuilder apiPath = new ApiPathBuilder("/executions/delete").xml(
|
||||||
|
new DeleteExecutionsGenerator(executionIds)
|
||||||
|
);
|
||||||
|
return new ApiCall(this).post(
|
||||||
|
apiPath,
|
||||||
|
new DeleteExecutionsResponseParser( rootXpath() + "/deleteExecutions")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* History
|
* History
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -0,0 +1,84 @@
|
||||||
|
package org.rundeck.api.domain;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeleteExecutionsResponse is ...
|
||||||
|
*
|
||||||
|
* @author Greg Schueler <greg@simplifyops.com>
|
||||||
|
* @since 2014-11-06
|
||||||
|
*/
|
||||||
|
public class DeleteExecutionsResponse implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
private int failedCount;
|
||||||
|
private int successCount;
|
||||||
|
private boolean allsuccessful;
|
||||||
|
private int requestCount;
|
||||||
|
private List<DeleteFailure> failures;
|
||||||
|
|
||||||
|
public int getFailedCount() {
|
||||||
|
return failedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFailedCount(final int failedCount) {
|
||||||
|
this.failedCount = failedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getSuccessCount() {
|
||||||
|
return successCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSuccessCount(final int successCount) {
|
||||||
|
this.successCount = successCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAllsuccessful() {
|
||||||
|
return allsuccessful;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAllsuccessful(final boolean allsuccessful) {
|
||||||
|
this.allsuccessful = allsuccessful;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getRequestCount() {
|
||||||
|
return requestCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRequestCount(final int requestCount) {
|
||||||
|
this.requestCount = requestCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<DeleteFailure> getFailures() {
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFailures(final List<DeleteFailure> failures) {
|
||||||
|
this.failures = failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class DeleteFailure implements Serializable{
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
private Long executionId;
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
public Long getExecutionId() {
|
||||||
|
return executionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExecutionId(final Long executionId) {
|
||||||
|
this.executionId = executionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMessage(final String message) {
|
||||||
|
this.message = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
package org.rundeck.api.generator;
|
||||||
|
|
||||||
|
import org.dom4j.DocumentFactory;
|
||||||
|
import org.dom4j.Element;
|
||||||
|
import org.rundeck.api.domain.ProjectConfig;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeleteExecutionsGenerator is ...
|
||||||
|
*
|
||||||
|
* @author Greg Schueler <greg@simplifyops.com>
|
||||||
|
* @since 2014-11-06
|
||||||
|
*/
|
||||||
|
public class DeleteExecutionsGenerator extends BaseDocGenerator {
|
||||||
|
private Set<Long> executionIds;
|
||||||
|
|
||||||
|
public DeleteExecutionsGenerator(final Set<Long> executionIds) {
|
||||||
|
this.executionIds = executionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public Element generateXmlElement() {
|
||||||
|
Element rootElem = DocumentFactory.getInstance().createElement("executions");
|
||||||
|
for (Long executionId : executionIds) {
|
||||||
|
rootElem.addElement("execution").addAttribute("id", Long.toString(executionId));
|
||||||
|
}
|
||||||
|
return rootElem;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Set<Long> getExecutionIds() {
|
||||||
|
return executionIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setExecutionIds(final Set<Long> executionIds) {
|
||||||
|
this.executionIds = executionIds;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
package org.rundeck.api.parser;
|
||||||
|
|
||||||
|
import org.dom4j.Node;
|
||||||
|
import org.rundeck.api.domain.DeleteExecutionsResponse;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeleteExecutionsResponseParser is ...
|
||||||
|
*
|
||||||
|
* @author Greg Schueler <greg@simplifyops.com>
|
||||||
|
* @since 2014-11-06
|
||||||
|
*/
|
||||||
|
public class DeleteExecutionsResponseParser implements XmlNodeParser<DeleteExecutionsResponse> {
|
||||||
|
private String xpath;
|
||||||
|
|
||||||
|
public DeleteExecutionsResponseParser(final String xpath) {
|
||||||
|
this.xpath = xpath;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public DeleteExecutionsResponse parseXmlNode(final Node node) {
|
||||||
|
final Node baseNode = xpath != null ? node.selectSingleNode(xpath) : node;
|
||||||
|
|
||||||
|
final DeleteExecutionsResponse response = new DeleteExecutionsResponse();
|
||||||
|
response.setAllsuccessful(Boolean.parseBoolean(baseNode.valueOf("@allsuccessful")));
|
||||||
|
response.setRequestCount(Integer.parseInt(baseNode.valueOf("@requestCount")));
|
||||||
|
response.setSuccessCount(Integer.parseInt(baseNode.valueOf("successful/@count")));
|
||||||
|
|
||||||
|
final Node failedNode = baseNode.selectSingleNode("failed");
|
||||||
|
//parse failures
|
||||||
|
final List<DeleteExecutionsResponse.DeleteFailure> failures = new ArrayList
|
||||||
|
<DeleteExecutionsResponse.DeleteFailure>();
|
||||||
|
int failedCount = 0;
|
||||||
|
if (null != failedNode) {
|
||||||
|
failedCount = Integer.parseInt(baseNode.valueOf("failed/@count"));
|
||||||
|
final List list = baseNode.selectNodes("failed/execution");
|
||||||
|
|
||||||
|
for (final Object o : list) {
|
||||||
|
final Node execNode = (Node) o;
|
||||||
|
final DeleteExecutionsResponse.DeleteFailure deleteFailure =
|
||||||
|
new DeleteExecutionsResponse.DeleteFailure();
|
||||||
|
deleteFailure.setExecutionId(Long.parseLong(execNode.valueOf("@id")));
|
||||||
|
deleteFailure.setMessage(execNode.valueOf("@message"));
|
||||||
|
failures.add(deleteFailure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.setFailedCount(failedCount);
|
||||||
|
response.setFailures(failures);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
|
@ -56,6 +56,7 @@ public class RundeckClientTest {
|
||||||
public static final String TEST_TOKEN_5 = "C3O6d5O98Kr6Dpv71sdE4ERdCuU12P6d";
|
public static final String TEST_TOKEN_5 = "C3O6d5O98Kr6Dpv71sdE4ERdCuU12P6d";
|
||||||
public static final String TEST_TOKEN_6 = "Do4d3NUD5DKk21DR4sNK755RcPk618vn";
|
public static final String TEST_TOKEN_6 = "Do4d3NUD5DKk21DR4sNK755RcPk618vn";
|
||||||
public static final String TEST_TOKEN_7 = "8Dp9op111ER6opsDRkddvE86K9sE499s";
|
public static final String TEST_TOKEN_7 = "8Dp9op111ER6opsDRkddvE86K9sE499s";
|
||||||
|
public static final String TEST_TOKEN_8 = "GG7uj1y6UGahOs7QlmeN2sIwz1Y2j7zI";
|
||||||
|
|
||||||
@Rule
|
@Rule
|
||||||
public Recorder recorder = new Recorder();
|
public Recorder recorder = new Recorder();
|
||||||
|
@ -1490,6 +1491,87 @@ public class RundeckClientTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* delete executions with failure
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Betamax(tape = "delete_executions_unauthorized", mode = TapeMode.READ_ONLY)
|
||||||
|
public void deleteExecutionsUnauthorized() throws Exception {
|
||||||
|
final RundeckClient client = createClient(TEST_TOKEN_8, 12);
|
||||||
|
final DeleteExecutionsResponse response = client.deleteExecutions(
|
||||||
|
new HashSet<Long>() {{
|
||||||
|
add(640L);
|
||||||
|
add(641L);
|
||||||
|
}}
|
||||||
|
);
|
||||||
|
Assert.assertEquals(2, response.getRequestCount());
|
||||||
|
Assert.assertEquals(0, response.getSuccessCount());
|
||||||
|
Assert.assertEquals(2, response.getFailedCount());
|
||||||
|
Assert.assertFalse(response.isAllsuccessful());
|
||||||
|
Assert.assertNotNull(response.getFailures());
|
||||||
|
Assert.assertEquals(2, response.getFailures().size());
|
||||||
|
Assert.assertEquals(Long.valueOf(641L), response.getFailures().get(0).getExecutionId());
|
||||||
|
Assert.assertEquals(
|
||||||
|
"Unauthorized: Delete execution in project test",
|
||||||
|
response.getFailures().get(0).getMessage()
|
||||||
|
);
|
||||||
|
Assert.assertEquals(Long.valueOf(640L), response.getFailures().get(1).getExecutionId());
|
||||||
|
Assert.assertEquals(
|
||||||
|
"Unauthorized: Delete execution in project test",
|
||||||
|
response.getFailures().get(1).getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* delete executions with success
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Betamax(tape = "delete_executions_success", mode = TapeMode.READ_ONLY)
|
||||||
|
public void deleteExecutionsSuccess() throws Exception {
|
||||||
|
final RundeckClient client = createClient(TEST_TOKEN_8, 12);
|
||||||
|
final DeleteExecutionsResponse response = client.deleteExecutions(
|
||||||
|
new HashSet<Long>() {{
|
||||||
|
add(640L);
|
||||||
|
add(641L);
|
||||||
|
}}
|
||||||
|
);
|
||||||
|
Assert.assertEquals(2, response.getRequestCount());
|
||||||
|
Assert.assertEquals(2, response.getSuccessCount());
|
||||||
|
Assert.assertEquals(0, response.getFailedCount());
|
||||||
|
Assert.assertTrue(response.isAllsuccessful());
|
||||||
|
Assert.assertNotNull(response.getFailures());
|
||||||
|
Assert.assertEquals(0, response.getFailures().size());
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* delete executions mixed success
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
@Betamax(tape = "delete_executions_mixed", mode = TapeMode.READ_ONLY)
|
||||||
|
public void deleteExecutionsMixed() throws Exception {
|
||||||
|
final RundeckClient client = createClient(TEST_TOKEN_8, 12);
|
||||||
|
final DeleteExecutionsResponse response = client.deleteExecutions(
|
||||||
|
new HashSet<Long>() {{
|
||||||
|
add(642L);
|
||||||
|
add(640L);
|
||||||
|
add(1640L);
|
||||||
|
}}
|
||||||
|
);
|
||||||
|
Assert.assertEquals(3, response.getRequestCount());
|
||||||
|
Assert.assertEquals(1, response.getSuccessCount());
|
||||||
|
Assert.assertEquals(2, response.getFailedCount());
|
||||||
|
Assert.assertFalse(response.isAllsuccessful());
|
||||||
|
Assert.assertNotNull(response.getFailures());
|
||||||
|
Assert.assertEquals(2, response.getFailures().size());
|
||||||
|
Assert.assertEquals(Long.valueOf(1640L), response.getFailures().get(0).getExecutionId());
|
||||||
|
Assert.assertEquals(
|
||||||
|
"Execution Not found: 1640",
|
||||||
|
response.getFailures().get(0).getMessage()
|
||||||
|
);
|
||||||
|
Assert.assertEquals(Long.valueOf(640L), response.getFailures().get(1).getExecutionId());
|
||||||
|
Assert.assertEquals(
|
||||||
|
"Execution Not found: 640",
|
||||||
|
response.getFailures().get(1).getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
@Before
|
@Before
|
||||||
public void setUp() throws Exception {
|
public void setUp() throws Exception {
|
||||||
// not that you can put whatever here, because we don't actually connect to the RunDeck instance
|
// not that you can put whatever here, because we don't actually connect to the RunDeck instance
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
!tape
|
||||||
|
name: delete_executions_mixed
|
||||||
|
interactions:
|
||||||
|
- recorded: 2014-11-06T17:29:44.266Z
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
uri: http://rundeck.local:4440/api/12/executions/delete
|
||||||
|
headers:
|
||||||
|
Accept: text/xml
|
||||||
|
Content-Type: application/xml
|
||||||
|
Host: rundeck.local:4440
|
||||||
|
Proxy-Connection: Keep-Alive
|
||||||
|
Transfer-Encoding: chunked
|
||||||
|
User-Agent: RunDeck API Java Client 12
|
||||||
|
X-RunDeck-Auth-Token: GG7uj1y6UGahOs7QlmeN2sIwz1Y2j7zI
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
headers:
|
||||||
|
Content-Type: application/xml;charset=UTF-8
|
||||||
|
Expires: Thu, 01 Jan 1970 00:00:00 GMT
|
||||||
|
Server: Jetty(7.6.0.v20120127)
|
||||||
|
Set-Cookie: JSESSIONID=smlj6wcfe4yccemt6bptnmgy;Path=/
|
||||||
|
X-Rundeck-API-Version: '12'
|
||||||
|
X-Rundeck-API-XML-Response-Wrapper: 'false'
|
||||||
|
body: !!binary |-
|
||||||
|
PGRlbGV0ZUV4ZWN1dGlvbnMgcmVxdWVzdENvdW50PSczJyBhbGxzdWNjZXNzZnVsPSdmYWxzZSc+CiAgPHN1Y2Nlc3NmdWwgY291bnQ9JzEnIC8+CiAgPGZhaWxlZCBjb3VudD0nMic+CiAgICA8ZXhlY3V0aW9uIGlkPScxNjQwJyBtZXNzYWdlPSdFeGVjdXRpb24gTm90IGZvdW5kOiAxNjQwJyAvPgogICAgPGV4ZWN1dGlvbiBpZD0nNjQwJyBtZXNzYWdlPSdFeGVjdXRpb24gTm90IGZvdW5kOiA2NDAnIC8+CiAgPC9mYWlsZWQ+CjwvZGVsZXRlRXhlY3V0aW9ucz4=
|
|
@ -0,0 +1,26 @@
|
||||||
|
!tape
|
||||||
|
name: delete_executions_success
|
||||||
|
interactions:
|
||||||
|
- recorded: 2014-11-06T17:24:56.487Z
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
uri: http://rundeck.local:4440/api/12/executions/delete
|
||||||
|
headers:
|
||||||
|
Accept: text/xml
|
||||||
|
Content-Type: application/xml
|
||||||
|
Host: rundeck.local:4440
|
||||||
|
Proxy-Connection: Keep-Alive
|
||||||
|
Transfer-Encoding: chunked
|
||||||
|
User-Agent: RunDeck API Java Client 12
|
||||||
|
X-RunDeck-Auth-Token: GG7uj1y6UGahOs7QlmeN2sIwz1Y2j7zI
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
headers:
|
||||||
|
Content-Type: application/xml;charset=UTF-8
|
||||||
|
Expires: Thu, 01 Jan 1970 00:00:00 GMT
|
||||||
|
Server: Jetty(7.6.0.v20120127)
|
||||||
|
Set-Cookie: JSESSIONID=1xueq6cjfrsnxjn43hcfadqyk;Path=/
|
||||||
|
X-Rundeck-API-Version: '12'
|
||||||
|
X-Rundeck-API-XML-Response-Wrapper: 'false'
|
||||||
|
body: !!binary |-
|
||||||
|
PGRlbGV0ZUV4ZWN1dGlvbnMgcmVxdWVzdENvdW50PScyJyBhbGxzdWNjZXNzZnVsPSd0cnVlJz4KICA8c3VjY2Vzc2Z1bCBjb3VudD0nMicgLz4KPC9kZWxldGVFeGVjdXRpb25zPg==
|
|
@ -0,0 +1,26 @@
|
||||||
|
!tape
|
||||||
|
name: delete_executions_success
|
||||||
|
interactions:
|
||||||
|
- recorded: 2014-11-06T17:15:24.673Z
|
||||||
|
request:
|
||||||
|
method: POST
|
||||||
|
uri: http://rundeck.local:4440/api/12/executions/delete
|
||||||
|
headers:
|
||||||
|
Accept: text/xml
|
||||||
|
Content-Type: application/xml
|
||||||
|
Host: rundeck.local:4440
|
||||||
|
Proxy-Connection: Keep-Alive
|
||||||
|
Transfer-Encoding: chunked
|
||||||
|
User-Agent: RunDeck API Java Client 12
|
||||||
|
X-RunDeck-Auth-Token: GG7uj1y6UGahOs7QlmeN2sIwz1Y2j7zI
|
||||||
|
response:
|
||||||
|
status: 200
|
||||||
|
headers:
|
||||||
|
Content-Type: application/xml;charset=UTF-8
|
||||||
|
Expires: Thu, 01 Jan 1970 00:00:00 GMT
|
||||||
|
Server: Jetty(7.6.0.v20120127)
|
||||||
|
Set-Cookie: JSESSIONID=1kzmot0r2scpjfxlcpfthh7tz;Path=/
|
||||||
|
X-Rundeck-API-Version: '12'
|
||||||
|
X-Rundeck-API-XML-Response-Wrapper: 'false'
|
||||||
|
body: !!binary |-
|
||||||
|
PGRlbGV0ZUV4ZWN1dGlvbnMgcmVxdWVzdENvdW50PScyJyBhbGxzdWNjZXNzZnVsPSdmYWxzZSc+CiAgPHN1Y2Nlc3NmdWwgY291bnQ9JzAnIC8+CiAgPGZhaWxlZCBjb3VudD0nMic+CiAgICA8ZXhlY3V0aW9uIGlkPSc2NDEnIG1lc3NhZ2U9J1VuYXV0aG9yaXplZDogRGVsZXRlIGV4ZWN1dGlvbiBpbiBwcm9qZWN0IHRlc3QnIC8+CiAgICA8ZXhlY3V0aW9uIGlkPSc2NDAnIG1lc3NhZ2U9J1VuYXV0aG9yaXplZDogRGVsZXRlIGV4ZWN1dGlvbiBpbiBwcm9qZWN0IHRlc3QnIC8+CiAgPC9mYWlsZWQ+CjwvZGVsZXRlRXhlY3V0aW9ucz4=
|
Loading…
Add table
Add a link
Reference in a new issue