support vars in interface

This commit is contained in:
Evgenii Alekseev 2023-10-25 18:36:09 +03:00
parent 8bc185049c
commit 00bfedc47f
30 changed files with 961 additions and 206 deletions

View File

@ -51,7 +51,7 @@
</button> </button>
</li> </li>
<li> <li>
<button id="package-update-button" class="btn dropdown-item" onclick="updatePackages()" hidden> <button id="package-update-button" class="btn dropdown-item" onclick="packagesUpdate()" hidden>
<i class="bi bi-play"></i> update <i class="bi bi-play"></i> update
</button> </button>
</li> </li>
@ -61,7 +61,7 @@
</button> </button>
</li> </li>
<li> <li>
<button id="package-remove-button" class="btn dropdown-item" onclick="removePackages()" disabled hidden> <button id="package-remove-button" class="btn dropdown-item" onclick="packagesRemove()" disabled hidden>
<i class="bi bi-trash"></i> remove <i class="bi bi-trash"></i> remove
</button> </button>
</li> </li>
@ -77,7 +77,8 @@
</button> </button>
</div> </div>
<table id="packages" class="table table-striped table-hover" <table id="packages"
data-classes="table table-hover"
data-export-options='{"fileName": "packages"}' data-export-options='{"fileName": "packages"}'
data-filter-control="true" data-filter-control="true"
data-filter-control-visible="false" data-filter-control-visible="false"
@ -102,13 +103,13 @@
<tr> <tr>
<th data-checkbox="true"></th> <th data-checkbox="true"></th>
<th data-sortable="true" data-switchable="false" data-field="base" data-filter-control="input" data-filter-control-placeholder="(any base)">package base</th> <th data-sortable="true" data-switchable="false" data-field="base" data-filter-control="input" data-filter-control-placeholder="(any base)">package base</th>
<th data-sortable="true" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th> <th data-sortable="true" data-align="right" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th>
<th data-sortable="true" data-field="packages" data-filter-control="input" data-filter-control-placeholder="(any package)">packages</th> <th data-sortable="true" data-field="packages" data-filter-control="input" data-filter-control-placeholder="(any package)">packages</th>
<th data-sortable="true" data-visible="false" data-field="groups" data-filter-control="select" data-filter-data="func:filterListGroups" data-filter-custom-search="filterList" data-filter-control-placeholder="(any group)">groups</th> <th data-sortable="true" data-visible="false" data-field="groups" data-filter-control="select" data-filter-data="func:filterListGroups" data-filter-custom-search="filterList" data-filter-control-placeholder="(any group)">groups</th>
<th data-sortable="true" data-visible="false" data-field="licenses" data-filter-control="select" data-filter-data="func:filterListLicenses" data-filter-custom-search="filterList" data-filter-control-placeholder="(any license)">licenses</th> <th data-sortable="true" data-visible="false" data-field="licenses" data-filter-control="select" data-filter-data="func:filterListLicenses" data-filter-custom-search="filterList" data-filter-control-placeholder="(any license)">licenses</th>
<th data-sortable="true" data-visible="false" data-field="packager" data-filter-control="select" data-filter-custom-search="filterContains" data-filter-control-placeholder="(any packager)">packager</th> <th data-sortable="true" data-visible="false" data-field="packager" data-filter-control="select" data-filter-custom-search="filterContains" data-filter-control-placeholder="(any packager)">packager</th>
<th data-sortable="true" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">last update</th> <th data-sortable="true" data-align="right" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">last update</th>
<th data-sortable="true" data-cell-style="statusFormat" data-field="status" data-filter-control="select" data-filter-control-placeholder="(any status)">status</th> <th data-sortable="true" data-align="center" data-cell-style="statusFormat" data-field="status" data-filter-control="select" data-filter-control-placeholder="(any status)">status</th>
</tr> </tr>
</thead> </thead>
</table> </table>

View File

@ -8,20 +8,20 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label for="key-import-fingerprint-input" class="col-sm-2 col-form-label">fingerprint</label> <label for="key-import-fingerprint-input" class="col-2 col-form-label">fingerprint</label>
<div class="col-sm-10"> <div class="col-10">
<input id="key-import-fingerprint-input" type="text" class="form-control" placeholder="PGP key fingerprint" name="key" required> <input id="key-import-fingerprint-input" type="text" class="form-control" placeholder="PGP key fingerprint" name="key" required>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label for="key-import-server-input" class="col-sm-2 col-form-label">key server</label> <label for="key-import-server-input" class="col-2 col-form-label">key server</label>
<div class="col-sm-10"> <div class="col-10">
<input id="key-import-server-input" type="text" class="form-control" placeholder="PGP key server" name="server" value="keyserver.ubuntu.com" required> <input id="key-import-server-input" type="text" class="form-control" placeholder="PGP key server" name="server" value="keyserver.ubuntu.com" required>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<div class="col-sm-2"></div> <div class="col-2"></div>
<div class="col-sm-10"> <div class="col-10">
<pre class="language-less"><samp id="key-import-body-input" class="pre-scrollable language-less"></samp><button id="key-import-copy-button" type="button" class="btn language-less" onclick="copyPgpKey()"><i class="bi bi-clipboard"></i> copy</button></pre> <pre class="language-less"><samp id="key-import-body-input" class="pre-scrollable language-less"></samp><button id="key-import-copy-button" type="button" class="btn language-less" onclick="copyPgpKey()"><i class="bi bi-clipboard"></i> copy</button></pre>
</div> </div>
</div> </div>

View File

@ -8,14 +8,14 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label for="login-username" class="col-sm-4 col-form-label">username</label> <label for="login-username" class="col-4 col-form-label">username</label>
<div class="col-sm-8"> <div class="col-8">
<input id="login-username" type="text" class="form-control" placeholder="enter username" name="username" required> <input id="login-username" type="text" class="form-control" placeholder="enter username" name="username" required>
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label for="login-password" class="col-sm-4 col-form-label">password</label> <label for="login-password" class="col-4 col-form-label">password</label>
<div class="col-sm-8"> <div class="col-8">
<div class="input-group"> <div class="input-group">
<input id="login-password" type="password" class="form-control" placeholder="enter password" name="password" required> <input id="login-password" type="password" class="form-control" placeholder="enter password" name="password" required>
<div class="input-group-append"> <div class="input-group-append">

View File

