From 516e43537a216ad76581266ec1811b8b84218f3b Mon Sep 17 00:00:00 2001 From: Kayra Date: Mon, 13 May 2024 14:12:56 +0300 Subject: [PATCH 01/20] metrics middleware skelly --- internal/api/handlers.go | 31 +++++++++++++------ internal/api/middleware.go | 55 +++++++++++++++++++++++++++++++++ internal/api/middleware_test.go | 1 + 3 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 internal/api/middleware.go create mode 100644 internal/api/middleware_test.go diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 5b1fc5a..9d94940 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -36,9 +36,30 @@ func NewGoCertRouter(env *Environment) http.Handler { router.Handle("/api/v1/", http.StripPrefix("/api/v1", apiV1Router)) router.Handle("/", frontendHandler) - return logging(router) + ctx := Context{} + middleware := createMiddlewareStack( + Metrics(&ctx), + Logging(&ctx), + ) + return middleware(router) } +// createMiddlewareStack chains given middleware for the server. +// Each middleware functions calls next.ServeHTTP in order to resume the chain of execution. +// The order these functions are given to createMiddlewareStack matters. +// The functions will run the code before next.ServeHTTP in order. +// The functions will run the code after next.ServeHTTP in reverse order. +func createMiddlewareStack(middleware ...Middleware) Middleware { + return func(next http.Handler) http.Handler { + for i := len(middleware) - 1; i >= 0; i-- { + mw := middleware[i] + next = mw(next) + } + return next + } +} + +// newFrontendFileServer uses the embedded ui output files as the base for a file server func newFrontendFileServer() http.Handler { frontendFS, err := fs.Sub(ui.FrontendFS, "out") if err != nil { @@ -213,14 +234,6 @@ func DeleteCertificate(env *Environment) http.HandlerFunc { } } -// The logging middleware captures any http request coming through, and logs it -func logging(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - next.ServeHTTP(w, r) - log.Println(r.Method, r.URL.Path) - }) -} - // logErrorAndWriteResponse is a helper function that logs any error and writes it back as an http response func logErrorAndWriteResponse(msg string, status int, w http.ResponseWriter) { errMsg := fmt.Sprintf("error: %s", msg) diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..4ec6b69 --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,55 @@ +package server + +import ( + "log" + "net/http" +) + +type Middleware func(http.Handler) http.Handler + +// The Context type helps middleware pass along information through the chain. +type Context struct { + responseStatusCode int +} + +// The ResponseWriterCloner struct implements the http.ResponseWriter class, and copies the status +// code of the response for the middleware to be able to read the responses. +type ResponseWriterCloner struct { + http.ResponseWriter + statusCode int +} + +// NewResponseWriter returns a new ResponseWriterCloner struct +func NewResponseWriter(w http.ResponseWriter) *ResponseWriterCloner { + return &ResponseWriterCloner{w, http.StatusOK} +} + +// WriteHeader duplicates the status code into the cloner struct for reading +func (rwc *ResponseWriterCloner) WriteHeader(code int) { + rwc.statusCode = code + rwc.ResponseWriter.WriteHeader(code) +} + +// The Metrics middleware captures any request relevant to a metric and records it for prometheus. +func Metrics(ctx *Context) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r) + if ctx.responseStatusCode != 200 { + return + } + }) + } +} + +// The logging middleware captures any http request coming through, and logs it. +func Logging(ctx *Context) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + clonedWwriter := NewResponseWriter(w) + next.ServeHTTP(w, r) + log.Println(r.Method, r.URL.Path, clonedWwriter.statusCode, http.StatusText(clonedWwriter.statusCode)) + ctx.responseStatusCode = clonedWwriter.statusCode + }) + } +} diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go new file mode 100644 index 0000000..5868720 --- /dev/null +++ b/internal/api/middleware_test.go @@ -0,0 +1 @@ +package server_test From b025a4a37dabb0aaf900a39c4c17571576e03d57 Mon Sep 17 00:00:00 2001 From: kayra1 Date: Mon, 3 Jun 2024 19:50:11 +0300 Subject: [PATCH 02/20] frontend proof of concept --- .github/workflows/build-rock.yaml | 2 +- .github/workflows/go-lint.yaml | 2 +- .github/workflows/publish-rock.yaml | 2 +- go.mod | 4 +- go.sum | 8 +- internal/api/handlers.go | 25 +- internal/api/middleware.go | 63 +++-- internal/api/middleware_test.go | 1 - internal/certdb/validation.go | 2 + internal/metrics/metrics.go | 219 +++++++++++++++++- internal/metrics/metrics_test.go | 135 ++++++++++- rockcraft.yaml | 5 +- ui/package-lock.json | 125 +++++++++- ui/package.json | 9 +- ui/src/app/certificate_requests/page.test.tsx | 8 + ui/src/app/certificate_requests/page.tsx | 9 + ui/src/app/certificate_requests/row.test.tsx | 11 + ui/src/app/certificate_requests/row.tsx | 32 +++ ui/src/app/certificate_requests/table.tsx | 38 +++ ui/src/app/globals.scss | 5 + ui/src/app/layout.tsx | 11 +- ui/src/app/nav.test.tsx | 14 ++ ui/src/app/nav.tsx | 113 +++++++++ ui/src/app/page.test.tsx | 4 +- ui/src/app/page.tsx | 6 +- 25 files changed, 774 insertions(+), 79 deletions(-) delete mode 100644 internal/api/middleware_test.go create mode 100644 ui/src/app/certificate_requests/page.test.tsx create mode 100644 ui/src/app/certificate_requests/page.tsx create mode 100644 ui/src/app/certificate_requests/row.test.tsx create mode 100644 ui/src/app/certificate_requests/row.tsx create mode 100644 ui/src/app/certificate_requests/table.tsx create mode 100644 ui/src/app/globals.scss create mode 100644 ui/src/app/nav.test.tsx create mode 100644 ui/src/app/nav.tsx diff --git a/.github/workflows/build-rock.yaml b/.github/workflows/build-rock.yaml index ad38bc5..713d738 100644 --- a/.github/workflows/build-rock.yaml +++ b/.github/workflows/build-rock.yaml @@ -28,7 +28,7 @@ jobs: docker run -d -p 3000:3000 --name gocert gocert:latest - name: Load config run: | - docker exec gocert /bin/pebble mkdir /etc/config + docker exec gocert /usr/bin/pebble mkdir /etc/config docker cp key.pem gocert:/etc/config/key.pem docker cp cert.pem gocert:/etc/config/cert.pem docker cp config.yaml gocert:/etc/config/config.yaml diff --git a/.github/workflows/go-lint.yaml b/.github/workflows/go-lint.yaml index 9edf37b..2dd6a88 100644 --- a/.github/workflows/go-lint.yaml +++ b/.github/workflows/go-lint.yaml @@ -18,6 +18,6 @@ jobs: name: frontend-static-files path: ui/out - name: golangci-lint - uses: golangci/golangci-lint-action@v5 + uses: golangci/golangci-lint-action@v6 with: version: v1.54 \ No newline at end of file diff --git a/.github/workflows/publish-rock.yaml b/.github/workflows/publish-rock.yaml index dd8ab77..c0820eb 100644 --- a/.github/workflows/publish-rock.yaml +++ b/.github/workflows/publish-rock.yaml @@ -11,7 +11,7 @@ jobs: uses: actions/checkout@v4 - name: Log in to the Container registry - uses: docker/login-action@v3.1.0 + uses: docker/login-action@v3.2.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/go.mod b/go.mod index 249e027..0038d40 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.22.1 require ( github.com/mattn/go-sqlite3 v1.14.22 - github.com/prometheus/client_golang v1.19.0 + github.com/prometheus/client_golang v1.19.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -15,6 +15,6 @@ require ( github.com/prometheus/client_model v0.5.0 // indirect github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect - golang.org/x/sys v0.16.0 // indirect + golang.org/x/sys v0.17.0 // indirect google.golang.org/protobuf v1.33.0 // indirect ) diff --git a/go.sum b/go.sum index 2f7c8ea..8962407 100644 --- a/go.sum +++ b/go.sum @@ -13,8 +13,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= -github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= +github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= +github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= @@ -23,8 +23,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 9d94940..5764198 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -27,38 +27,23 @@ func NewGoCertRouter(env *Environment) http.Handler { apiV1Router.HandleFunc("POST /certificate_requests/{id}/certificate/reject", RejectCertificate(env)) apiV1Router.HandleFunc("DELETE /certificate_requests/{id}/certificate", DeleteCertificate(env)) - metricsHandler := metrics.NewPrometheusMetricsHandler() + m := metrics.NewMetricsSubsystem(env.DB) frontendHandler := newFrontendFileServer() router := http.NewServeMux() router.HandleFunc("/status", HealthCheck) - router.Handle("/metrics", metricsHandler) + router.Handle("/metrics", m.Handler) router.Handle("/api/v1/", http.StripPrefix("/api/v1", apiV1Router)) router.Handle("/", frontendHandler) - ctx := Context{} + ctx := middlewareContext{metrics: m} middleware := createMiddlewareStack( - Metrics(&ctx), - Logging(&ctx), + metricsMiddleware(&ctx), + loggingMiddleware(&ctx), ) return middleware(router) } -// createMiddlewareStack chains given middleware for the server. -// Each middleware functions calls next.ServeHTTP in order to resume the chain of execution. -// The order these functions are given to createMiddlewareStack matters. -// The functions will run the code before next.ServeHTTP in order. -// The functions will run the code after next.ServeHTTP in reverse order. -func createMiddlewareStack(middleware ...Middleware) Middleware { - return func(next http.Handler) http.Handler { - for i := len(middleware) - 1; i >= 0; i-- { - mw := middleware[i] - next = mw(next) - } - return next - } -} - // newFrontendFileServer uses the embedded ui output files as the base for a file server func newFrontendFileServer() http.Handler { frontendFS, err := fs.Sub(ui.FrontendFS, "out") diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 4ec6b69..8e8e3d1 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -3,50 +3,75 @@ package server import ( "log" "net/http" + + "github.com/canonical/gocert/internal/metrics" + "github.com/prometheus/client_golang/prometheus/promhttp" ) -type Middleware func(http.Handler) http.Handler +type middleware func(http.Handler) http.Handler -// The Context type helps middleware pass along information through the chain. -type Context struct { +// The middlewareContext type helps middleware receive and pass along information through the middleware chain. +type middlewareContext struct { responseStatusCode int + metrics *metrics.PrometheusMetrics } -// The ResponseWriterCloner struct implements the http.ResponseWriter class, and copies the status -// code of the response for the middleware to be able to read the responses. -type ResponseWriterCloner struct { +// The responseWriterCloner struct wraps the http.ResponseWriter struct, and extracts the status +// code of the response writer for the middleware to read +type responseWriterCloner struct { http.ResponseWriter statusCode int } -// NewResponseWriter returns a new ResponseWriterCloner struct -func NewResponseWriter(w http.ResponseWriter) *ResponseWriterCloner { - return &ResponseWriterCloner{w, http.StatusOK} +// newResponseWriter returns a new ResponseWriterCloner struct +// it returns http.StatusOK by default because the http.ResponseWriter defaults to that header +// if the WriteHeader() function is never called. +func newResponseWriter(w http.ResponseWriter) *responseWriterCloner { + return &responseWriterCloner{w, http.StatusOK} } -// WriteHeader duplicates the status code into the cloner struct for reading -func (rwc *ResponseWriterCloner) WriteHeader(code int) { +// WriteHeader overrides the ResponseWriter method to duplicate the status code into the wrapper struct +func (rwc *responseWriterCloner) WriteHeader(code int) { rwc.statusCode = code rwc.ResponseWriter.WriteHeader(code) } +// createMiddlewareStack chains the given middleware functions to wrap the api. +// Each middleware functions calls next.ServeHTTP in order to resume the chain of execution. +// The order the middleware functions are given to createMiddlewareStack matters. +// Any code before next.ServeHTTP is called is executed in the given middleware's order. +// Any code after next.ServeHTTP is called is executed in the given middleware's reverse order. +func createMiddlewareStack(middleware ...middleware) middleware { + return func(next http.Handler) http.Handler { + for i := len(middleware) - 1; i >= 0; i-- { + mw := middleware[i] + next = mw(next) + } + return next + } +} + // The Metrics middleware captures any request relevant to a metric and records it for prometheus. -func Metrics(ctx *Context) Middleware { +func metricsMiddleware(ctx *middlewareContext) middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - next.ServeHTTP(w, r) - if ctx.responseStatusCode != 200 { - return - } + base := promhttp.InstrumentHandlerCounter( + &ctx.metrics.RequestsTotal, + promhttp.InstrumentHandlerDuration( + &ctx.metrics.RequestsDuration, + next, + ), + ) + base.ServeHTTP(w, r) }) } } -// The logging middleware captures any http request coming through, and logs it. -func Logging(ctx *Context) Middleware { +// The Logging middleware captures any http request coming through and the response status code, and logs it. +func loggingMiddleware(ctx *middlewareContext) middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - clonedWwriter := NewResponseWriter(w) + clonedWwriter := newResponseWriter(w) next.ServeHTTP(w, r) log.Println(r.Method, r.URL.Path, clonedWwriter.statusCode, http.StatusText(clonedWwriter.statusCode)) ctx.responseStatusCode = clonedWwriter.statusCode diff --git a/internal/api/middleware_test.go b/internal/api/middleware_test.go deleted file mode 100644 index 5868720..0000000 --- a/internal/api/middleware_test.go +++ /dev/null @@ -1 +0,0 @@ -package server_test diff --git a/internal/certdb/validation.go b/internal/certdb/validation.go index 9a487a4..ae191ae 100644 --- a/internal/certdb/validation.go +++ b/internal/certdb/validation.go @@ -22,6 +22,7 @@ func ValidateCertificateRequest(csr string) error { if err != nil { return err } + // TODO: We should validate the actual certificate request parameters here too. (Has the required fields etc) return nil } @@ -40,6 +41,7 @@ func ValidateCertificate(cert string) error { if err != nil { return err } + // TODO: We should validate the actual certificate parameters here too. (Has the required fields etc) return nil } diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index b55d107..a790d50 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -1,17 +1,224 @@ package metrics import ( + "crypto/x509" + "encoding/pem" + "errors" + "log" "net/http" + "time" + "github.com/canonical/gocert/internal/certdb" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" "github.com/prometheus/client_golang/prometheus/promhttp" ) -// Returns an HTTP handler for Prometheus metrics. -func NewPrometheusMetricsHandler() http.Handler { - reg := prometheus.NewRegistry() - reg.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) - prometheusHandler := promhttp.HandlerFor(reg, promhttp.HandlerOpts{}) - return prometheusHandler +type PrometheusMetrics struct { + http.Handler + registry *prometheus.Registry + CertificateRequests prometheus.Gauge + OutstandingCertificateRequests prometheus.Gauge + Certificates prometheus.Gauge + CertificatesExpiringIn1Day prometheus.Gauge + CertificatesExpiringIn7Days prometheus.Gauge + CertificatesExpiringIn30Days prometheus.Gauge + CertificatesExpiringIn90Days prometheus.Gauge + ExpiredCertificates prometheus.Gauge + + RequestsTotal prometheus.CounterVec + RequestsDuration prometheus.HistogramVec +} + +// NewMetricsSubsystem returns the metrics endpoint HTTP handler and the Prometheus metrics collectors for the server and middleware. +func NewMetricsSubsystem(db *certdb.CertificateRequestsRepository) *PrometheusMetrics { + metricsBackend := newPrometheusMetrics() + metricsBackend.Handler = promhttp.HandlerFor(metricsBackend.registry, promhttp.HandlerOpts{}) + ticker := time.NewTicker(120 * time.Second) + go func() { + for ; ; <-ticker.C { + csrs, err := db.RetrieveAll() + if err != nil { + log.Println(errors.Join(errors.New("error generating metrics repository: "), err)) + panic(1) + } + metricsBackend.GenerateMetrics(csrs) + } + }() + return metricsBackend +} + +// newPrometheusMetrics reads the status of the database, calculates all of the values of the metrics, +// registers these metrics to the prometheus registry, and returns the registry and the metrics. +// The registry and metrics can be modified from this struct from anywhere in the codebase. +func newPrometheusMetrics() *PrometheusMetrics { + m := &PrometheusMetrics{ + registry: prometheus.NewRegistry(), + CertificateRequests: certificateRequestsMetric(), + OutstandingCertificateRequests: outstandingCertificateRequestsMetric(), + Certificates: certificatesMetric(), + ExpiredCertificates: expiredCertificatesMetric(), + CertificatesExpiringIn1Day: certificatesExpiringIn1DayMetric(), + CertificatesExpiringIn7Days: certificatesExpiringIn7DaysMetric(), + CertificatesExpiringIn30Days: certificatesExpiringIn30DaysMetric(), + CertificatesExpiringIn90Days: certificatesExpiringIn90DaysMetric(), + + RequestsTotal: requestsTotalMetric(), + RequestsDuration: requestDurationMetric(), + } + m.registry.MustRegister(m.CertificateRequests) + m.registry.MustRegister(m.OutstandingCertificateRequests) + m.registry.MustRegister(m.Certificates) + m.registry.MustRegister(m.ExpiredCertificates) + m.registry.MustRegister(m.CertificatesExpiringIn1Day) + m.registry.MustRegister(m.CertificatesExpiringIn7Days) + m.registry.MustRegister(m.CertificatesExpiringIn30Days) + m.registry.MustRegister(m.CertificatesExpiringIn90Days) + + m.registry.MustRegister(m.RequestsTotal) + m.registry.MustRegister(m.RequestsDuration) + + m.registry.MustRegister(collectors.NewGoCollector()) + m.registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{})) + return m +} + +// GenerateMetrics receives the live list of csrs to calculate the most recent values for the metrics +// defined for prometheus +func (pm *PrometheusMetrics) GenerateMetrics(csrs []certdb.CertificateRequest) { + var csrCount float64 = float64(len(csrs)) + var outstandingCSRCount float64 + var certCount float64 + var expiredCertCount float64 + var expiringIn1DayCertCount float64 + var expiringIn7DaysCertCount float64 + var expiringIn30DaysCertCount float64 + var expiringIn90DaysCertCount float64 + for _, entry := range csrs { + if entry.Certificate == "" { + outstandingCSRCount += 1 + continue + } + if entry.Certificate == "rejected" { + continue + } + certCount += 1 + expiryDate := certificateExpiryDate(entry.Certificate) + daysRemaining := time.Until(expiryDate).Hours() / 24 + if daysRemaining < 0 { + expiredCertCount += 1 + } else { + if daysRemaining < 1 { + expiringIn1DayCertCount += 1 + } + if daysRemaining < 7 { + expiringIn7DaysCertCount += 1 + } + if daysRemaining < 30 { + expiringIn30DaysCertCount += 1 + } + if daysRemaining < 90 { + expiringIn90DaysCertCount += 1 + } + } + } + pm.CertificateRequests.Set(csrCount) + pm.OutstandingCertificateRequests.Set(outstandingCSRCount) + pm.Certificates.Set(certCount) + pm.ExpiredCertificates.Set(expiredCertCount) + pm.CertificatesExpiringIn1Day.Set(expiringIn1DayCertCount) + pm.CertificatesExpiringIn7Days.Set(expiringIn7DaysCertCount) + pm.CertificatesExpiringIn30Days.Set(expiringIn30DaysCertCount) + pm.CertificatesExpiringIn90Days.Set(expiringIn90DaysCertCount) +} + +func certificateRequestsMetric() prometheus.Gauge { + metric := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "certificate_requests", + Help: "Total number of certificate requests", + }) + return metric +} + +func outstandingCertificateRequestsMetric() prometheus.Gauge { + metric := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "outstanding_certificate_requests", + Help: "Number of outstanding certificate requests", + }) + return metric +} + +func certificatesMetric() prometheus.Gauge { + metric := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "certificates", + Help: "Total number of certificates provided to certificate requests", + }) + return metric +} + +func expiredCertificatesMetric() prometheus.Gauge { + metric := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "certificates_expired", + Help: "Number of expired certificates", + }) + return metric +} + +func certificatesExpiringIn1DayMetric() prometheus.Gauge { + metric := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "certificates_expiring_in_1_day", + Help: "Number of certificates expiring in less than 1 day", + }) + return metric +} + +func certificatesExpiringIn7DaysMetric() prometheus.Gauge { + metric := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "certificates_expiring_in_7_days", + Help: "Number of certificates expiring in less than 7 days", + }) + return metric +} +func certificatesExpiringIn30DaysMetric() prometheus.Gauge { + metric := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "certificates_expiring_in_30_days", + Help: "Number of certificates expiring in less than 30 days", + }) + return metric +} + +func certificatesExpiringIn90DaysMetric() prometheus.Gauge { + metric := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "certificates_expiring_in_90_days", + Help: "Number of certificates expiring in less than 90 days", + }) + return metric +} + +func requestsTotalMetric() prometheus.CounterVec { + metric := prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Tracks the number of HTTP requests.", + }, []string{"method", "code"}, + ) + return *metric +} + +func requestDurationMetric() prometheus.HistogramVec { + metric := prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "http_request_duration_seconds", + Help: "Tracks the latencies for HTTP requests.", + Buckets: prometheus.ExponentialBuckets(0.1, 1.5, 5), + }, []string{"method", "code"}, + ) + return *metric +} + +func certificateExpiryDate(certString string) time.Time { + certBlock, _ := pem.Decode([]byte(certString)) + cert, _ := x509.ParseCertificate(certBlock.Bytes) + // TODO: cert.NotAfter can exist in a wrong cert. We should catch that at the db level validation + return cert.NotAfter } diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go index 5885eb7..fbfc162 100644 --- a/internal/metrics/metrics_test.go +++ b/internal/metrics/metrics_test.go @@ -1,17 +1,31 @@ package metrics_test import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "math/big" "net/http" "net/http/httptest" + "os" "strings" "testing" + "time" + "github.com/canonical/gocert/internal/certdb" metrics "github.com/canonical/gocert/internal/metrics" ) // TestPrometheusHandler tests that the Prometheus metrics handler responds correctly to an HTTP request. func TestPrometheusHandler(t *testing.T) { - handler := metrics.NewPrometheusMetricsHandler() + db, err := certdb.NewCertificateRequestsRepository(":memory:", "CertificateReq") + if err != nil { + t.Fatal(err) + } + m := metrics.NewMetricsSubsystem(db) request, err := http.NewRequest("GET", "/", nil) if err != nil { @@ -19,7 +33,7 @@ func TestPrometheusHandler(t *testing.T) { } recorder := httptest.NewRecorder() - handler.ServeHTTP(recorder, request) + m.Handler.ServeHTTP(recorder, request) if status := recorder.Code; status != http.StatusOK { t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) @@ -30,4 +44,121 @@ func TestPrometheusHandler(t *testing.T) { if !strings.Contains(recorder.Body.String(), "go_goroutines") { t.Errorf("handler returned an empty body") } + err = db.Close() + if err != nil { + t.Fatal(err) + } +} + +// Generates a CSR and Certificate with the given days remaining +func generateCertPair(daysRemaining int) (string, string) { + NotAfterTime := time.Now().AddDate(0, 0, daysRemaining) + key, _ := rsa.GenerateKey(rand.Reader, 2048) + + csrTemplate := x509.CertificateRequest{} + certTemplate := x509.Certificate{ + SerialNumber: big.NewInt(1), + NotAfter: NotAfterTime, + } + + csrBytes, _ := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, key) + certBytes, _ := x509.CreateCertificate(rand.Reader, &certTemplate, &certTemplate, &key.PublicKey, key) + + var buff bytes.Buffer + pem.Encode(&buff, &pem.Block{ //nolint:errcheck + Type: "CERTIFICATE REQUEST", + Bytes: csrBytes, + }) + csr := buff.String() + buff.Reset() + pem.Encode(&buff, &pem.Block{ //nolint:errcheck + Type: "CERTIFICATE", + Bytes: certBytes, + }) + cert := buff.String() + return csr, cert +} + +func initializeTestDB(t *testing.T, db *certdb.CertificateRequestsRepository) { + for i, v := range []int{5, 10, 32} { + csr, cert := generateCertPair(v) + _, err := db.Create(csr) + if err != nil { + t.Fatalf("couldn't create test csr:%s", err) + } + _, err = db.Update(fmt.Sprint(i+1), cert) + if err != nil { + t.Fatalf("couldn't create test cert:%s", err) + } + } +} + +// TestMetrics tests some of the metrics that we currently collect. +func TestMetrics(t *testing.T) { + f, err := os.CreateTemp("./","*.db") + fmt.Print(f.Name()) + if err != nil { + t.Fatal("couldn't create temp db file: "+ err.Error()) + } + defer f.Close() + defer os.Remove(f.Name()) + db, err := certdb.NewCertificateRequestsRepository(f.Name(), "CertificateReq") + if err != nil { + t.Fatal(err) + } + initializeTestDB(t, db) + m := metrics.NewMetricsSubsystem(db) + csrs, _ := db.RetrieveAll() + m.GenerateMetrics(csrs) + + request, _ := http.NewRequest("GET", "/", nil) + recorder := httptest.NewRecorder() + m.Handler.ServeHTTP(recorder, request) + + if status := recorder.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) + } + if recorder.Body.String() == "" { + t.Errorf("handler returned an empty body") + } + for _, line := range strings.Split(recorder.Body.String(), "\n") { + if strings.Contains(line, "outstanding_certificate_requests ") && !strings.HasPrefix(line, "#") { + if !strings.HasSuffix(line, "0") { + t.Errorf("outstanding_certificate_requests expected to receive 0") + } + } else if strings.Contains(line, "certificate_requests ") && !strings.HasPrefix(line, "#") { + if !strings.HasSuffix(line, "3") { + t.Errorf("certificate_requests expected to receive 3") + } + } else if strings.Contains(line, "certificates ") && !strings.HasPrefix(line, "#") { + if !strings.HasSuffix(line, "3") { + t.Errorf("certificates expected to receive 3") + } + } else if strings.Contains(line, "certificates_expired ") && !strings.HasPrefix(line, "#") { + if !strings.HasSuffix(line, "0") { + t.Errorf("certificates_expired expected to receive 0") + } + } else if strings.Contains(line, "certificates_expiring_in_1_day ") && !strings.HasPrefix(line, "#") { + if !strings.HasSuffix(line, "0") { + t.Errorf("certificates_expiring_in_1_day expected to receive 0") + } + } else if strings.Contains(line, "certificates_expiring_in_7_days ") && !strings.HasPrefix(line, "#") { + if !strings.HasSuffix(line, "1") { + t.Errorf("certificates_expiring_in_7_days expected to receive 1") + } + } else if strings.Contains(line, "certificates_expiring_in_30_days ") && !strings.HasPrefix(line, "#") { + if !strings.HasSuffix(line, "2") { + t.Errorf("certificates_expiring_in_30_days expected to receive 2") + } + } else if strings.Contains(line, "certificates_expiring_in_90_days ") && !strings.HasPrefix(line, "#") { + if !strings.HasSuffix(line, "3") { + t.Errorf("certificates_expiring_in_90_days expected to receive 3") + } + } + } + + err = db.Close() + if err != nil { + t.Fatal(err) + } } diff --git a/rockcraft.yaml b/rockcraft.yaml index d461324..1732ec5 100644 --- a/rockcraft.yaml +++ b/rockcraft.yaml @@ -1,6 +1,6 @@ name: gocert base: bare -build-base: ubuntu@22.04 +build-base: ubuntu@24.04 version: '0.0.2' summary: A certificate management tool description: | @@ -30,4 +30,5 @@ parts: craftctl default stage-packages: - ca-certificates_data - - libc6_libs \ No newline at end of file + - libc6_libs + - base-files_lib diff --git a/ui/package-lock.json b/ui/package-lock.json index d66e34d..a378531 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,7 +8,9 @@ "dependencies": { "next": "14.2.3", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "sass": "^1.77.4", + "vanilla-framework": "^4.11.0" }, "devDependencies": { "@testing-library/react": "^15.0.4", @@ -1785,6 +1787,18 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -2028,6 +2042,17 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -2042,7 +2067,6 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, "dependencies": { "fill-range": "^7.0.1" }, @@ -2195,6 +2219,40 @@ "node": "*" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/client-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", @@ -3254,7 +3312,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -3347,7 +3404,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -3746,6 +3802,11 @@ "node": ">= 4" } }, + "node_modules/immutable": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", + "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==" + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -3844,6 +3905,17 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-boolean-object": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", @@ -3918,7 +3990,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "engines": { "node": ">=0.10.0" } @@ -3963,7 +4034,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -3999,7 +4069,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "engines": { "node": ">=0.12.0" } @@ -4642,6 +4711,14 @@ "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", "dev": true }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/npm-run-path": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", @@ -4986,7 +5063,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -5172,6 +5248,17 @@ "node": ">=0.10.0" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", @@ -5393,6 +5480,22 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/sass": { + "version": "1.77.4", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.4.tgz", + "integrity": "sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw==", + "dependencies": { + "chokidar": ">=3.0.0 <4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -5868,7 +5971,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "dependencies": { "is-number": "^7.0.0" }, @@ -6148,6 +6250,11 @@ "requires-port": "^1.0.0" } }, + "node_modules/vanilla-framework": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/vanilla-framework/-/vanilla-framework-4.11.0.tgz", + "integrity": "sha512-S0AAHNVphn3YJ7BQhyHmvKhrqiZcncQBPedwMan18uavB4VfAT8UN0dwgrHQvY/ypUuJiq/GPA0Xe1xcd+E7dg==" + }, "node_modules/vite": { "version": "5.2.11", "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.11.tgz", diff --git a/ui/package.json b/ui/package.json index 337ba5d..348263e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -7,12 +7,15 @@ "build": "next build", "start": "next start", "lint": "next lint", - "test": "vitest run" + "test": "vitest run", + "test-live": "vitest" }, "dependencies": { + "next": "14.2.3", "react": "^18", "react-dom": "^18", - "next": "14.2.3" + "sass": "^1.77.4", + "vanilla-framework": "^4.11.0" }, "devDependencies": { "@testing-library/react": "^15.0.4", @@ -29,4 +32,4 @@ "overrides": { "rollup": "npm:@rollup/wasm-node@*" } -} \ No newline at end of file +} diff --git a/ui/src/app/certificate_requests/page.test.tsx b/ui/src/app/certificate_requests/page.test.tsx new file mode 100644 index 0000000..50eaaba --- /dev/null +++ b/ui/src/app/certificate_requests/page.test.tsx @@ -0,0 +1,8 @@ +import { expect, test } from 'vitest' +import { render, screen } from '@testing-library/react' +import CertificateRequests from './page' + +test('CertificateRequestsPage', () => { + render(< CertificateRequests />) + expect(screen.getByRole('table', {})).toBeDefined() +}) \ No newline at end of file diff --git a/ui/src/app/certificate_requests/page.tsx b/ui/src/app/certificate_requests/page.tsx new file mode 100644 index 0000000..a0e69a4 --- /dev/null +++ b/ui/src/app/certificate_requests/page.tsx @@ -0,0 +1,9 @@ +"use client" + +import { CertificateRequestsTable } from "./table" + +export default function CertificateRequests() { + return ( + + ) +} \ No newline at end of file diff --git a/ui/src/app/certificate_requests/row.test.tsx b/ui/src/app/certificate_requests/row.test.tsx new file mode 100644 index 0000000..dbe8269 --- /dev/null +++ b/ui/src/app/certificate_requests/row.test.tsx @@ -0,0 +1,11 @@ +import { expect, test } from 'vitest' +import { render, screen } from '@testing-library/react' +import Row from './row' + +test('Certificate Requests Table Row', () => { + render() + expect(screen.getByText('1')).toBeDefined() +}) +// TODO: when certificate rejected => rejected status +// TODO: when certificate empty => outstanding status +// TODO: when certificate anything else => certificate.NotAfter \ No newline at end of file diff --git a/ui/src/app/certificate_requests/row.tsx b/ui/src/app/certificate_requests/row.tsx new file mode 100644 index 0000000..6692ea6 --- /dev/null +++ b/ui/src/app/certificate_requests/row.tsx @@ -0,0 +1,32 @@ +const extractCSR = (csrPemString: string) => { + //TODO +} + +const extractCert = (certPemString: string) => { + //TODO +} + +type rowProps = { + id: number, + csr: string, + certificate: string +} +export default function Row({ id, csr, certificate }: rowProps) { + return ( + + {id} + +

CN: example.com

+

SAN: example.com, 127.0.0.1, 1.2.3.4.5.56

+ + {certificate == "" ? "outstanding" : (certificate == "rejected" ? "rejected" : "certificate expiry date here")} + + + + + + + + + ) +} \ No newline at end of file diff --git a/ui/src/app/certificate_requests/table.tsx b/ui/src/app/certificate_requests/table.tsx new file mode 100644 index 0000000..74c4914 --- /dev/null +++ b/ui/src/app/certificate_requests/table.tsx @@ -0,0 +1,38 @@ +import { Dispatch, SetStateAction, useContext } from "react" +import { AsideContext } from "../nav" +import Row from "./row" + + +export function CertificateRequestsTable() { + const { isOpen: isAsideOpen, setIsOpen: setAsideIsOpen } = useContext(AsideContext) + return ( +
+
+

Certificate Requests

+
+ +
+
+
+
+ + + + + + + + + + + + + + + +
IDDetailsCSR StatusSign/RejectDelete
+
+
+
+ ) +} \ No newline at end of file diff --git a/ui/src/app/globals.scss b/ui/src/app/globals.scss new file mode 100644 index 0000000..4124068 --- /dev/null +++ b/ui/src/app/globals.scss @@ -0,0 +1,5 @@ +// Import the framework +@import 'node_modules/vanilla-framework'; + +// Include all of Vanilla Framework +@include vanilla; \ No newline at end of file diff --git a/ui/src/app/layout.tsx b/ui/src/app/layout.tsx index 86903b9..5ee9ea2 100644 --- a/ui/src/app/layout.tsx +++ b/ui/src/app/layout.tsx @@ -1,4 +1,7 @@ import type { Metadata } from "next"; +import './globals.scss' +import Navigation from "@/app/nav"; + export const metadata: Metadata = { title: "GoCert", @@ -12,7 +15,11 @@ export default function RootLayout({ }>) { return ( - {children} + + + {children} + + ); -} +} \ No newline at end of file diff --git a/ui/src/app/nav.test.tsx b/ui/src/app/nav.test.tsx new file mode 100644 index 0000000..08bb5de --- /dev/null +++ b/ui/src/app/nav.test.tsx @@ -0,0 +1,14 @@ +import { expect, describe, it } from "vitest"; +import {render, fireEvent, screen} from '@testing-library/react' +import Navigation from "./nav"; +import { CertificateRequestsTable } from "./certificate_requests/table"; + +describe('Navigation', () => { + it('should open aside when clicking button', () => { + render() + const addCSRButton = screen.getByLabelText(/add-csr-button/i) + expect(screen.getByLabelText(/aside-panel/i).className.endsWith('is-collapsed')).toBe(true) + fireEvent.click(addCSRButton) + expect(screen.getByLabelText(/aside-panel/i).className.endsWith('is-collapsed')).toBe(false) + }) +}); \ No newline at end of file diff --git a/ui/src/app/nav.tsx b/ui/src/app/nav.tsx new file mode 100644 index 0000000..ffa17cd --- /dev/null +++ b/ui/src/app/nav.tsx @@ -0,0 +1,113 @@ +"use client" + +import { SetStateAction, Dispatch, useState, createContext } from "react" + +type AsideContextType = { + isOpen: boolean, + setIsOpen: Dispatch> +} +export const AsideContext = createContext({ isOpen: false, setIsOpen: () => { } }); + +export function Aside({ isOpen, setIsOpen }: { isOpen: boolean, setIsOpen: Dispatch> }) { + return ( + + ) +} + +export function SideBar({ sidebarVisible, setSidebarVisible }: { sidebarVisible: boolean, setSidebarVisible: Dispatch> }) { + return ( +
+
+
+
+ +
+ + +
+
+ +
+
+
+ ) +} + +export function TopBar({ setSidebarVisible }: { setSidebarVisible: Dispatch> }) { + return ( +
+
+
+ +
+ { setSidebarVisible(true) }}>Menu +
+
+
+
+ ) +} + +export function Logo() { + return ( + + + GoCert + + ) +} +export default function Navigation({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const [sidebarVisible, setSidebarVisible] = useState(true) + const [asideOpen, setAsideOpen] = useState(false) + return ( +
+ + +
+ + {children} + +
+
+ ) +} \ No newline at end of file diff --git a/ui/src/app/page.test.tsx b/ui/src/app/page.test.tsx index e415d7c..0dac6e6 100644 --- a/ui/src/app/page.test.tsx +++ b/ui/src/app/page.test.tsx @@ -2,7 +2,7 @@ import { expect, test } from 'vitest' import { render, screen } from '@testing-library/react' import Page from './page' -test('Page', () => { +test('HomePage', () => { render() - expect(screen.getByRole('heading', { level: 1, name: 'Hello from the Frontend' })).toBeDefined() + expect(screen.getByText(/Welcome to GoCert/i)).toBeDefined() }) \ No newline at end of file diff --git a/ui/src/app/page.tsx b/ui/src/app/page.tsx index c29c5a8..4cdbd8c 100644 --- a/ui/src/app/page.tsx +++ b/ui/src/app/page.tsx @@ -1,7 +1,5 @@ -export default function Home() { +export default function Main() { return ( -
-

Hello from the Frontend

-
+
Welcome to GoCert
); } From c4d4da55f44313175c2fce455c62896b49190090 Mon Sep 17 00:00:00 2001 From: kayra1 Date: Mon, 3 Jun 2024 20:07:47 +0300 Subject: [PATCH 03/20] use effect --- ui/src/app/nav.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/ui/src/app/nav.tsx b/ui/src/app/nav.tsx index ffa17cd..d45806c 100644 --- a/ui/src/app/nav.tsx +++ b/ui/src/app/nav.tsx @@ -1,6 +1,6 @@ "use client" -import { SetStateAction, Dispatch, useState, createContext } from "react" +import { SetStateAction, Dispatch, useState, createContext, useEffect } from "react" type AsideContextType = { isOpen: boolean, @@ -35,6 +35,13 @@ export function Aside({ isOpen, setIsOpen }: { isOpen: boolean, setIsOpen: Dispa } export function SideBar({ sidebarVisible, setSidebarVisible }: { sidebarVisible: boolean, setSidebarVisible: Dispatch> }) { + const [activeTab, setActiveTab] = useState(""); + + useEffect(() => { + if (typeof window !== 'undefined') { + setActiveTab(location.pathname.split('/')[1]); + } + }, []); return (
@@ -51,7 +58,7 @@ export function SideBar({ sidebarVisible, setSidebarVisible }: { sidebarVisible: