diff --git a/docs/docs/champagne_admin/errors.md b/docs/docs/champagne_admin/errors.md new file mode 100644 index 00000000..e82ede5e --- /dev/null +++ b/docs/docs/champagne_admin/errors.md @@ -0,0 +1,17 @@ +# Viewing and Managing Application Errors + +The application errors page lets a champagne admin user view and resolve a list of application errors that have occurred in champagne. + +![Screenshot](../img/errors.png) + +## Resolve a single error + +To resolve a single error, find the row with the error to resolve and click the 3 vertical dots to open the action menu and select Resolve. + +## Resolve all unresolved errors + +To resolve all existing errors that are unresolved, click the button at the top right of the Application Errors card. + +## Viewing error details + +To view more details about a specific error, then find the row with the error and click the 3 vertical dots to open the action menu and select View Details. This will open a dialog with more information including error causes and stack traces. diff --git a/docs/docs/img/errors.png b/docs/docs/img/errors.png new file mode 100644 index 00000000..c51b123b Binary files /dev/null and b/docs/docs/img/errors.png differ diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 6a715a71..4a01bc3d 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -19,3 +19,4 @@ nav: - Users: champagne_admin/users.md - Audits: champagne_admin/audits.md - Systems: champagne_admin/systems.md + - Errors: champagne_admin/errors.md diff --git a/service/src/main/java/org/kiwiproject/champagne/App.java b/service/src/main/java/org/kiwiproject/champagne/App.java index bfc19f3b..dedeaf25 100644 --- a/service/src/main/java/org/kiwiproject/champagne/App.java +++ b/service/src/main/java/org/kiwiproject/champagne/App.java @@ -34,6 +34,7 @@ import org.kiwiproject.champagne.dao.mappers.BuildMapper; import org.kiwiproject.champagne.job.CleanOutAuditsJob; import org.kiwiproject.champagne.model.Build; +import org.kiwiproject.champagne.resource.ApplicationErrorWithAuthResource; import org.kiwiproject.champagne.resource.AuditRecordResource; import org.kiwiproject.champagne.resource.AuthResource; import org.kiwiproject.champagne.resource.BuildResource; @@ -49,7 +50,6 @@ import org.kiwiproject.dropwizard.error.ErrorContextBuilder; import org.kiwiproject.dropwizard.error.dao.ApplicationErrorDao; import org.kiwiproject.dropwizard.error.model.ServiceDetails; -import org.kiwiproject.dropwizard.error.resource.ApplicationErrorResource; import org.kiwiproject.dropwizard.jdbi3.Jdbi3Builders; import org.kiwiproject.dropwizard.util.config.JacksonConfig; import org.kiwiproject.dropwizard.util.exception.StandardExceptionMappers; @@ -120,7 +120,7 @@ public void run(AppConfig configuration, Environment environment) { environment.jersey().register(new HostConfigurationResource(hostDao, componentDao, tagDao, auditRecordDao, errorDao)); environment.jersey().register(new TaskResource(releaseDao, releaseStatusDao, taskDao, taskStatusDao, deploymentEnvironmentDao, auditRecordDao, errorDao)); environment.jersey().register(new UserResource(userDao, deployableSystemDao, auditRecordDao, errorDao)); - environment.jersey().register(new ApplicationErrorResource(errorDao)); + environment.jersey().register(new ApplicationErrorWithAuthResource(errorDao)); environment.jersey().register(new DeployableSystemResource(deployableSystemDao, userDao, deploymentEnvironmentDao, auditRecordDao, errorDao)); environment.jersey().register(new TagResource(tagDao, auditRecordDao, errorDao)); environment.jersey().register(new MetricsResource(buildDao)); diff --git a/service/src/main/java/org/kiwiproject/champagne/resource/ApplicationErrorWithAuthResource.java b/service/src/main/java/org/kiwiproject/champagne/resource/ApplicationErrorWithAuthResource.java new file mode 100644 index 00000000..fa9b1bcd --- /dev/null +++ b/service/src/main/java/org/kiwiproject/champagne/resource/ApplicationErrorWithAuthResource.java @@ -0,0 +1,77 @@ +package org.kiwiproject.champagne.resource; + +import java.util.OptionalInt; +import java.util.OptionalLong; + +import com.codahale.metrics.annotation.ExceptionMetered; +import com.codahale.metrics.annotation.Timed; +import com.google.common.annotations.VisibleForTesting; +import jakarta.annotation.security.PermitAll; +import jakarta.annotation.security.RolesAllowed; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.kiwiproject.dropwizard.error.dao.ApplicationErrorDao; +import org.kiwiproject.dropwizard.error.resource.ApplicationErrorResource; + +/** + * The purposes of this resource is to add authentication and authorization to the application errors endpoints. The + * application errors library does not have a concept of auth as of this writing. + */ +@Path("/errors") +@Produces(MediaType.APPLICATION_JSON) +@PermitAll +public class ApplicationErrorWithAuthResource { + + private final ApplicationErrorResource delegate; + + public ApplicationErrorWithAuthResource(ApplicationErrorDao errorDao) { + this(new ApplicationErrorResource(errorDao)); + } + + @VisibleForTesting + ApplicationErrorWithAuthResource(ApplicationErrorResource delegate) { + this.delegate = delegate; + } + + @GET + @Path("/{id}") + @RolesAllowed({"admin"}) + @Timed + @ExceptionMetered + public Response getById(@PathParam("id") Long id) { + return delegate.getById(OptionalLong.of(id)); + } + + @GET + @RolesAllowed({"admin"}) + @Timed + @ExceptionMetered + public Response getErrors(@QueryParam("status") @DefaultValue("UNRESOLVED") String statusParam, @QueryParam("pageNumber") @DefaultValue("1") Integer pageNumber, @QueryParam("pageSize") @DefaultValue("25") Integer pageSize) { + return delegate.getErrors(statusParam, OptionalInt.of(pageNumber), OptionalInt.of(pageSize)); + } + + @PUT + @Path("/resolve/{id}") + @RolesAllowed({"admin"}) + @Timed + @ExceptionMetered + public Response resolve(@PathParam("id") Long id) { + return delegate.resolve(OptionalLong.of(id)); + } + + @PUT + @Path("/resolve") + @RolesAllowed({"admin"}) + @Timed + @ExceptionMetered + public Response resolveAllUnresolved() { + return delegate.resolveAllUnresolved(); + } +} diff --git a/service/src/main/java/org/kiwiproject/champagne/resource/AuditableResource.java b/service/src/main/java/org/kiwiproject/champagne/resource/AuditableResource.java index c67a8935..0a85a0f3 100644 --- a/service/src/main/java/org/kiwiproject/champagne/resource/AuditableResource.java +++ b/service/src/main/java/org/kiwiproject/champagne/resource/AuditableResource.java @@ -2,6 +2,8 @@ import static org.kiwiproject.champagne.util.DeployableSystems.getSystemIdOrNull; +import java.util.Objects; + import lombok.extern.slf4j.Slf4j; import org.dhatim.dropwizard.jwt.cookie.authentication.CurrentPrincipal; import org.kiwiproject.champagne.dao.AuditRecordDao; @@ -10,13 +12,11 @@ import org.kiwiproject.dropwizard.error.ApplicationErrorThrower; import org.kiwiproject.dropwizard.error.dao.ApplicationErrorDao; -import java.util.Objects; - @Slf4j public abstract class AuditableResource { private final AuditRecordDao auditRecordDao; - private final ApplicationErrorThrower applicationErrorThrower; + protected final ApplicationErrorThrower applicationErrorThrower; protected AuditableResource(AuditRecordDao auditRecordDao, ApplicationErrorDao applicationErrorDao) { this.auditRecordDao = auditRecordDao; diff --git a/service/src/test/java/org/kiwiproject/champagne/resource/ApplicationErrorWithAuthResourceTest.java b/service/src/test/java/org/kiwiproject/champagne/resource/ApplicationErrorWithAuthResourceTest.java new file mode 100644 index 00000000..c19589ee --- /dev/null +++ b/service/src/test/java/org/kiwiproject/champagne/resource/ApplicationErrorWithAuthResourceTest.java @@ -0,0 +1,126 @@ +package org.kiwiproject.champagne.resource; + +import static jakarta.ws.rs.client.Entity.json; +import static org.kiwiproject.test.jaxrs.JaxrsTestHelper.assertOkResponse; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.reset; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.OptionalInt; +import java.util.OptionalLong; + +import io.dropwizard.testing.junit5.DropwizardExtensionsSupport; +import io.dropwizard.testing.junit5.ResourceExtension; +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.kiwiproject.champagne.junit.jupiter.DeployableSystemExtension; +import org.kiwiproject.champagne.junit.jupiter.JwtExtension; +import org.kiwiproject.dropwizard.error.resource.ApplicationErrorResource; +import org.kiwiproject.dropwizard.util.exception.JerseyViolationExceptionMapper; +import org.kiwiproject.jaxrs.exception.JaxrsExceptionMapper; + +@DisplayName("BuildResource") +@ExtendWith({DropwizardExtensionsSupport.class, DeployableSystemExtension.class}) +class ApplicationErrorWithAuthResourceTest { + + private static final ApplicationErrorResource DELEGATE = mock(ApplicationErrorResource.class); + + private static final ApplicationErrorWithAuthResource RESOURCE = new ApplicationErrorWithAuthResource(DELEGATE); + + private static final ResourceExtension RESOURCES = ResourceExtension.builder() + .bootstrapLogging(false) + .addResource(RESOURCE) + .addProvider(JerseyViolationExceptionMapper.class) + .addProvider(JaxrsExceptionMapper.class) + .build(); + + @RegisterExtension + public final JwtExtension jwtExtension = new JwtExtension("bob"); + + @AfterEach + void cleanup() { + reset(DELEGATE); + } + + @Nested + class GetById { + + @Test + void delegateToGetById() { + when(DELEGATE.getById(OptionalLong.of(1L))).thenReturn(Response.ok().build()); + + var response = RESOURCES.client() + .target("/errors/{id}") + .resolveTemplate("id", 1L) + .request() + .get(); + + assertOkResponse(response); + + verify(DELEGATE).getById(OptionalLong.of(1L)); + } + } + + @Nested + class GetErrors { + + @Test + void shouldDelegateToGetErrors() { + when(DELEGATE.getErrors(anyString(), any(OptionalInt.class), any(OptionalInt.class))).thenReturn(Response.ok().build()); + + var response = RESOURCES.client() + .target("/errors") + .request() + .get(); + + assertOkResponse(response); + + verify(DELEGATE).getErrors(anyString(), any(OptionalInt.class), any(OptionalInt.class)); + } + } + + @Nested + class Resolve { + + @Test + void shouldDelegateToResolve() { + when(DELEGATE.resolve(OptionalLong.of(1L))).thenReturn(Response.ok().build()); + + var response = RESOURCES.client() + .target("/errors/resolve/{id}") + .resolveTemplate("id", 1L) + .request() + .put(json("")); + + assertOkResponse(response); + + verify(DELEGATE).resolve(OptionalLong.of(1L)); + } + } + + @Nested + class ResolveAllUnresolved { + + @Test + void shouldDelegateToResolveAllUnresolved() { + when(DELEGATE.resolveAllUnresolved()).thenReturn(Response.ok().build()); + + var response = RESOURCES.client() + .target("/errors/resolve") + .request() + .put(json("")); + + assertOkResponse(response); + + verify(DELEGATE).resolveAllUnresolved(); + } + } +} diff --git a/ui/package-lock.json b/ui/package-lock.json index 5fe7af03..c55ee573 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -30,6 +30,7 @@ "@vue/test-utils": "2.4.1", "autoprefixer": "10.4.15", "cypress": "13.2.0", + "daisyui": "3.7.4", "eslint": "8.49.0", "eslint-plugin-cypress": "2.14.0", "eslint-plugin-vue": "9.17.0", @@ -1939,6 +1940,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -2040,6 +2047,16 @@ "css": "^2.0.0" } }, + "node_modules/css-selector-tokenizer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz", + "integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "fastparse": "^1.1.2" + } + }, "node_modules/css/node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -2134,6 +2151,26 @@ "node": "^16.0.0 || ^18.0.0 || >=20.0.0" } }, + "node_modules/daisyui": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-3.7.4.tgz", + "integrity": "sha512-hAgTomIK8RDQ/RLH9Z2NxZiNVAO40w08FlhgYS/8CTFF+wggeHeNJ0qNBHWAJJzhjD8UU2u4PZ4nc4r9rwfTLw==", + "dev": true, + "dependencies": { + "colord": "^2.9", + "css-selector-tokenizer": "^0.8", + "postcss": "^8", + "postcss-js": "^4", + "tailwindcss": "^3" + }, + "engines": { + "node": ">=16.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/daisyui" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -2973,6 +3010,12 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fastparse": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", + "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", + "dev": true + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -7947,6 +7990,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "dev": true + }, "colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -8045,6 +8094,16 @@ "css": "^2.0.0" } }, + "css-selector-tokenizer": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz", + "integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==", + "dev": true, + "requires": { + "cssesc": "^3.0.0", + "fastparse": "^1.1.2" + } + }, "cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -8115,6 +8174,19 @@ "yauzl": "^2.10.0" } }, + "daisyui": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-3.7.4.tgz", + "integrity": "sha512-hAgTomIK8RDQ/RLH9Z2NxZiNVAO40w08FlhgYS/8CTFF+wggeHeNJ0qNBHWAJJzhjD8UU2u4PZ4nc4r9rwfTLw==", + "dev": true, + "requires": { + "colord": "^2.9", + "css-selector-tokenizer": "^0.8", + "postcss": "^8", + "postcss-js": "^4", + "tailwindcss": "^3" + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -8718,6 +8790,12 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "fastparse": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", + "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", + "dev": true + }, "fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", diff --git a/ui/package.json b/ui/package.json index e7502322..dc18d199 100644 --- a/ui/package.json +++ b/ui/package.json @@ -36,6 +36,7 @@ "@vue/test-utils": "2.4.1", "autoprefixer": "10.4.15", "cypress": "13.2.0", + "daisyui": "3.7.4", "eslint": "8.49.0", "eslint-plugin-cypress": "2.14.0", "eslint-plugin-vue": "9.17.0", diff --git a/ui/src/router/index.js b/ui/src/router/index.js index 05095ac4..6055fad1 100644 --- a/ui/src/router/index.js +++ b/ui/src/router/index.js @@ -16,6 +16,7 @@ import UsersView from "@/views/ChampagneAdmin/UsersView.vue"; import SystemAuditsView from "@/views/SystemAdmin/SystemAuditsView.vue"; import AuditsView from "@/views/ChampagneAdmin/AuditsView.vue"; import SystemsView from "@/views/ChampagneAdmin/SystemsView.vue"; +import ErrorsView from "@/views/ChampagneAdmin/ErrorsView.vue"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -37,6 +38,7 @@ const router = createRouter({ { path: '/systemAudits', component: SystemAuditsView, name: 'systemAudits' }, { path: '/audits', component: AuditsView, name: 'audits' }, { path: '/systems', component: SystemsView, name: 'systems' }, + { path: '/errors', component: ErrorsView, name: 'errors' }, { path: '/noDeployableSystem', component: NoDeployableSystemView, name: 'noDeployableSystem' } ] }, diff --git a/ui/src/views/ChampagneAdmin/ErrorsView.vue b/ui/src/views/ChampagneAdmin/ErrorsView.vue new file mode 100644 index 00000000..707c2f73 --- /dev/null +++ b/ui/src/views/ChampagneAdmin/ErrorsView.vue @@ -0,0 +1,179 @@ + + + diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js index 435f174e..a55429ed 100644 --- a/ui/tailwind.config.js +++ b/ui/tailwind.config.js @@ -98,6 +98,7 @@ module.exports = { ], plugins: [ require("@tailwindcss/forms"), + require("daisyui"), plugin(function ({ addComponents, theme }) { const screens = theme("screens", {}); addComponents([