@ -1,5 +1,5 @@
<div id="package-add-modal" tabindex="-1" role="dialog" class="modal fade"> <div id="package-add-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document"> <div class="modal-dialog modal-lg" role="document">
<div class="modal-content"> <div class="modal-content">
<form id="package-add-form" onsubmit="return false"> <form id="package-add-form" onsubmit="return false">
<div class="modal-header"> <div class="modal-header">
@ -8,9 +8,9 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label for="package-add-repository-input" class="col-sm-4 col-form-label">repository</label> <label for="package-add-repository-input" class="col-3 col-form-label">repository</label>
<div class="col-sm-8"> <div class="col-9">
<select id="package-add-repository-input" class="form-control" name="repository" required> <select id="package-add-repository-input" class="form-control" required>
{% for repository in repositories %} {% for repository in repositories %}
<option value="{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</option> <option value="{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</option>
{% endfor %} {% endfor %}
@ -18,12 +18,18 @@
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label for="package-add-input" class="col-sm-4 col-form-label">package</label> <label for="package-add-input" class="col-3 col-form-label">package</label>
<div class="col-sm-8"> <div class="col-9">
<input id="package-add-input" type="text" list="known-packages-dlist" autocomplete="off" class="form-control" placeholder="AUR package" name="package" required> <input id="package-add-input" type="text" list="package-add-known-packages-dlist" autocomplete="off" class="form-control" placeholder="AUR package" required>
<datalist id="package-add-known-packages-dlist"></datalist> <datalist id="package-add-known-packages-dlist"></datalist>
</div> </div>
</div> </div>
<div class="form-group row">
<div class="col-12">
<button id="package-add-variable-button" type="button" class="form-control btn btn-light rounded" onclick="packageAddVariableInputCreate()"><i class="bi bi-plus"></i> add environment variable </button>
</div>
</div>
<div id="package-add-variables-div" class="form-group row"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="submit" class="btn btn-primary" onclick="packagesAdd()"><i class="bi bi-play"></i> add</button> <button type="submit" class="btn btn-primary" onclick="packagesAdd()"><i class="bi bi-play"></i> add</button>
@ -39,9 +45,11 @@
const packageAddForm = $("#package-add-form"); const packageAddForm = $("#package-add-form");
packageAddModal.on("shown.bs.modal", () => { packageAddModal.on("shown.bs.modal", () => {
$(`#package-add-repository-input option[value="${repository.architecture}-${repository.repository}"]`).prop("selected", true); $(`#package-add-repository-input option[value="${repository.architecture}-${repository.repository}"]`).prop("selected", true);
}); });
packageAddModal.on("hidden.bs.modal", () => { packageAddForm.trigger("reset"); }); packageAddModal.on("hidden.bs.modal", () => {
packageAddVariablesDiv.empty();
packageAddForm.trigger("reset");
});
const packageAddInput = $("#package-add-input"); const packageAddInput = $("#package-add-input");
const packageAddRepositoryInput = $("#package-add-repository-input"); const packageAddRepositoryInput = $("#package-add-repository-input");
@ -71,25 +79,81 @@
}, this), 500)); }, this), 500));
}); });
function packagesAdd() { const packageAddVariablesDiv = $("#package-add-variables-div");
const packages = packageAddInput.val();
function packageAddVariableInputCreate() {
const variableInput = document.createElement("div");
variableInput.classList.add("input-group");
variableInput.classList.add("package-add-variable");
const variableNameInput = document.createElement("input");
variableNameInput.type = "text";
variableNameInput.classList.add("form-control");
variableNameInput.classList.add("package-add-variable-name");
variableNameInput.placeholder = "name";
variableNameInput.ariaLabel = "variable name";
const variableSeparator = document.createElement("span");
variableSeparator.classList.add("input-group-text")
variableSeparator.textContent = "=";
const variableValueInput = document.createElement("input");
variableValueInput.type = "text";
variableValueInput.classList.add("form-control");
variableValueInput.classList.add("package-add-variable-value");
variableValueInput.placeholder = "value";
variableValueInput.ariaLabel = "variable value";
const variableButtonRemove = document.createElement("button");
variableButtonRemove.type = "button";
variableButtonRemove.classList.add("btn");
variableButtonRemove.classList.add("btn-outline-danger");
variableButtonRemove.innerHTML = "<i class=\"bi bi-trash\"></i>";
variableButtonRemove.onclick = _ => { return variableInput.remove(); };
// bring them together
variableInput.appendChild(variableNameInput);
variableInput.appendChild(variableSeparator);
variableInput.appendChild(variableValueInput);
variableInput.appendChild(variableButtonRemove);
packageAddVariablesDiv.append(variableInput);
}
function patchesParse() {
const patches = packageAddVariablesDiv.find(".package-add-variable").map((_, element) => {
const richElement = $(element);
return {
key: richElement.find(".package-add-variable-name").val(),
value: richElement.find(".package-add-variable-value").val(),
};
}).filter((_, patch) => patch.key).get();
return {patches: patches};
}
function packagesAdd(packages, patches) {
packages = packages ?? packageAddInput.val();
patches = patches ?? patchesParse();
const repository = getRepositorySelector(packageAddRepositoryInput); const repository = getRepositorySelector(packageAddRepositoryInput);
if (packages) { if (packages) {
packageAddModal.modal("hide"); packageAddModal.modal("hide");
const onSuccess = update => `Packages ${update} have been added`; const onSuccess = update => `Packages ${update} have been added`;
const onFailure = error => `Package addition failed: ${error}`; const onFailure = error => `Package addition failed: ${error}`;
doPackageAction("/api/v1/service/add", [packages], repository, onSuccess, onFailure); doPackageAction("/api/v1/service/add", [packages], repository, onSuccess, onFailure, patches);
} }
} }
function packagesRequest() { function packagesRequest(packages, patches) {
const packages = packageAddInput.val(); packages = packages ?? packageAddInput.val();
patches = patches ?? patchesParse();
const repository = getRepositorySelector(packageAddRepositoryInput); const repository = getRepositorySelector(packageAddRepositoryInput);
if (packages) { if (packages) {
packageAddModal.modal("hide"); packageAddModal.modal("hide");
const onSuccess = update => `Packages ${update} have been requested`; const onSuccess = update => `Packages ${update} have been requested`;
const onFailure = error => `Package request failed: ${error}`; const onFailure = error => `Package request failed: ${error}`;
doPackageAction("/api/v1/service/request", [packages], repository, onSuccess, onFailure); doPackageAction("/api/v1/service/request", [packages], repository, onSuccess, onFailure, patches);
} }
} }
</script> </script>

View File

@ -6,10 +6,47 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group row">
<div class="input-group">
<table class="table table-borderless">
<tbody>
<tr>
<td style="width: 10%; text-align: right">version</td>
<td id="package-info-version" style="width: 40%"></td>
<td style="width: 10%; text-align: right">packager</td>
<td id="package-info-packager" style="width: 40%"></td>
</tr>
<tr>
<td style="width: 10%; text-align: right">groups</td>
<td id="package-info-groups" style="width: 40%"></td>
<td style="width: 10%; text-align: right">licenses</td>
<td id="package-info-licenses" style="width: 40%"></td>
</tr>
<tr>
<td style="width: 10%; text-align: right">packages</td>
<td id="package-info-packages" style="width: 40%"></td>
<td style="width: 10%; text-align: right">depends</td>
<td id="package-info-depends" style="width: 40%"></td>
</tr>
</tbody>
</table>
</div>
</div>
<hr class="col-12">
<h3>Environment variables</h3>
<div id="package-info-variables-div" class="form-group row"></div>
<hr class="col-12">
<h3>Build logs</h3>
<pre class="language-logs"><samp id="package-info-logs-input" class="pre-scrollable language-logs"></samp><button id="package-info-logs-copy-button" type="button" class="btn language-logs" onclick="copyLogs()"><i class="bi bi-clipboard"></i> copy</button></pre> <pre class="language-logs"><samp id="package-info-logs-input" class="pre-scrollable language-logs"></samp><button id="package-info-logs-copy-button" type="button" class="btn language-logs" onclick="copyLogs()"><i class="bi bi-clipboard"></i> copy</button></pre>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="showLogs()"><i class="bi bi-arrow-clockwise"></i> reload</button> <button type="submit" class="btn btn-success" onclick="packageInfoUpdate()" data-bs-dismiss="modal"><i class="bi bi-play"></i> update</button>
<button type="submit" class="btn btn-danger" onclick="packageInfoRemove()" data-bs-dismiss="modal"><i class="bi bi-trash"></i> remove</button>
<button type="button" class="btn btn-secondary" onclick="showPackageInfo()"><i class="bi bi-arrow-clockwise"></i> reload</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i> close</button> <button type="button" class="btn btn-primary" data-bs-dismiss="modal"><i class="bi bi-x"></i> close</button>
</div> </div>
</div> </div>
@ -20,30 +57,78 @@
const packageInfoModal = $("#package-info-modal"); const packageInfoModal = $("#package-info-modal");
const packageInfoModalHeader = $("#package-info-modal-header"); const packageInfoModalHeader = $("#package-info-modal-header");
const packageInfo = $("#package-info"); const packageInfo = $("#package-info");
packageInfoModal.on("hidden.bs.modal", () => {
packageInfoDepends.empty();
packageInfoGroups.empty();
packageInfoLicenses.empty();
packageInfoPackager.empty();
packageInfoPackages.empty();
packageInfoVersion.empty();
packageInfoVariablesDiv.empty();
packageInfoModal.trigger("reset");
});
const packageInfoLogsInput = $("#package-info-logs-input"); const packageInfoLogsInput = $("#package-info-logs-input");
const packageInfoLogsCopyButton = $("#package-info-logs-copy-button"); const packageInfoLogsCopyButton = $("#package-info-logs-copy-button");
const packageInfoDepends = $("#package-info-depends");
const packageInfoGroups = $("#package-info-groups");
const packageInfoLicenses = $("#package-info-licenses");
const packageInfoPackager = $("#package-info-packager");
const packageInfoPackages = $("#package-info-packages");
const packageInfoVersion = $("#package-info-version");
const packageInfoVariablesDiv = $("#package-info-variables-div");
async function copyLogs() { async function copyLogs() {
const logs = packageInfoLogsInput.text(); const logs = packageInfoLogsInput.text();
await copyToClipboard(logs, packageInfoLogsCopyButton); await copyToClipboard(logs, packageInfoLogsCopyButton);
} }
function showLogs(packageBase) { function insertVariable(packageBase, variable) {
const isPackageBaseSet = packageBase !== undefined; const variableInput = document.createElement("div");
if (isPackageBaseSet) variableInput.classList.add("input-group");
packageInfoModal.data("package", packageBase); // set package base as currently used
else
packageBase = packageInfoModal.data("package"); // read package base from the current window attribute
const headerClass = status => { const variableNameInput = document.createElement("input");
if (status === "pending") return ["bg-warning"]; variableNameInput.classList.add("form-control");
if (status === "building") return ["bg-warning"]; variableNameInput.readOnly = true;
if (status === "failed") return ["bg-danger", "text-white"]; variableNameInput.value = variable.key;
if (status === "success") return ["bg-success", "text-white"];
return ["bg-secondary", "text-white"]; const variableSeparator = document.createElement("span");
variableSeparator.classList.add("input-group-text")
variableSeparator.textContent = "=";
const variableValueInput = document.createElement("input");
variableValueInput.classList.add("form-control");
variableValueInput.readOnly = true;
variableValueInput.value = variable.value;
const variableButtonRemove = document.createElement("button");
variableButtonRemove.type = "button";
variableButtonRemove.classList.add("btn");
variableButtonRemove.classList.add("btn-outline-danger");
variableButtonRemove.innerHTML = "<i class=\"bi bi-trash\"></i>";
variableButtonRemove.onclick = _ => {
$.ajax({
url: `/api/v1/packages/${packageBase}/patches/${variable.key}`,
type: "DELETE",
dataType: "json",
success: _ => variableInput.remove(),
});
}; };
// bring them together
variableInput.appendChild(variableNameInput);
variableInput.appendChild(variableSeparator);
variableInput.appendChild(variableValueInput);
variableInput.appendChild(variableButtonRemove);
packageInfoVariablesDiv.append(variableInput);
}
function loadLogs(packageBase, onFailure) {
$.ajax({ $.ajax({
url: `/api/v2/packages/${packageBase}/logs`, url: `/api/v2/packages/${packageBase}/logs`,
data: { data: {
@ -53,26 +138,101 @@
type: "GET", type: "GET",
dataType: "json", dataType: "json",
success: response => { success: response => {
packageInfo.text(`${response.package_base} ${response.status.status} at ${new Date(1000 * response.status.timestamp).toISOString()}`); const logs = response.map(log_record => {
const logs = response.logs.map(log_record => { return `[${new Date(1000 * log_record.created).toISOString()}] ${log_record.message}`;
const [timestamp, record] = log_record;
return `[${new Date(1000 * timestamp).toISOString()}] ${record}`;
}); });
packageInfoLogsInput.text(logs.join("\n")); packageInfoLogsInput.text(logs.join("\n"));
},
error: onFailure,
});
}
function loadPackage(packageBase, onFailure) {
const headerClass = status => {
if (status === "pending") return ["bg-warning"];
if (status === "building") return ["bg-warning"];
if (status === "failed") return ["bg-danger", "text-white"];
if (status === "success") return ["bg-success", "text-white"];
return ["bg-secondary", "text-white"];
};
$.ajax({
url: `/api/v1/packages/${packageBase}`,
data: {
architecture: repository.architecture,
repository: repository.repository,
},
type: "GET",
dataType: "json",
success: response => {
const description = response.find(Boolean);
const packages = Object.keys(description.package.packages);
packageInfo.text(`${description.package.base} ${description.status.status} at ${new Date(1000 * description.status.timestamp).toISOStringShort()}`);
packageInfoModalHeader.removeClass(); packageInfoModalHeader.removeClass();
packageInfoModalHeader.addClass("modal-header"); packageInfoModalHeader.addClass("modal-header");
headerClass(response.status.status).forEach((clz) => packageInfoModalHeader.addClass(clz)); headerClass(description.status.status).forEach(clz => packageInfoModalHeader.addClass(clz));
if (isPackageBaseSet) packageInfoModal.modal("show"); // we don't need to show window again packageInfoDepends.html(listToTable(
}, Object.values(description.package.packages)
error: (jqXHR, _, errorThrown) => { .reduce((accumulator, currentValue) => {
// show failed modal in case if first time loading return accumulator.concat(currentValue.depends.filter(v => packages.indexOf(v) === -1))
if (isPackageBaseSet) { .concat(currentValue.make_depends.filter(v => packages.indexOf(v) === -1).map(v => `${v} (make)`))
const message = error => `Could not load package ${packageBase} logs: ${error}`; .concat(currentValue.opt_depends.filter(v => packages.indexOf(v) === -1).map(v => `${v} (optional)`));
showFailure("Load failure", message, jqXHR, errorThrown); }, [])
} ));
packageInfoGroups.html(listToTable(extractListProperties(description.package, "groups")));
packageInfoLicenses.html(listToTable(extractListProperties(description.package, "licenses")));
packageInfoPackager.text(description.package.packager);
packageInfoPackages.html(listToTable(packages));
packageInfoVersion.text(description.package.version);
}, },
error: onFailure,
}); });
} }
function loadPatches(packageBase, onFailure) {
$.ajax({
url: `/api/v1/packages/${packageBase}/patches`,
type: "GET",
dataType: "json",
success: response => {
packageInfoVariablesDiv.empty();
response.map(patch => insertVariable(packageBase, patch));
},
error: onFailure,
});
}
function packageInfoRemove() {
const packageBase = packageInfoModal.data("package");
if (packageBase) return packagesRemove([packageBase]);
}
function packageInfoUpdate() {
const packageBase = packageInfoModal.data("package");
if (packageBase) return packagesAdd(packageBase, []);
}
function showPackageInfo(packageBase) {
const isPackageBaseSet = packageBase !== undefined;
if (isPackageBaseSet)
packageInfoModal.data("package", packageBase); // set package base as currently used
else
packageBase = packageInfoModal.data("package"); // read package base from the current window attribute
const onFailure = (jqXHR, _, errorThrown) => {
if (isPackageBaseSet) {
const message = error => `Could not load package ${packageBase} info: ${error}`;
showFailure("Load failure", message, jqXHR, errorThrown);
}
};
loadPackage(packageBase, onFailure);
loadPatches(packageBase, onFailure);
loadLogs(packageBase, onFailure);
if (isPackageBaseSet) packageInfoModal.modal("show");
}
</script> </script>

View File

@ -1,5 +1,5 @@
<div id="package-rebuild-modal" tabindex="-1" role="dialog" class="modal fade"> <div id="package-rebuild-modal" tabindex="-1" role="dialog" class="modal fade">
<div class="modal-dialog" role="document"> <div class="modal-dialog modal-lg" role="document">
<div class="modal-content"> <div class="modal-content">
<form id="package-rebuild-form" onsubmit="return false"> <form id="package-rebuild-form" onsubmit="return false">
<div class="modal-header"> <div class="modal-header">
@ -8,8 +8,8 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="form-group row"> <div class="form-group row">
<label for="package-rebuild-repository-input" class="col-sm-4 col-form-label">repository</label> <label for="package-rebuild-repository-input" class="col-3 col-form-label">repository</label>
<div class="col-sm-8"> <div class="col-9">
<select id="package-rebuild-repository-input" class="form-control" name="repository" required> <select id="package-rebuild-repository-input" class="form-control" name="repository" required>
{% for repository in repositories %} {% for repository in repositories %}
<option value="{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</option> <option value="{{ repository.id }}" data-repository="{{ repository.repository }}" data-architecture="{{ repository.architecture }}">{{ repository.repository }} ({{ repository.architecture }})</option>
@ -18,8 +18,8 @@
</div> </div>
</div> </div>
<div class="form-group row"> <div class="form-group row">
<label for="package-rebuild-dependency-input" class="col-sm-4 col-form-label">dependency</label> <label for="package-rebuild-dependency-input" class="col-3 col-form-label">dependency</label>
<div class="col-sm-8"> <div class="col-9">
<input id="package-rebuild-dependency-input" type="text" class="form-control" placeholder="packages dependency" name="package" required> <input id="package-rebuild-dependency-input" type="text" class="form-control" placeholder="packages dependency" name="package" required>
</div> </div>
</div> </div>

View File

@ -25,7 +25,7 @@
if (0 === cell || "base" === cell) { if (0 === cell || "base" === cell) {
const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript const method = data[0] === true ? "uncheckBy" : "checkBy"; // fck javascript
table.bootstrapTable(method, {field: "id", values: [data.id]}); table.bootstrapTable(method, {field: "id", values: [data.id]});
} else showLogs(data.id); } else showPackageInfo(data.id);
}); });
table.on("created-controls.bs.table", () => { table.on("created-controls.bs.table", () => {
const pickerInput = $(".bootstrap-table-filter-control-timestamp"); const pickerInput = $(".bootstrap-table-filter-control-timestamp");
@ -50,7 +50,7 @@
const statusBadge = $("#badge-status"); const statusBadge = $("#badge-status");
const versionBadge = $("#badge-version"); const versionBadge = $("#badge-version");
function doPackageAction(uri, packages, repository, successText, failureText) { function doPackageAction(uri, packages, repository, successText, failureText, data) {
const queryParams = $.param({ const queryParams = $.param({
architecture: repository.architecture, architecture: repository.architecture,
repository: repository.repository, repository: repository.repository,
@ -58,7 +58,7 @@
$.ajax({ $.ajax({
url: `${uri}?${queryParams}`, url: `${uri}?${queryParams}`,
data: JSON.stringify({packages: packages}), data: JSON.stringify(Object.assign({}, {packages: packages}, data || {})),
type: "POST", type: "POST",
contentType: "application/json", contentType: "application/json",
success: _ => { success: _ => {
@ -71,31 +71,30 @@
}); });
} }
function filterListGroups() {
return extractDataList(table.bootstrapTable("getData"), "groups");
}
function filterListLicenses() {
return extractDataList(table.bootstrapTable("getData"), "licenses");
}
function filterListPackagers() {
return extractDataList(table.bootstrapTable("getData"), "packager");
}
function getRepositorySelector(selector) {
const selected = selector.find(":selected");
return {
architecture: selected.data("architecture"),
repository: selected.data("repository"),
};
}
function getSelection() { function getSelection() {
return table.bootstrapTable("getSelections").map(row => row.id); return table.bootstrapTable("getSelections").map(row => row.id);
} }
function removePackages() {
const onSuccess = update => `Packages ${update} have been removed`;
const onFailure = error => `Could not remove packages: ${error}`;
doPackageAction("/api/v1/service/remove", getSelection(), repository, onSuccess, onFailure);
}
function selectRepository() {
const fragment = window.location.hash.replace("#", "") || "{{ repositories[0].id }}";
const element = $(`#${fragment}-lnk`);
element.click();
}
function updatePackages() {
const currentSelection = getSelection();
const [url, onSuccess] = currentSelection.length === 0
? ["/api/v1/service/update", _ => `Repository update has been run`]
: ["/api/v1/service/add", update => `Run update for packages ${update}`];
const onFailure = error => `Packages update failed: ${error}`;
doPackageAction(url, currentSelection, repository, onSuccess, onFailure);
}
function hideControls(hidden) { function hideControls(hidden) {
keyImportButton.attr("hidden", hidden); keyImportButton.attr("hidden", hidden);
packageAddButton.attr("hidden", hidden); packageAddButton.attr("hidden", hidden);
@ -104,6 +103,24 @@
packageUpdateButton.attr("hidden", hidden); packageUpdateButton.attr("hidden", hidden);
} }
function packagesRemove(packages) {
packages = packages ?? getSelection();
const onSuccess = update => `Packages ${update} have been removed`;
const onFailure = error => `Could not remove packages: ${error}`;
doPackageAction("/api/v1/service/remove", packages, repository, onSuccess, onFailure);
}
function packagesUpdate() {
const currentSelection = getSelection();
const [url, onSuccess] = currentSelection.length === 0
? ["/api/v1/service/update", _ => `Repository update has been run`]
: ["/api/v1/service/add", update => `Run update for packages ${update}`];
const onFailure = error => `Packages update failed: ${error}`;
doPackageAction(url, currentSelection, repository, onSuccess, onFailure);
}
function reload() { function reload() {
table.bootstrapTable("showLoading"); table.bootstrapTable("showLoading");
@ -124,18 +141,6 @@
type: "GET", type: "GET",
dataType: "json", dataType: "json",
success: response => { success: response => {
const extractListProperties = (description, property) => {
return Object.values(description.packages)
.map(pkg => pkg[property])
.reduce((left, right) => left.concat(right), []);
};
const listToTable = data => {
return Array.from(new Set(data))
.sort()
.map(entry => safe(entry))
.join("<br>");
};
const payload = response.map(description => { const payload = response.map(description => {
const package_base = description.package.base; const package_base = description.package.base;
const web_url = description.package.remote.web_url; const web_url = description.package.remote.web_url;
@ -147,7 +152,7 @@
packages: listToTable(Object.keys(description.package.packages)), packages: listToTable(Object.keys(description.package.packages)),
groups: listToTable(extractListProperties(description.package, "groups")), groups: listToTable(extractListProperties(description.package, "groups")),
licenses: listToTable(extractListProperties(description.package, "licenses")), licenses: listToTable(extractListProperties(description.package, "licenses")),
timestamp: new Date(1000 * description.status.timestamp).toISOString(), timestamp: new Date(1000 * description.status.timestamp).toISOStringShort(),
status: description.status.status, status: description.status.status,
}; };
}); });
@ -186,7 +191,7 @@
statusBadge statusBadge
.popover("dispose") .popover("dispose")
.attr("data-bs-content", `${response.status.status} at ${new Date(1000 * response.status.timestamp).toISOString()}`) .attr("data-bs-content", `${response.status.status} at ${new Date(1000 * response.status.timestamp).toISOStringShort()}`)
.popover(); .popover();
statusBadge.removeClass(); statusBadge.removeClass();
statusBadge.addClass("btn"); statusBadge.addClass("btn");
@ -195,6 +200,12 @@
}); });
} }
function selectRepository() {
const fragment = window.location.hash.replace("#", "") || "{{ repositories[0].id }}";
const element = $(`#${fragment}-lnk`);
element.click();
}
function statusFormat(value) { function statusFormat(value) {
const cellClass = status => { const cellClass = status => {
if (status === "pending") return "table-warning"; if (status === "pending") return "table-warning";
@ -206,26 +217,6 @@
return {classes: cellClass(value)}; return {classes: cellClass(value)};
} }
function filterListGroups() {
return extractDataList(table.bootstrapTable("getData"), "groups");
}
function filterListLicenses() {
return extractDataList(table.bootstrapTable("getData"), "licenses");
}
function filterListPackagers() {
return extractDataList(table.bootstrapTable("getData"), "packager");
}
function getRepositorySelector(selector) {
const selected = selector.find(":selected");
return {
architecture: selected.data("architecture"),
repository: selected.data("repository"),
};
}
$(() => { $(() => {
table.bootstrapTable({}); table.bootstrapTable({});
statusBadge.popover(); statusBadge.popover();

View File

@ -30,7 +30,8 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
</div> </div>
<div class="container"> <div class="container">
<table id="packages" class="table table-striped table-hover" <table id="packages"
data-classes="table table-hover"
data-export-options='{"fileName": "packages"}' data-export-options='{"fileName": "packages"}'
data-filter-control="true" data-filter-control="true"
data-filter-control-visible="false" data-filter-control-visible="false"
@ -53,16 +54,16 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
<thead class="table-primary"> <thead class="table-primary">
<tr> <tr>
<th data-sortable="true" data-switchable="false" data-field="name" data-filter-control="input" data-filter-control-placeholder="(any package)">package</th> <th data-sortable="true" data-switchable="false" data-field="name" data-filter-control="input" data-filter-control-placeholder="(any package)">package</th>
<th data-sortable="true" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th> <th data-sortable="true" data-align="right" data-field="version" data-filter-control="input" data-filter-control-placeholder="(any version)">version</th>
<th data-sortable="true" data-visible="false" data-field="architecture" data-filter-control="select" data-filter-control-placeholder="(any arch)">architecture</th> <th data-sortable="true" data-visible="false" data-field="architecture" data-filter-control="select" data-filter-control-placeholder="(any arch)">architecture</th>
<th data-sortable="true" data-visible="false" data-field="description" data-filter-control="input" data-filter-control-placeholder="(any description)">description</th> <th data-sortable="true" data-visible="false" data-field="description" data-filter-control="input" data-filter-control-placeholder="(any description)">description</th>
<th data-sortable="true" data-visible="false" data-field="url">upstream url</th> <th data-sortable="true" data-visible="false" data-field="url">upstream url</th>
<th data-sortable="true" data-visible="false" data-field="licenses" data-filter-control="select" data-filter-data="func:filterListLicenses" data-filter-custom-search="filterList" data-filter-control-placeholder="(any license)">licenses</th> <th data-sortable="true" data-visible="false" data-field="licenses" data-filter-control="select" data-filter-data="func:filterListLicenses" data-filter-custom-search="filterList" data-filter-control-placeholder="(any license)">licenses</th>
<th data-sortable="true" data-visible="false" data-field="groups" data-filter-control="select" data-filter-data="func:filterListGroups" data-filter-custom-search="filterList" data-filter-control-placeholder="(any group)">groups</th> <th data-sortable="true" data-visible="false" data-field="groups" data-filter-control="select" data-filter-data="func:filterListGroups" data-filter-custom-search="filterList" data-filter-control-placeholder="(any group)">groups</th>
<th data-sortable="true" data-visible="false" data-field="depends" data-filter-control="select" data-filter-data="func:filterListDepends" data-filter-custom-search="filterList" data-filter-control-placeholder="(any depends)">depends</th> <th data-sortable="true" data-visible="false" data-field="depends" data-filter-control="select" data-filter-data="func:filterListDepends" data-filter-custom-search="filterList" data-filter-control-placeholder="(any depends)">depends</th>
<th data-sortable="true" data-field="archive_size">archive size</th> <th data-sortable="true" data-align="right" data-field="archive_size">archive size</th>
<th data-sortable="true" data-field="installed_size">installed size</th> <th data-sortable="true" data-align="right" data-field="installed_size">installed size</th>
<th data-sortable="true" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">build date</th> <th data-sortable="true" data-align="right" data-field="timestamp" data-filter-control="input" data-filter-custom-search="filterDateRange" data-filter-control-placeholder="(any date)">build date</th>
</tr> </tr>
</thead> </thead>

View File

@ -36,19 +36,17 @@
}, 2000); }, 2000);
} }
function safe(string) {
return String(string)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function extractDataList(data, column) { function extractDataList(data, column) {
const elements = data.flatMap(row => row[column].split("<br>")).filter(v => v); // remove empty elements from array const elements = data.flatMap(row => row[column].split("<br>")).filter(v => v); // remove empty elements from array
return Array.from(new Set(elements)).sort(); return Array.from(new Set(elements)).sort();
} }
function extractListProperties(description, property) {
return Object.values(description.packages)
.map(pkg => pkg[property])
.reduce((left, right) => left.concat(right), []);
}
function filterContains(text, value) { function filterContains(text, value) {
return value.includes(text.toLowerCase().trim()); return value.includes(text.toLowerCase().trim());
} }
@ -67,4 +65,24 @@
// the library removes all symbols from string, so it is just string // the library removes all symbols from string, so it is just string
return value.includes(dataList[index].toLowerCase()); return value.includes(dataList[index].toLowerCase());
} }
function listToTable(data) {
return Array.from(new Set(data))
.sort()
.map(entry => safe(entry))
.join("<br>");
}
function safe(string) {
return String(string)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
Date.prototype.toISOStringShort = function() {
const pad = number => String(number).padStart(2, "0");
return `${this.getFullYear()}-${pad(this.getMonth())}-${pad(this.getDate())} ${pad(this.getHours())}:${pad(this.getMinutes())}:${pad(this.getSeconds())}`;
}
</script> </script>

View File

@ -23,6 +23,7 @@ from ahriman.core.log import LazyLogging
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
@ -162,6 +163,40 @@ class Watcher(LazyLogging):
self.known[package_base] = (package, full_status) self.known[package_base] = (package, full_status)
self.database.package_update(package, full_status, self.repository_id) self.database.package_update(package, full_status, self.repository_id)
def patches_get(self, package_base: str, variable: str | None) -> list[PkgbuildPatch]:
"""
get patches for the package
Args:
package_base(str): package base
variable(str | None): patch variable name if any
Returns:
list[PkgbuildPatch]: list of patches which are stored for the package
"""
variables = [variable] if variable is not None else None
return self.database.patches_list(package_base, variables).get(package_base, [])
def patches_remove(self, package_base: str, variable: str) -> None:
"""
remove package patch
Args:
package_base(str): package base
variable(str): patch variable name
"""
self.database.patches_remove(package_base, [variable])
def patches_update(self, package_base: str, patch: PkgbuildPatch) -> None:
"""
update package patch
Args:
package_base(str): package base
patch(PkgbuildPatch): package patch
"""
self.database.patches_insert(package_base, [patch])
def status_update(self, status: BuildStatusEnum) -> None: def status_update(self, status: BuildStatusEnum) -> None:
""" """
update service status update service status

View File

@ -21,9 +21,9 @@ import shlex
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Self from typing import Any, Self
from ahriman.core.util import unquote from ahriman.core.util import dataclass_view, unquote
@dataclass(frozen=True) @dataclass(frozen=True)
@ -109,6 +109,15 @@ class PkgbuildPatch:
return f"{self.key} {self.value}" # no quoting enabled here return f"{self.key} {self.value}" # no quoting enabled here
return f"""{self.key}={PkgbuildPatch.quote(self.value)}""" return f"""{self.key}={PkgbuildPatch.quote(self.value)}"""
def view(self) -> dict[str, Any]:
"""
generate json patch view
Returns:
dict[str, Any]: json-friendly dictionary
"""
return dataclass_view(self)
def write(self, pkgbuild_path: Path) -> None: def write(self, pkgbuild_path: Path) -> None:
""" """
write serialized value into PKGBUILD by specified path write serialized value into PKGBUILD by specified path

View File

@ -25,15 +25,17 @@ from ahriman.web.schemas.file_schema import FileSchema
from ahriman.web.schemas.internal_status_schema import InternalStatusSchema from ahriman.web.schemas.internal_status_schema import InternalStatusSchema
from ahriman.web.schemas.log_schema import LogSchema from ahriman.web.schemas.log_schema import LogSchema
from ahriman.web.schemas.login_schema import LoginSchema from ahriman.web.schemas.login_schema import LoginSchema
from ahriman.web.schemas.logs_schema import LogsSchema, LogsSchemaV2 from ahriman.web.schemas.logs_schema import LogsSchema
from ahriman.web.schemas.oauth2_schema import OAuth2Schema from ahriman.web.schemas.oauth2_schema import OAuth2Schema
from ahriman.web.schemas.package_name_schema import PackageNameSchema from ahriman.web.schemas.package_name_schema import PackageNameSchema
from ahriman.web.schemas.package_names_schema import PackageNamesSchema from ahriman.web.schemas.package_names_schema import PackageNamesSchema
from ahriman.web.schemas.package_patch_schema import PackagePatchSchema
from ahriman.web.schemas.package_properties_schema import PackagePropertiesSchema from ahriman.web.schemas.package_properties_schema import PackagePropertiesSchema
from ahriman.web.schemas.package_schema import PackageSchema from ahriman.web.schemas.package_schema import PackageSchema
from ahriman.web.schemas.package_status_schema import PackageStatusSimplifiedSchema, PackageStatusSchema from ahriman.web.schemas.package_status_schema import PackageStatusSimplifiedSchema, PackageStatusSchema
from ahriman.web.schemas.pagination_schema import PaginationSchema from ahriman.web.schemas.pagination_schema import PaginationSchema
from ahriman.web.schemas.patch_schema import PackagePatchSchema, PatchSchema from ahriman.web.schemas.patch_name_schema import PatchNameSchema
from ahriman.web.schemas.patch_schema import PatchSchema
from ahriman.web.schemas.pgp_key_id_schema import PGPKeyIdSchema from ahriman.web.schemas.pgp_key_id_schema import PGPKeyIdSchema
from ahriman.web.schemas.pgp_key_schema import PGPKeySchema from ahriman.web.schemas.pgp_key_schema import PGPKeySchema
from ahriman.web.schemas.process_id_schema import ProcessIdSchema from ahriman.web.schemas.process_id_schema import ProcessIdSchema
@ -42,4 +44,5 @@ from ahriman.web.schemas.remote_schema import RemoteSchema
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
from ahriman.web.schemas.search_schema import SearchSchema from ahriman.web.schemas.search_schema import SearchSchema
from ahriman.web.schemas.status_schema import StatusSchema from ahriman.web.schemas.status_schema import StatusSchema
from ahriman.web.schemas.versioned_log_schema import VersionedLogSchema
from ahriman.web.schemas.update_flags_schema import UpdateFlagsSchema from ahriman.web.schemas.update_flags_schema import UpdateFlagsSchema

View File

@ -17,13 +17,10 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
# #
from marshmallow import fields from marshmallow import Schema, fields
from ahriman import __version__
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
class LogSchema(RepositoryIdSchema): class LogSchema(Schema):
""" """
request package log schema request package log schema
""" """
@ -32,10 +29,6 @@ class LogSchema(RepositoryIdSchema):
"description": "Log record timestamp", "description": "Log record timestamp",
"example": 1680537091.233495, "example": 1680537091.233495,
}) })
version = fields.Integer(required=True, metadata={
"description": "Package version to tag",
"example": __version__,
})
message = fields.String(required=True, metadata={ message = fields.String(required=True, metadata={
"description": "Log message", "description": "Log message",
}) })

View File

@ -27,23 +27,9 @@ class LogsSchema(Schema):
response package logs schema response package logs schema
""" """
package_base = fields.String(required=True, metadata={
"description": "Package base name",
"example": "ahriman",
})
status = fields.Nested(StatusSchema(), required=True, metadata={
"description": "Last package status",
})
logs = fields.String(required=True, metadata={ logs = fields.String(required=True, metadata={
"description": "Full package log from the last build", "description": "Full package log from the last build",
}) })
class LogsSchemaV2(Schema):
"""
response package logs api v2 schema
"""
package_base = fields.String(required=True, metadata={ package_base = fields.String(required=True, metadata={
"description": "Package base name", "description": "Package base name",
"example": "ahriman", "example": "ahriman",
@ -51,7 +37,3 @@ class LogsSchemaV2(Schema):
status = fields.Nested(StatusSchema(), required=True, metadata={ status = fields.Nested(StatusSchema(), required=True, metadata={
"description": "Last package status", "description": "Last package status",
}) })
logs = fields.List(fields.Tuple([fields.Float(), fields.String()]), required=True, metadata={ # type: ignore[no-untyped-call]
"description": "Package log records timestamp and message",
"example": [(1680537091.233495, "log record")]
})

View File

@ -0,0 +1,33 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from marshmallow import fields
from ahriman.web.schemas.package_names_schema import PackageNamesSchema
from ahriman.web.schemas.patch_schema import PatchSchema
class PackagePatchSchema(PackageNamesSchema):
"""
response schema with packages and patches
"""
patches = fields.Nested(PatchSchema(many=True), metadata={
"description": "optional environment variables to be applied as patches"
})

View File

@ -0,0 +1,33 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from marshmallow import fields
from ahriman.web.schemas.package_name_schema import PackageNameSchema
class PatchNameSchema(PackageNameSchema):
"""
request package patch schema
"""
patch = fields.String(required=True, metadata={
"description": "Variable name",
"example": "PKGEXT",
})

View File

@ -19,12 +19,10 @@
# #
from marshmallow import Schema, fields from marshmallow import Schema, fields
from ahriman.web.schemas.package_names_schema import PackageNamesSchema
class PatchSchema(Schema): class PatchSchema(Schema):
""" """
request patch schema request and response patch schema
""" """
key = fields.String(required=True, metadata={ key = fields.String(required=True, metadata={
@ -33,13 +31,3 @@ class PatchSchema(Schema):
value = fields.String(metadata={ value = fields.String(metadata={
"description": "environment variable value", "description": "environment variable value",
}) })
class PackagePatchSchema(PackageNamesSchema):
"""
request schema with packages and patches
"""
patches = fields.Nested(PatchSchema(many=True), metadata={
"description": "optional environment variables to be applied as patches"
})

View File

@ -0,0 +1,35 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
from marshmallow import fields
from ahriman import __version__
from ahriman.web.schemas.log_schema import LogSchema
from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema
class VersionedLogSchema(LogSchema, RepositoryIdSchema):
"""
request package log schema
"""
version = fields.Integer(required=True, metadata={
"description": "Package version to tag",
"example": __version__,
})

View File

@ -25,7 +25,8 @@ from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.util import pretty_datetime from ahriman.core.util import pretty_datetime
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, LogSchema, LogsSchema, PackageNameSchema, RepositoryIdSchema from ahriman.web.schemas import AuthSchema, ErrorSchema, LogsSchema, PackageNameSchema, RepositoryIdSchema, \
VersionedLogSchema
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -128,7 +129,7 @@ class LogsView(BaseView):
) )
@aiohttp_apispec.cookies_schema(AuthSchema) @aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.match_info_schema(PackageNameSchema) @aiohttp_apispec.match_info_schema(PackageNameSchema)
@aiohttp_apispec.json_schema(LogSchema) @aiohttp_apispec.json_schema(VersionedLogSchema)
async def post(self) -> None: async def post(self) -> None:
""" """
create new package log record create new package log record

View File

@ -0,0 +1,103 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import aiohttp_apispec # type: ignore[import-untyped]
from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, PatchNameSchema, PatchSchema
from ahriman.web.views.base import BaseView
class PatchView(BaseView):
"""
package patch web view
Attributes:
DELETE_PERMISSION(UserAccess): (class attribute) delete permissions of self
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
"""
DELETE_PERMISSION = UserAccess.Full
GET_PERMISSION = UserAccess.Reporter
ROUTES = ["/api/v1/packages/{package}/patches/{patch}"]
@aiohttp_apispec.docs(
tags=["Packages"],
summary="Delete package patch",
description="Delete package patch by variable",
responses={
204: {"description": "Success response"},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
500: {"description": "Internal server error", "schema": ErrorSchema},
},
security=[{"token": [DELETE_PERMISSION]}],
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.match_info_schema(PatchNameSchema)
async def delete(self) -> None:
"""
delete package patch
Raises:
HTTPNoContent: on success response
"""
package_base = self.request.match_info["package"]
variable = self.request.match_info["patch"]
self.service().patches_remove(package_base, variable)
raise HTTPNoContent
@aiohttp_apispec.docs(
tags=["Packages"],
summary="Get package patch",
description="Retrieve package patch by variable",
responses={
200: {"description": "Success response", "schema": PatchSchema},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
404: {"description": "Patch name is unknown", "schema": ErrorSchema},
500: {"description": "Internal server error", "schema": ErrorSchema},
},
security=[{"token": [GET_PERMISSION]}],
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.match_info_schema(PatchNameSchema)
async def get(self) -> Response:
"""
get package patch
Returns:
Response: 200 with package patch on success
Raises:
HTTPNotFound: if package base is unknown
"""
package_base = self.request.match_info["package"]
variable = self.request.match_info["patch"]
patches = self.service().patches_get(package_base, variable)
selected = next((patch for patch in patches if patch.key == variable), None)
if selected is None:
raise HTTPNotFound
return json_response(selected.view())

View File

@ -0,0 +1,108 @@
#
# Copyright (c) 2021-2023 ahriman team.
#
# This file is part of ahriman
# (see https://github.com/arcan1s/ahriman).
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import aiohttp_apispec # type: ignore[import-untyped]
from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageNameSchema, PatchSchema
from ahriman.web.views.base import BaseView
class PatchesView(BaseView):
"""
package patches web view
Attributes:
GET_PERMISSION(UserAccess): (class attribute) get permissions of self
POST_PERMISSION(UserAccess): (class attribute) post permissions of self
"""
GET_PERMISSION = UserAccess.Reporter
POST_PERMISSION = UserAccess.Full
ROUTES = ["/api/v1/packages/{package}/patches"]
@aiohttp_apispec.docs(
tags=["Packages"],
summary="Get package patches",
description="Retrieve all package patches",
responses={
200: {"description": "Success response", "schema": PatchSchema(many=True)},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
500: {"description": "Internal server error", "schema": ErrorSchema},
},
security=[{"token": [GET_PERMISSION]}],
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.match_info_schema(PackageNameSchema)
async def get(self) -> Response:
"""
get package patches
Returns:
Response: 200 with package patches on success
Raises:
HTTPNotFound: if package base is unknown
"""
package_base = self.request.match_info["package"]
patches = self.service().patches_get(package_base, None)
response = [patch.view() for patch in patches]
return json_response(response)
@aiohttp_apispec.docs(
tags=["Packages"],
summary="Update package patch",
description="Update or create package patch",
responses={
204: {"description": "Success response"},
400: {"description": "Bad data is supplied", "schema": ErrorSchema},
401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema},
500: {"description": "Internal server error", "schema": ErrorSchema},
},
security=[{"token": [GET_PERMISSION]}],
)
@aiohttp_apispec.cookies_schema(AuthSchema)
@aiohttp_apispec.match_info_schema(PackageNameSchema)
@aiohttp_apispec.json_schema(PatchSchema)
async def post(self) -> None:
"""
update or create package patch
Raises:
HTTPBadRequest: if bad data is supplied
HTTPNoContent: on success response
"""
package_base = self.request.match_info["package"]
data = await self.extract_data()
try:
key = data["key"]
value = data["value"]
except Exception as ex:
raise HTTPBadRequest(reason=str(ex))
self.service().patches_update(package_base, PkgbuildPatch(key, value))
raise HTTPNoContent

View File

@ -19,11 +19,10 @@
# #
import aiohttp_apispec # type: ignore[import-untyped] import aiohttp_apispec # type: ignore[import-untyped]
from aiohttp.web import HTTPNotFound, Response, json_response from aiohttp.web import Response, json_response
from ahriman.core.exceptions import UnknownPackageError
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, LogsSchemaV2, PackageNameSchema, PaginationSchema from ahriman.web.schemas import AuthSchema, ErrorSchema, LogSchema, PackageNameSchema, PaginationSchema
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
@ -43,7 +42,7 @@ class LogsView(BaseView):
summary="Get paginated package logs", summary="Get paginated package logs",
description="Retrieve package logs and the last package status", description="Retrieve package logs and the last package status",
responses={ responses={
200: {"description": "Success response", "schema": LogsSchemaV2}, 200: {"description": "Success response", "schema": LogSchema(many=True)},
400: {"description": "Bad data is supplied", "schema": ErrorSchema}, 400: {"description": "Bad data is supplied", "schema": ErrorSchema},
401: {"description": "Authorization required", "schema": ErrorSchema}, 401: {"description": "Authorization required", "schema": ErrorSchema},
403: {"description": "Access is forbidden", "schema": ErrorSchema}, 403: {"description": "Access is forbidden", "schema": ErrorSchema},
@ -67,16 +66,12 @@ class LogsView(BaseView):
""" """
package_base = self.request.match_info["package"] package_base = self.request.match_info["package"]
limit, offset = self.page() limit, offset = self.page()
try:
_, status = self.service().package_get(package_base)
except UnknownPackageError:
raise HTTPNotFound(reason=f"Package {package_base} is unknown")
logs = self.service().logs_get(package_base, limit, offset) logs = self.service().logs_get(package_base, limit, offset)
response = { response = [
"package_base": package_base, {
"status": status.view(), "created": created,
"logs": logs, "message": message,
} } for created, message in logs
]
return json_response(response) return json_response(response)

View File

@ -1,12 +1,14 @@
import pytest import pytest
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import call as MockCall
from ahriman.core.exceptions import UnknownPackageError from ahriman.core.exceptions import UnknownPackageError
from ahriman.core.status.watcher import Watcher from ahriman.core.status.watcher import Watcher
from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.log_record_id import LogRecordId from ahriman.models.log_record_id import LogRecordId
from ahriman.models.package import Package from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
def test_load(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: def test_load(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
@ -163,6 +165,42 @@ def test_package_update_unknown(watcher: Watcher, package_ahriman: Package) -> N
watcher.package_update(package_ahriman.base, BuildStatusEnum.Unknown, None) watcher.package_update(package_ahriman.base, BuildStatusEnum.Unknown, None)
def test_patches_get(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must return patches for the package
"""
patches_mock = mocker.patch("ahriman.core.database.SQLite.patches_list")
watcher.patches_get(package_ahriman.base, None)
watcher.patches_get(package_ahriman.base, "var")
patches_mock.assert_has_calls([
MockCall(package_ahriman.base, None),
MockCall().get(package_ahriman.base, []),
MockCall(package_ahriman.base, ["var"]),
MockCall().get(package_ahriman.base, []),
])
def test_patches_remove(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must remove patches for the package
"""
patches_mock = mocker.patch("ahriman.core.database.SQLite.patches_remove")
watcher.patches_remove(package_ahriman.base, "var")
patches_mock.assert_called_once_with(package_ahriman.base, ["var"])
def test_patches_update(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None:
"""
must update patches for the package
"""
patch = PkgbuildPatch("key", "value")
patches_mock = mocker.patch("ahriman.core.database.SQLite.patches_insert")
watcher.patches_update(package_ahriman.base, patch)
patches_mock.assert_called_once_with(package_ahriman.base, [patch])
def test_status_update(watcher: Watcher) -> None: def test_status_update(watcher: Watcher) -> None:
""" """
must update service status must update service status

View File

@ -92,6 +92,13 @@ def test_serialize_list() -> None:
assert PkgbuildPatch("key", ["val'ue", "val\"ue2"]).serialize() == """key=('val'"'"'ue' 'val"ue2')""" assert PkgbuildPatch("key", ["val'ue", "val\"ue2"]).serialize() == """key=('val'"'"'ue' 'val"ue2')"""
def test_view() -> None:
"""
must correctly serialize to json
"""
assert PkgbuildPatch("key", "value").view() == {"key": "key", "value": "value"}
def test_write(mocker: MockerFixture) -> None: def test_write(mocker: MockerFixture) -> None:
""" """
must write serialized value to the file must write serialized value to the file

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

@ -0,0 +1,81 @@
import pytest
from aiohttp.test_utils import TestClient
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.status.patch import PatchView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET",):
request = pytest.helpers.request("", "", method)
assert await PatchView.get_permission(request) == UserAccess.Reporter
for method in ("DELETE",):
request = pytest.helpers.request("", "", method)
assert await PatchView.get_permission(request) == UserAccess.Full
def test_routes() -> None:
"""
must return correct routes
"""
assert PatchView.ROUTES == ["/api/v1/packages/{package}/patches/{patch}"]
async def test_delete(client: TestClient, package_ahriman: Package, package_python_schedule: Package) -> None:
"""
must delete patch for package
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
await client.post(f"/api/v1/packages/{package_python_schedule.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_python_schedule.view()})
patch_key = "k"
await client.post(f"/api/v1/packages/{package_ahriman.base}/patches", json={"key": patch_key, "value": "v"})
await client.post(f"/api/v1/packages/{package_python_schedule.base}/patches",
json={"key": patch_key, "value": "v2"})
response = await client.delete(f"/api/v1/packages/{package_ahriman.base}/patches/{patch_key}")
assert response.status == 204
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/patches")
assert not await response.json()
response = await client.get(f"/api/v1/packages/{package_python_schedule.base}/patches")
assert await response.json()
async def test_get(client: TestClient, package_ahriman: Package) -> None:
"""
must get patch for package
"""
patch = PkgbuildPatch("k", "v")
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
await client.post(f"/api/v1/packages/{package_ahriman.base}/patches", json=patch.view())
response_schema = pytest.helpers.schema_response(PatchView.get)
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/patches/{patch.key}")
assert response.status == 200
patches = await response.json()
assert not response_schema.validate(patches)
assert patches == patch.view()
async def test_get_not_found(client: TestClient, package_ahriman: Package) -> None:
"""
must return not found for missing package
"""
response_schema = pytest.helpers.schema_response(PatchView.get, code=404)
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/patches/random")
assert response.status == 404
assert not response_schema.validate(await response.json())

View File

@ -0,0 +1,75 @@
import pytest
from aiohttp.test_utils import TestClient
from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package
from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess
from ahriman.web.views.v1.status.patches import PatchesView
async def test_get_permission() -> None:
"""
must return correct permission for the request
"""
for method in ("GET",):
request = pytest.helpers.request("", "", method)
assert await PatchesView.get_permission(request) == UserAccess.Reporter
for method in ("POST",):
request = pytest.helpers.request("", "", method)
assert await PatchesView.get_permission(request) == UserAccess.Full
def test_routes() -> None:
"""
must return correct routes
"""
assert PatchesView.ROUTES == ["/api/v1/packages/{package}/patches"]
async def test_get(client: TestClient, package_ahriman: Package) -> None:
"""
must get patch for package
"""
patch = PkgbuildPatch("k", "v")
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
await client.post(f"/api/v1/packages/{package_ahriman.base}/patches", json=patch.view())
response_schema = pytest.helpers.schema_response(PatchesView.get)
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/patches")
assert response.status == 200
patches = await response.json()
assert not response_schema.validate(patches)
assert patches == [patch.view()]
async def test_post(client: TestClient, package_ahriman: Package) -> None:
"""
must create patch
"""
await client.post(f"/api/v1/packages/{package_ahriman.base}",
json={"status": BuildStatusEnum.Success.value, "package": package_ahriman.view()})
request_schema = pytest.helpers.schema_request(PatchesView.post)
payload = {"key": "k", "value": "v"}
assert not request_schema.validate(payload)
response = await client.post(f"/api/v1/packages/{package_ahriman.base}/patches", json=payload)
assert response.status == 204
response = await client.get(f"/api/v1/packages/{package_ahriman.base}/patches")
patches = await response.json()
assert patches == [payload]
async def test_post_exception(client: TestClient, package_ahriman: Package) -> None:
"""
must raise exception on invalid payload
"""
response_schema = pytest.helpers.schema_response(PatchesView.post, code=400)
response = await client.post(f"/api/v1/packages/{package_ahriman.base}/patches", json={})
assert response.status == 400
assert not response_schema.validate(await response.json())

View File

@ -44,7 +44,16 @@ async def test_get(client: TestClient, package_ahriman: Package) -> None:
logs = await response.json() logs = await response.json()
assert not response_schema.validate(logs) assert not response_schema.validate(logs)
assert logs["logs"] == [[42.0, "message 1"], [43.0, "message 2"]] assert logs == [
{
"created": 42.0,
"message": "message 1",
},
{
"created": 43.0,
"message": "message 2",
},
]
async def test_get_with_pagination(client: TestClient, package_ahriman: Package) -> None: async def test_get_with_pagination(client: TestClient, package_ahriman: Package) -> None:
@ -67,18 +76,7 @@ async def test_get_with_pagination(client: TestClient, package_ahriman: Package)
logs = await response.json() logs = await response.json()
assert not response_schema.validate(logs) assert not response_schema.validate(logs)
assert logs["logs"] == [[43.0, "message 2"]] assert logs == [{"created": 43.0, "message": "message 2"}]
async def test_get_not_found(client: TestClient, package_ahriman: Package) -> None:
"""
must return not found for missing package
"""
response_schema = pytest.helpers.schema_response(LogsView.get, code=404)
response = await client.get(f"/api/v2/packages/{package_ahriman.base}/logs")
assert response.status == 404
assert not response_schema.validate(await response.json())
async def test_get_bad_request(client: TestClient, package_ahriman: Package) -> None: async def test_get_bad_request(client: TestClient, package_ahriman: Package) -> None: