mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
Implement M5: hardening, input validation, and Vite+React+TS dashboard
Backend hardening: - Fix 6 nginx.go non-constant format string build errors - Add validation.go with hostname, PEM, and enum validators - Apply input validation to all POST/PUT handlers (certificates, agents, CSR, policies, teams, owners, targets, issuers) - Fix unchecked JSON decode in TriggerDeployment handler Frontend (Vite + React + TypeScript): - Migrate from single-file SPA to proper build pipeline - 7 pages: Dashboard, Certificates (list+detail), Agents, Jobs, Notifications, Policies, Audit Trail - TanStack Query for server state with auto-refetch intervals - Certificate detail with version history and renewal trigger - Job cancellation, status/type filtering, expiry countdowns - Reusable components: DataTable, StatusBadge, ErrorState, PageHeader - Dark theme with Tailwind CSS, sidebar nav via React Router Server integration: - Go server serves web/dist/ (Vite output) with SPA fallback - Falls back to web/index.html for legacy mode - .gitignore updated for web/node_modules/ and web/dist/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -6,7 +6,10 @@
|
|||||||
*.so.*
|
*.so.*
|
||||||
*.dylib
|
*.dylib
|
||||||
bin/
|
bin/
|
||||||
dist/
|
|
||||||
|
# Frontend
|
||||||
|
web/node_modules/
|
||||||
|
web/dist/
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
# Test binary, built with `go test -c`
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
+15
-5
@@ -174,10 +174,15 @@ func main() {
|
|||||||
middleware.Recovery,
|
middleware.Recovery,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Wrap with dashboard static file serving if web/ directory exists
|
// Wrap with dashboard static file serving
|
||||||
|
// Vite builds to web/dist/; fall back to web/ for legacy single-file SPA
|
||||||
var finalHandler http.Handler
|
var finalHandler http.Handler
|
||||||
webDir := "./web"
|
webDir := "./web/dist"
|
||||||
if _, err := os.Stat(webDir); err == nil {
|
if _, err := os.Stat(webDir + "/index.html"); err != nil {
|
||||||
|
webDir = "./web"
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(webDir + "/index.html"); err == nil {
|
||||||
|
fileServer := http.FileServer(http.Dir(webDir))
|
||||||
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
finalHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
// API and health routes go to the API handler
|
// API and health routes go to the API handler
|
||||||
@@ -186,10 +191,15 @@ func main() {
|
|||||||
apiHandler.ServeHTTP(w, r)
|
apiHandler.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Serve the dashboard SPA index.html for everything else
|
// Try to serve static files (JS, CSS, assets)
|
||||||
|
if len(path) > 8 && path[:8] == "/assets/" {
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// SPA fallback: serve index.html for all other routes
|
||||||
http.ServeFile(w, r, webDir+"/index.html")
|
http.ServeFile(w, r, webDir+"/index.html")
|
||||||
})
|
})
|
||||||
logger.Info("dashboard available at /")
|
logger.Info("dashboard available at /", "web_dir", webDir)
|
||||||
} else {
|
} else {
|
||||||
finalHandler = apiHandler
|
finalHandler = apiHandler
|
||||||
logger.Info("dashboard directory not found, serving API only")
|
logger.Info("dashboard directory not found, serving API only")
|
||||||
|
|||||||
@@ -117,6 +117,20 @@ func (h AgentHandler) RegisterAgent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if err := ValidateRequired("name", agent.Name); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateStringLength("name", agent.Name, 128); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateRequired("hostname", agent.Hostname); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
created, err := h.svc.RegisterAgent(agent)
|
created, err := h.svc.RegisterAgent(agent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to register agent", requestID)
|
||||||
@@ -186,8 +200,9 @@ func (h AgentHandler) AgentCSRSubmit(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.CSRPEM == "" {
|
// Validate CSR PEM
|
||||||
ErrorWithRequestID(w, http.StatusBadRequest, "CSR PEM is required", requestID)
|
if err := ValidateCSRPEM(req.CSRPEM); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -305,6 +305,9 @@ func TestCreateCertificate_Success(t *testing.T) {
|
|||||||
certBody := domain.ManagedCertificate{
|
certBody := domain.ManagedCertificate{
|
||||||
Name: "Production Cert",
|
Name: "Production Cert",
|
||||||
CommonName: "example.com",
|
CommonName: "example.com",
|
||||||
|
OwnerID: "o-alice",
|
||||||
|
TeamID: "t-platform",
|
||||||
|
IssuerID: "iss-local",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(certBody)
|
body, _ := json.Marshal(certBody)
|
||||||
|
|
||||||
@@ -359,6 +362,9 @@ func TestCreateCertificate_ServiceError(t *testing.T) {
|
|||||||
certBody := domain.ManagedCertificate{
|
certBody := domain.ManagedCertificate{
|
||||||
Name: "Production Cert",
|
Name: "Production Cert",
|
||||||
CommonName: "example.com",
|
CommonName: "example.com",
|
||||||
|
OwnerID: "o-alice",
|
||||||
|
TeamID: "t-platform",
|
||||||
|
IssuerID: "iss-local",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(certBody)
|
body, _ := json.Marshal(certBody)
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,28 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if err := ValidateRequired("common_name", cert.CommonName); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateCommonName(cert.CommonName); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateRequired("owner_id", cert.OwnerID); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateRequired("team_id", cert.TeamID); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateRequired("issuer_id", cert.IssuerID); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
created, err := h.svc.CreateCertificate(cert)
|
created, err := h.svc.CreateCertificate(cert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
|
||||||
@@ -153,6 +175,26 @@ func (h CertificateHandler) UpdateCertificate(w http.ResponseWriter, r *http.Req
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields (if provided)
|
||||||
|
if cert.CommonName != "" {
|
||||||
|
if err := ValidateCommonName(cert.CommonName); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cert.OwnerID != "" {
|
||||||
|
if err := ValidateStringLength("owner_id", cert.OwnerID, 255); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cert.TeamID != "" {
|
||||||
|
if err := ValidateStringLength("team_id", cert.TeamID, 255); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updated, err := h.svc.UpdateCertificate(id, cert)
|
updated, err := h.svc.UpdateCertificate(id, cert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update certificate", requestID)
|
||||||
@@ -290,7 +332,11 @@ func (h CertificateHandler) TriggerDeployment(w http.ResponseWriter, r *http.Req
|
|||||||
TargetID string `json:"target_id,omitempty"`
|
TargetID string `json:"target_id,omitempty"`
|
||||||
}
|
}
|
||||||
if r.Header.Get("Content-Type") == "application/json" {
|
if r.Header.Get("Content-Type") == "application/json" {
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
// Log but don't fail - targetID is optional
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "Invalid request body", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.svc.TriggerDeployment(certID, req.TargetID); err != nil {
|
if err := h.svc.TriggerDeployment(certID, req.TargetID); err != nil {
|
||||||
|
|||||||
@@ -111,6 +111,20 @@ func (h IssuerHandler) CreateIssuer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if err := ValidateRequired("name", issuer.Name); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateStringLength("name", issuer.Name, 255); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if issuer.Type == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
created, err := h.svc.CreateIssuer(issuer)
|
created, err := h.svc.CreateIssuer(issuer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create issuer", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create issuer", requestID)
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ func (h OwnerHandler) CreateOwner(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if err := ValidateRequired("name", owner.Name); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateStringLength("name", owner.Name, 255); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
created, err := h.svc.CreateOwner(owner)
|
created, err := h.svc.CreateOwner(owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create owner", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create owner", requestID)
|
||||||
|
|||||||
@@ -113,6 +113,20 @@ func (h PolicyHandler) CreatePolicy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if err := ValidateRequired("name", policy.Name); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if policy.Type == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidatePolicyType(policy.Type); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
created, err := h.svc.CreatePolicy(policy)
|
created, err := h.svc.CreatePolicy(policy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create policy", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create policy", requestID)
|
||||||
@@ -146,6 +160,20 @@ func (h PolicyHandler) UpdatePolicy(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate fields if provided
|
||||||
|
if policy.Name != "" {
|
||||||
|
if err := ValidateStringLength("name", policy.Name, 255); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if policy.Type != "" {
|
||||||
|
if err := ValidatePolicyType(policy.Type); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updated, err := h.svc.UpdatePolicy(id, policy)
|
updated, err := h.svc.UpdatePolicy(id, policy)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update policy", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to update policy", requestID)
|
||||||
|
|||||||
@@ -110,6 +110,20 @@ func (h TargetHandler) CreateTarget(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if err := ValidateRequired("name", target.Name); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateStringLength("name", target.Name, 255); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if target.Type == "" {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, "type is required", requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
created, err := h.svc.CreateTarget(target)
|
created, err := h.svc.CreateTarget(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create target", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create target", requestID)
|
||||||
|
|||||||
@@ -112,6 +112,16 @@ func (h TeamHandler) CreateTeam(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if err := ValidateRequired("name", team.Name); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := ValidateStringLength("name", team.Name, 255); err != nil {
|
||||||
|
ErrorWithRequestID(w, http.StatusBadRequest, err.Error(), requestID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
created, err := h.svc.CreateTeam(team)
|
created, err := h.svc.CreateTeam(team)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create team", requestID)
|
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create team", requestID)
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ValidationError represents a validation error with field-level details.
|
||||||
|
type ValidationError struct {
|
||||||
|
Field string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCommonName validates a certificate common name.
|
||||||
|
func ValidateCommonName(cn string) error {
|
||||||
|
if cn == "" {
|
||||||
|
return ValidationError{Field: "common_name", Message: "common_name is required"}
|
||||||
|
}
|
||||||
|
if len(cn) > 253 {
|
||||||
|
return ValidationError{Field: "common_name", Message: "common_name must be 253 characters or fewer"}
|
||||||
|
}
|
||||||
|
// Basic hostname validation: allow alphanumeric, dots, hyphens
|
||||||
|
if err := isValidHostname(cn); err != nil {
|
||||||
|
return ValidationError{Field: "common_name", Message: fmt.Sprintf("invalid hostname format: %v", err)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRequired checks if a string field is present and non-empty.
|
||||||
|
func ValidateRequired(field, value string) error {
|
||||||
|
if value == "" {
|
||||||
|
return ValidationError{Field: field, Message: fmt.Sprintf("%s is required", field)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateStringLength checks if a string is within acceptable length bounds.
|
||||||
|
func ValidateStringLength(field, value string, maxLen int) error {
|
||||||
|
if len(value) > maxLen {
|
||||||
|
return ValidationError{Field: field, Message: fmt.Sprintf("%s must be %d characters or fewer", field, maxLen)}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCSRPEM validates a certificate signing request PEM block.
|
||||||
|
func ValidateCSRPEM(csrPEM string) error {
|
||||||
|
if csrPEM == "" {
|
||||||
|
return ValidationError{Field: "csr_pem", Message: "csr_pem is required"}
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(csrPEM), "-----BEGIN CERTIFICATE REQUEST-----") {
|
||||||
|
return ValidationError{Field: "csr_pem", Message: "csr_pem must be a valid PEM-encoded certificate request"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePolicyType checks if a policy rule type is valid.
|
||||||
|
func ValidatePolicyType(policyType interface{}) error {
|
||||||
|
validTypes := map[string]bool{
|
||||||
|
"AllowedIssuers": true,
|
||||||
|
"AllowedDomains": true,
|
||||||
|
"RequiredMetadata": true,
|
||||||
|
"AllowedEnvironments": true,
|
||||||
|
"RenewalLeadTime": true,
|
||||||
|
}
|
||||||
|
typeStr := fmt.Sprintf("%v", policyType)
|
||||||
|
if !validTypes[typeStr] {
|
||||||
|
return ValidationError{Field: "type", Message: "type must be one of: AllowedIssuers, AllowedDomains, RequiredMetadata, AllowedEnvironments, RenewalLeadTime"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePolicySeverity checks if a severity level is valid.
|
||||||
|
func ValidatePolicySeverity(severity interface{}) error {
|
||||||
|
validSeverities := map[string]bool{
|
||||||
|
"Warning": true,
|
||||||
|
"Error": true,
|
||||||
|
"Critical": true,
|
||||||
|
}
|
||||||
|
sevStr := fmt.Sprintf("%v", severity)
|
||||||
|
if !validSeverities[sevStr] {
|
||||||
|
return ValidationError{Field: "severity", Message: "severity must be one of: Warning, Error, Critical"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidHostname performs basic validation on a hostname.
|
||||||
|
func isValidHostname(hostname string) error {
|
||||||
|
// Use net.SplitHostPort-compatible check
|
||||||
|
// Hostname can be an IP or domain name
|
||||||
|
if ip := net.ParseIP(hostname); ip != nil {
|
||||||
|
return nil // Valid IP address
|
||||||
|
}
|
||||||
|
|
||||||
|
// For domain names, check basic format
|
||||||
|
if len(hostname) == 0 || len(hostname) > 253 {
|
||||||
|
return fmt.Errorf("hostname length invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for invalid characters (very basic)
|
||||||
|
for _, char := range hostname {
|
||||||
|
if !isValidHostnameChar(char) {
|
||||||
|
return fmt.Errorf("hostname contains invalid character: %c", char)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Labels must not start or end with hyphen
|
||||||
|
labels := strings.Split(hostname, ".")
|
||||||
|
for _, label := range labels {
|
||||||
|
if len(label) == 0 {
|
||||||
|
return fmt.Errorf("hostname has empty label")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") {
|
||||||
|
return fmt.Errorf("hostname labels cannot start or end with hyphen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidHostnameChar checks if a character is valid in a hostname.
|
||||||
|
func isValidHostnameChar(r rune) bool {
|
||||||
|
return (r >= 'a' && r <= 'z') ||
|
||||||
|
(r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') ||
|
||||||
|
r == '.' ||
|
||||||
|
r == '-' ||
|
||||||
|
r == '_' || // Underscores are sometimes allowed
|
||||||
|
r == '*' // Wildcard support
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error method makes ValidationError satisfy the error interface.
|
||||||
|
func (e ValidationError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
|||||||
TargetAddress: c.config.CertPath,
|
TargetAddress: c.config.CertPath,
|
||||||
Message: errMsg,
|
Message: errMsg,
|
||||||
DeployedAt: time.Now(),
|
DeployedAt: time.Now(),
|
||||||
}, fmt.Errorf(errMsg)
|
}, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write chain with same permissions
|
// Write chain with same permissions
|
||||||
@@ -114,7 +114,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
|||||||
TargetAddress: c.config.ChainPath,
|
TargetAddress: c.config.ChainPath,
|
||||||
Message: errMsg,
|
Message: errMsg,
|
||||||
DeployedAt: time.Now(),
|
DeployedAt: time.Now(),
|
||||||
}, fmt.Errorf(errMsg)
|
}, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate NGINX configuration before reload
|
// Validate NGINX configuration before reload
|
||||||
@@ -128,7 +128,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
|||||||
TargetAddress: c.config.CertPath,
|
TargetAddress: c.config.CertPath,
|
||||||
Message: errMsg,
|
Message: errMsg,
|
||||||
DeployedAt: time.Now(),
|
DeployedAt: time.Now(),
|
||||||
}, fmt.Errorf(errMsg)
|
}, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload NGINX
|
// Reload NGINX
|
||||||
@@ -142,7 +142,7 @@ func (c *Connector) DeployCertificate(ctx context.Context, request target.Deploy
|
|||||||
TargetAddress: c.config.CertPath,
|
TargetAddress: c.config.CertPath,
|
||||||
Message: errMsg,
|
Message: errMsg,
|
||||||
DeployedAt: time.Now(),
|
DeployedAt: time.Now(),
|
||||||
}, fmt.Errorf(errMsg)
|
}, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
deploymentDuration := time.Since(startTime)
|
deploymentDuration := time.Since(startTime)
|
||||||
@@ -188,7 +188,7 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
|||||||
TargetAddress: c.config.CertPath,
|
TargetAddress: c.config.CertPath,
|
||||||
Message: errMsg,
|
Message: errMsg,
|
||||||
ValidatedAt: time.Now(),
|
ValidatedAt: time.Now(),
|
||||||
}, fmt.Errorf(errMsg)
|
}, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify certificate file exists and is readable
|
// Verify certificate file exists and is readable
|
||||||
@@ -201,7 +201,7 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
|||||||
TargetAddress: c.config.CertPath,
|
TargetAddress: c.config.CertPath,
|
||||||
Message: errMsg,
|
Message: errMsg,
|
||||||
ValidatedAt: time.Now(),
|
ValidatedAt: time.Now(),
|
||||||
}, fmt.Errorf(errMsg)
|
}, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
validationDuration := time.Since(startTime)
|
validationDuration := time.Since(startTime)
|
||||||
|
|||||||
+7
-1863
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Generated
+2147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "certctl-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^6.30.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"autoprefixer": "^10.4.27",
|
||||||
|
"postcss": "^8.5.8",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^8.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, Issuer, Target, PaginatedResponse } from './types';
|
||||||
|
|
||||||
|
const BASE = '/api/v1';
|
||||||
|
|
||||||
|
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({ message: res.statusText }));
|
||||||
|
throw new Error(body.message || body.error || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificates
|
||||||
|
export const getCertificates = (params: Record<string, string> = {}) => {
|
||||||
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
return fetchJSON<PaginatedResponse<Certificate>>(`${BASE}/certificates?${qs}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCertificate = (id: string) =>
|
||||||
|
fetchJSON<Certificate>(`${BASE}/certificates/${id}`);
|
||||||
|
|
||||||
|
export const getCertificateVersions = (id: string) =>
|
||||||
|
fetchJSON<PaginatedResponse<CertificateVersion>>(`${BASE}/certificates/${id}/versions`);
|
||||||
|
|
||||||
|
export const createCertificate = (data: Partial<Certificate>) =>
|
||||||
|
fetchJSON<Certificate>(`${BASE}/certificates`, { method: 'POST', body: JSON.stringify(data) });
|
||||||
|
|
||||||
|
export const triggerRenewal = (id: string) =>
|
||||||
|
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/renew`, { method: 'POST' });
|
||||||
|
|
||||||
|
export const triggerDeployment = (id: string, targetId: string) =>
|
||||||
|
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/deploy`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ target_id: targetId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Agents
|
||||||
|
export const getAgents = (params: Record<string, string> = {}) => {
|
||||||
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
return fetchJSON<PaginatedResponse<Agent>>(`${BASE}/agents?${qs}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAgent = (id: string) =>
|
||||||
|
fetchJSON<Agent>(`${BASE}/agents/${id}`);
|
||||||
|
|
||||||
|
// Jobs
|
||||||
|
export const getJobs = (params: Record<string, string> = {}) => {
|
||||||
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
return fetchJSON<PaginatedResponse<Job>>(`${BASE}/jobs?${qs}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cancelJob = (id: string) =>
|
||||||
|
fetchJSON<{ message: string }>(`${BASE}/jobs/${id}/cancel`, { method: 'POST' });
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
export const getNotifications = (params: Record<string, string> = {}) => {
|
||||||
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
return fetchJSON<PaginatedResponse<Notification>>(`${BASE}/notifications?${qs}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Audit
|
||||||
|
export const getAuditEvents = (params: Record<string, string> = {}) => {
|
||||||
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
return fetchJSON<PaginatedResponse<AuditEvent>>(`${BASE}/audit?${qs}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Policies
|
||||||
|
export const getPolicies = (params: Record<string, string> = {}) => {
|
||||||
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
return fetchJSON<PaginatedResponse<PolicyRule>>(`${BASE}/policies?${qs}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Issuers
|
||||||
|
export const getIssuers = (params: Record<string, string> = {}) => {
|
||||||
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
return fetchJSON<PaginatedResponse<Issuer>>(`${BASE}/issuers?${qs}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Targets
|
||||||
|
export const getTargets = (params: Record<string, string> = {}) => {
|
||||||
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
return fetchJSON<PaginatedResponse<Target>>(`${BASE}/targets?${qs}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Health
|
||||||
|
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
export interface Certificate {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
common_name: string;
|
||||||
|
sans: string[];
|
||||||
|
status: string;
|
||||||
|
environment: string;
|
||||||
|
issuer_id: string;
|
||||||
|
owner_id: string;
|
||||||
|
team_id: string;
|
||||||
|
renewal_policy_id: string;
|
||||||
|
serial_number: string;
|
||||||
|
fingerprint: string;
|
||||||
|
key_algorithm: string;
|
||||||
|
key_size: number;
|
||||||
|
issued_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
tags: Record<string, string>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertificateVersion {
|
||||||
|
id: string;
|
||||||
|
certificate_id: string;
|
||||||
|
version: number;
|
||||||
|
serial_number: string;
|
||||||
|
fingerprint: string;
|
||||||
|
cert_pem: string;
|
||||||
|
chain_pem: string;
|
||||||
|
csr_pem: string;
|
||||||
|
not_before: string;
|
||||||
|
not_after: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Agent {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
hostname: string;
|
||||||
|
ip_address: string;
|
||||||
|
status: string;
|
||||||
|
version: string;
|
||||||
|
last_heartbeat: string;
|
||||||
|
capabilities: string[];
|
||||||
|
tags: Record<string, string>;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: string;
|
||||||
|
certificate_id: string;
|
||||||
|
type: string;
|
||||||
|
status: string;
|
||||||
|
attempts: number;
|
||||||
|
max_attempts: number;
|
||||||
|
error_message: string;
|
||||||
|
scheduled_at: string;
|
||||||
|
started_at: string;
|
||||||
|
completed_at: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
channel: string;
|
||||||
|
recipient: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
status: string;
|
||||||
|
certificate_id: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditEvent {
|
||||||
|
id: string;
|
||||||
|
actor: string;
|
||||||
|
actor_type: string;
|
||||||
|
action: string;
|
||||||
|
resource_type: string;
|
||||||
|
resource_id: string;
|
||||||
|
details: Record<string, unknown>;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyRule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
severity: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PolicyViolation {
|
||||||
|
id: string;
|
||||||
|
rule_id: string;
|
||||||
|
certificate_id: string;
|
||||||
|
severity: string;
|
||||||
|
message: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Issuer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Target {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
hostname: string;
|
||||||
|
agent_id: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
per_page: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export function formatDate(iso: string): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(iso: string): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeAgo(iso: string): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
const now = Date.now();
|
||||||
|
const then = new Date(iso).getTime();
|
||||||
|
const diff = now - then;
|
||||||
|
const mins = Math.floor(diff / 60000);
|
||||||
|
if (mins < 1) return 'just now';
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
return formatDate(iso);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysUntil(iso: string): number {
|
||||||
|
if (!iso) return 0;
|
||||||
|
return Math.ceil((new Date(iso).getTime() - Date.now()) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expiryColor(days: number): string {
|
||||||
|
if (days <= 0) return 'text-red-400';
|
||||||
|
if (days <= 7) return 'text-red-400';
|
||||||
|
if (days <= 14) return 'text-amber-400';
|
||||||
|
if (days <= 30) return 'text-amber-300';
|
||||||
|
return 'text-emerald-400';
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
interface Column<T> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
render: (item: T) => React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
columns: Column<T>[];
|
||||||
|
data: T[];
|
||||||
|
onRowClick?: (item: T) => void;
|
||||||
|
emptyMessage?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading }: DataTableProps<T>) {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16 text-slate-400">
|
||||||
|
<svg className="animate-spin h-5 w-5 mr-3" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
|
</svg>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.length) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16 text-slate-500">
|
||||||
|
{emptyMessage || 'No data found'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b-2 border-slate-700">
|
||||||
|
{columns.map(col => (
|
||||||
|
<th key={col.key} className={`px-4 py-3 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider ${col.className || ''}`}>
|
||||||
|
{col.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((item, i) => (
|
||||||
|
<tr
|
||||||
|
key={i}
|
||||||
|
onClick={() => onRowClick?.(item)}
|
||||||
|
className={`border-b border-slate-700/50 transition-colors hover:bg-blue-500/5 ${onRowClick ? 'cursor-pointer' : ''}`}
|
||||||
|
>
|
||||||
|
{columns.map(col => (
|
||||||
|
<td key={col.key} className={`px-4 py-3 ${col.className || ''}`}>
|
||||||
|
{col.render(item)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Column };
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
interface ErrorStateProps {
|
||||||
|
error: Error;
|
||||||
|
onRetry?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ErrorState({ error, onRetry }: ErrorStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
||||||
|
<svg className="w-12 h-12 text-red-400 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm mb-2">Failed to load data</p>
|
||||||
|
<p className="text-xs text-slate-500 mb-4">{error.message}</p>
|
||||||
|
{onRetry && (
|
||||||
|
<button onClick={onRetry} className="btn btn-primary text-xs">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ to: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' },
|
||||||
|
{ to: '/certificates', label: 'Certificates', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
|
||||||
|
{ to: '/agents', label: 'Agents', icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2' },
|
||||||
|
{ to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
|
||||||
|
{ to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
||||||
|
{ to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
|
||||||
|
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Icon({ d }: { d: string }) {
|
||||||
|
return (
|
||||||
|
<svg className="w-5 h-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-slate-800 border-r border-slate-700 flex flex-col">
|
||||||
|
<div className="p-6 border-b border-slate-700">
|
||||||
|
<h1 className="text-xl font-bold text-blue-400">certctl</h1>
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-wider mt-1">Certificate Control Plane</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||||
|
{nav.map(item => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-slate-400 hover:bg-slate-700 hover:text-slate-200'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon d={item.icon} />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="p-4 border-t border-slate-700 text-xs text-slate-500">
|
||||||
|
certctl v1.0-dev
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-700 bg-slate-800">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
{subtitle && <p className="text-sm text-slate-400 mt-0.5">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const statusStyles: Record<string, string> = {
|
||||||
|
Active: 'badge-success',
|
||||||
|
Expiring: 'badge-warning',
|
||||||
|
Expired: 'badge-danger',
|
||||||
|
RenewalInProgress: 'badge-info',
|
||||||
|
PendingIssuance: 'badge-info',
|
||||||
|
Archived: 'badge-neutral',
|
||||||
|
Revoked: 'badge-danger',
|
||||||
|
// Job statuses
|
||||||
|
Pending: 'badge-info',
|
||||||
|
Running: 'badge-warning',
|
||||||
|
Completed: 'badge-success',
|
||||||
|
Failed: 'badge-danger',
|
||||||
|
Cancelled: 'badge-neutral',
|
||||||
|
// Agent statuses
|
||||||
|
Online: 'badge-success',
|
||||||
|
Offline: 'badge-danger',
|
||||||
|
Stale: 'badge-warning',
|
||||||
|
// Notification statuses
|
||||||
|
sent: 'badge-success',
|
||||||
|
pending: 'badge-warning',
|
||||||
|
failed: 'badge-danger',
|
||||||
|
read: 'badge-neutral',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }: { status: string }) {
|
||||||
|
const cls = statusStyles[status] || 'badge-neutral';
|
||||||
|
return <span className={`badge ${cls}`}>{status}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
body {
|
||||||
|
@apply bg-slate-900 text-slate-100 antialiased;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
.badge-success { @apply bg-emerald-500/10 text-emerald-400 border border-emerald-500/20; }
|
||||||
|
.badge-warning { @apply bg-amber-500/10 text-amber-400 border border-amber-500/20; }
|
||||||
|
.badge-danger { @apply bg-red-500/10 text-red-400 border border-red-500/20; }
|
||||||
|
.badge-info { @apply bg-blue-500/10 text-blue-400 border border-blue-500/20; }
|
||||||
|
.badge-neutral { @apply bg-slate-500/10 text-slate-400 border border-slate-500/20; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-slate-800 border border-slate-700 rounded-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors;
|
||||||
|
}
|
||||||
|
.btn-primary { @apply bg-blue-600 hover:bg-blue-500 text-white; }
|
||||||
|
.btn-ghost { @apply hover:bg-slate-700 text-slate-300; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import DashboardPage from './pages/DashboardPage';
|
||||||
|
import CertificatesPage from './pages/CertificatesPage';
|
||||||
|
import CertificateDetailPage from './pages/CertificateDetailPage';
|
||||||
|
import AgentsPage from './pages/AgentsPage';
|
||||||
|
import JobsPage from './pages/JobsPage';
|
||||||
|
import NotificationsPage from './pages/NotificationsPage';
|
||||||
|
import PoliciesPage from './pages/PoliciesPage';
|
||||||
|
import AuditPage from './pages/AuditPage';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 10_000,
|
||||||
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="certificates" element={<CertificatesPage />} />
|
||||||
|
<Route path="certificates/:id" element={<CertificateDetailPage />} />
|
||||||
|
<Route path="agents" element={<AgentsPage />} />
|
||||||
|
<Route path="jobs" element={<JobsPage />} />
|
||||||
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
|
<Route path="policies" element={<PoliciesPage />} />
|
||||||
|
<Route path="audit" element={<AuditPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getAgents } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
import type { Column } from '../components/DataTable';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { timeAgo } from '../api/utils';
|
||||||
|
import type { Agent } from '../api/types';
|
||||||
|
|
||||||
|
function heartbeatStatus(lastHeartbeat: string): string {
|
||||||
|
if (!lastHeartbeat) return 'Offline';
|
||||||
|
const ago = Date.now() - new Date(lastHeartbeat).getTime();
|
||||||
|
if (ago < 5 * 60 * 1000) return 'Online';
|
||||||
|
if (ago < 15 * 60 * 1000) return 'Stale';
|
||||||
|
return 'Offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentsPage() {
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['agents'],
|
||||||
|
queryFn: () => getAgents(),
|
||||||
|
refetchInterval: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<Agent>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Agent',
|
||||||
|
render: (a) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-200">{a.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{a.id}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: 'Health',
|
||||||
|
render: (a) => <StatusBadge status={a.status || heartbeatStatus(a.last_heartbeat)} />,
|
||||||
|
},
|
||||||
|
{ key: 'hostname', label: 'Hostname', render: (a) => <span className="text-slate-300 font-mono text-xs">{a.hostname || '—'}</span> },
|
||||||
|
{ key: 'ip', label: 'IP Address', render: (a) => <span className="text-slate-400 font-mono text-xs">{a.ip_address || '—'}</span> },
|
||||||
|
{ key: 'version', label: 'Version', render: (a) => <span className="text-slate-400 text-xs">{a.version || '—'}</span> },
|
||||||
|
{
|
||||||
|
key: 'heartbeat',
|
||||||
|
label: 'Last Heartbeat',
|
||||||
|
render: (a) => <span className="text-slate-400 text-xs">{timeAgo(a.last_heartbeat)}</span>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Agents" subtitle={data ? `${data.total} agents` : undefined} />
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{error ? (
|
||||||
|
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No agents registered" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getAuditEvents } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
import type { Column } from '../components/DataTable';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { formatDateTime } from '../api/utils';
|
||||||
|
import type { AuditEvent } from '../api/types';
|
||||||
|
|
||||||
|
const actionColors: Record<string, string> = {
|
||||||
|
certificate_created: 'text-emerald-400',
|
||||||
|
renewal_triggered: 'text-blue-400',
|
||||||
|
renewal_job_created: 'text-blue-400',
|
||||||
|
renewal_completed: 'text-emerald-400',
|
||||||
|
deployment_completed: 'text-emerald-400',
|
||||||
|
deployment_failed: 'text-red-400',
|
||||||
|
expiration_alert_sent: 'text-amber-400',
|
||||||
|
agent_registered: 'text-blue-400',
|
||||||
|
policy_violated: 'text-red-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuditPage() {
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['audit'],
|
||||||
|
queryFn: () => getAuditEvents(),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<AuditEvent>[] = [
|
||||||
|
{
|
||||||
|
key: 'action',
|
||||||
|
label: 'Action',
|
||||||
|
render: (e) => (
|
||||||
|
<span className={`text-sm font-medium ${actionColors[e.action] || 'text-slate-300'}`}>
|
||||||
|
{e.action.replace(/_/g, ' ')}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actor',
|
||||||
|
label: 'Actor',
|
||||||
|
render: (e) => (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-slate-200">{e.actor}</div>
|
||||||
|
<div className="text-xs text-slate-500">{e.actor_type}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'resource',
|
||||||
|
label: 'Resource',
|
||||||
|
render: (e) => (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-slate-300">{e.resource_type}</div>
|
||||||
|
<div className="text-xs text-slate-500 font-mono">{e.resource_id}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'details',
|
||||||
|
label: 'Details',
|
||||||
|
render: (e) => {
|
||||||
|
if (!e.details || Object.keys(e.details).length === 0) return <span className="text-slate-500">—</span>;
|
||||||
|
return (
|
||||||
|
<span className="text-xs text-slate-400 font-mono truncate max-w-xs block">
|
||||||
|
{JSON.stringify(e.details).slice(0, 60)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'time', label: 'Time', render: (e) => <span className="text-xs text-slate-400">{formatDateTime(e.timestamp)}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Audit Trail" subtitle={data ? `${data.total} events` : undefined} />
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{error ? (
|
||||||
|
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No audit events" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getCertificate, getCertificateVersions, triggerRenewal } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { formatDate, formatDateTime, daysUntil, expiryColor } from '../api/utils';
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-between py-2 border-b border-slate-700/50">
|
||||||
|
<span className="text-sm text-slate-400">{label}</span>
|
||||||
|
<span className="text-sm text-slate-200">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CertificateDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: cert, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['certificate', id],
|
||||||
|
queryFn: () => getCertificate(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: versions } = useQuery({
|
||||||
|
queryKey: ['certificate-versions', id],
|
||||||
|
queryFn: () => getCertificateVersions(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const renewMutation = useMutation({
|
||||||
|
mutationFn: () => triggerRenewal(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['certificate', id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Certificate" />
|
||||||
|
<div className="flex items-center justify-center flex-1 text-slate-400">Loading...</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !cert) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Certificate" />
|
||||||
|
<ErrorState error={error as Error || new Error('Not found')} onRetry={() => refetch()} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = daysUntil(cert.expires_at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title={cert.common_name}
|
||||||
|
subtitle={cert.id}
|
||||||
|
action={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => navigate('/certificates')} className="btn btn-ghost text-xs">
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => renewMutation.mutate()}
|
||||||
|
disabled={renewMutation.isPending || cert.status === 'Archived' || cert.status === 'RenewalInProgress'}
|
||||||
|
className="btn btn-primary text-xs disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{renewMutation.isPending ? 'Renewing...' : 'Trigger Renewal'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
{renewMutation.isSuccess && (
|
||||||
|
<div className="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg px-4 py-3 text-sm">
|
||||||
|
Renewal triggered successfully. A renewal job has been created.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{renewMutation.isError && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
|
||||||
|
Failed to trigger renewal: {(renewMutation.error as Error).message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Certificate Info */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-300 mb-4">Certificate Details</h3>
|
||||||
|
<InfoRow label="Status" value={<StatusBadge status={cert.status} />} />
|
||||||
|
<InfoRow label="Common Name" value={cert.common_name} />
|
||||||
|
<InfoRow label="SANs" value={cert.sans?.length ? cert.sans.join(', ') : '—'} />
|
||||||
|
<InfoRow label="Serial Number" value={cert.serial_number || '—'} />
|
||||||
|
<InfoRow label="Fingerprint" value={
|
||||||
|
cert.fingerprint ? <span className="font-mono text-xs">{cert.fingerprint.slice(0, 24)}...</span> : '—'
|
||||||
|
} />
|
||||||
|
<InfoRow label="Key Algorithm" value={cert.key_algorithm || '—'} />
|
||||||
|
<InfoRow label="Key Size" value={cert.key_size ? `${cert.key_size} bits` : '—'} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lifecycle */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-300 mb-4">Lifecycle</h3>
|
||||||
|
<InfoRow label="Issued" value={formatDate(cert.issued_at)} />
|
||||||
|
<InfoRow label="Expires" value={
|
||||||
|
<span className={expiryColor(days)}>
|
||||||
|
{formatDate(cert.expires_at)} ({days <= 0 ? 'expired' : `${days} days`})
|
||||||
|
</span>
|
||||||
|
} />
|
||||||
|
<InfoRow label="Environment" value={cert.environment || '—'} />
|
||||||
|
<InfoRow label="Issuer" value={cert.issuer_id} />
|
||||||
|
<InfoRow label="Renewal Policy" value={cert.renewal_policy_id || '—'} />
|
||||||
|
<InfoRow label="Owner" value={cert.owner_id} />
|
||||||
|
<InfoRow label="Team" value={cert.team_id} />
|
||||||
|
<InfoRow label="Created" value={formatDateTime(cert.created_at)} />
|
||||||
|
<InfoRow label="Updated" value={formatDateTime(cert.updated_at)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
{cert.tags && Object.keys(cert.tags).length > 0 && (
|
||||||
|
<div className="card p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-300 mb-4">Tags</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(cert.tags).map(([k, v]) => (
|
||||||
|
<span key={k} className="badge badge-neutral">{k}: {v}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Version History */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-300 mb-4">
|
||||||
|
Version History {versions?.data?.length ? `(${versions.data.length})` : ''}
|
||||||
|
</h3>
|
||||||
|
{!versions?.data?.length ? (
|
||||||
|
<p className="text-sm text-slate-500">No versions yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{versions.data.map((v) => (
|
||||||
|
<div key={v.id} className="flex items-center justify-between py-2 border-b border-slate-700/50 last:border-0">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-slate-200">Version {v.version}</div>
|
||||||
|
<div className="text-xs text-slate-500 font-mono">{v.serial_number}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm text-slate-300">{formatDate(v.not_before)} — {formatDate(v.not_after)}</div>
|
||||||
|
<div className="text-xs text-slate-500">{formatDateTime(v.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { getCertificates } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
import type { Column } from '../components/DataTable';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { formatDate, daysUntil, expiryColor } from '../api/utils';
|
||||||
|
import type { Certificate } from '../api/types';
|
||||||
|
|
||||||
|
export default function CertificatesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [envFilter, setEnvFilter] = useState('');
|
||||||
|
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (statusFilter) params.status = statusFilter;
|
||||||
|
if (envFilter) params.environment = envFilter;
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['certificates', params],
|
||||||
|
queryFn: () => getCertificates(params),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<Certificate>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Certificate',
|
||||||
|
render: (c) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-200">{c.common_name}</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">{c.id}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'status', label: 'Status', render: (c) => <StatusBadge status={c.status} /> },
|
||||||
|
{
|
||||||
|
key: 'expires',
|
||||||
|
label: 'Expires',
|
||||||
|
render: (c) => {
|
||||||
|
const days = daysUntil(c.expires_at);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={expiryColor(days)}>{formatDate(c.expires_at)}</div>
|
||||||
|
<div className="text-xs text-slate-500">{days <= 0 ? 'Expired' : `${days} days`}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ key: 'env', label: 'Environment', render: (c) => <span className="text-slate-300">{c.environment || '—'}</span> },
|
||||||
|
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-slate-400 text-xs">{c.issuer_id}</span> },
|
||||||
|
{ key: 'owner', label: 'Owner', render: (c) => <span className="text-slate-400 text-xs">{c.owner_id}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Certificates"
|
||||||
|
subtitle={data ? `${data.total} certificates` : undefined}
|
||||||
|
/>
|
||||||
|
<div className="px-6 py-3 flex gap-3 border-b border-slate-700/50">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
|
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="Active">Active</option>
|
||||||
|
<option value="Expiring">Expiring</option>
|
||||||
|
<option value="Expired">Expired</option>
|
||||||
|
<option value="RenewalInProgress">Renewal In Progress</option>
|
||||||
|
<option value="Archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={envFilter}
|
||||||
|
onChange={e => setEnvFilter(e.target.value)}
|
||||||
|
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
||||||
|
>
|
||||||
|
<option value="">All environments</option>
|
||||||
|
<option value="production">Production</option>
|
||||||
|
<option value="staging">Staging</option>
|
||||||
|
<option value="development">Development</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{error ? (
|
||||||
|
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={data?.data || []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onRowClick={(c) => navigate(`/certificates/${c.id}`)}
|
||||||
|
emptyMessage="No certificates found"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { getCertificates, getAgents, getJobs, getNotifications, getHealth } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import { daysUntil, expiryColor, formatDate } from '../api/utils';
|
||||||
|
|
||||||
|
function StatCard({ label, value, icon, color }: { label: string; value: string | number; icon: string; color: string }) {
|
||||||
|
const colorMap: Record<string, string> = {
|
||||||
|
success: 'bg-emerald-500/10 text-emerald-400',
|
||||||
|
warning: 'bg-amber-500/10 text-amber-400',
|
||||||
|
danger: 'bg-red-500/10 text-red-400',
|
||||||
|
info: 'bg-blue-500/10 text-blue-400',
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="card p-5 flex items-start gap-4 hover:border-blue-500/30 transition-colors">
|
||||||
|
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${colorMap[color] || colorMap.info}`}>
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d={icon} />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-slate-400 uppercase tracking-wider">{label}</p>
|
||||||
|
<p className="text-2xl font-bold mt-1">{value}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 });
|
||||||
|
const { data: certs } = useQuery({ queryKey: ['certificates', {}], queryFn: () => getCertificates(), refetchInterval: 30000 });
|
||||||
|
const { data: agents } = useQuery({ queryKey: ['agents'], queryFn: () => getAgents(), refetchInterval: 15000 });
|
||||||
|
const { data: jobs } = useQuery({ queryKey: ['jobs', {}], queryFn: () => getJobs(), refetchInterval: 10000 });
|
||||||
|
const { data: notifs } = useQuery({ queryKey: ['notifications'], queryFn: () => getNotifications() });
|
||||||
|
|
||||||
|
const totalCerts = certs?.total || 0;
|
||||||
|
const expiringSoon = certs?.data?.filter(c => {
|
||||||
|
const d = daysUntil(c.expires_at);
|
||||||
|
return d > 0 && d <= 30;
|
||||||
|
}).length || 0;
|
||||||
|
const expired = certs?.data?.filter(c => c.status === 'Expired' || daysUntil(c.expires_at) <= 0).length || 0;
|
||||||
|
const activeAgents = agents?.data?.filter(a => a.status === 'Online').length || agents?.total || 0;
|
||||||
|
const pendingJobs = jobs?.data?.filter(j => j.status === 'Pending' || j.status === 'Running').length || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Dashboard"
|
||||||
|
subtitle={health?.status === 'healthy' ? 'System healthy' : 'Checking system status...'}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard label="Total Certificates" value={totalCerts} color="info"
|
||||||
|
icon="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||||
|
<StatCard label="Expiring Soon" value={expiringSoon} color={expiringSoon > 0 ? 'warning' : 'success'}
|
||||||
|
icon="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
<StatCard label="Expired" value={expired} color={expired > 0 ? 'danger' : 'success'}
|
||||||
|
icon="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
<StatCard label="Active Agents" value={activeAgents} color="success"
|
||||||
|
icon="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Expiring Certificates */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-300">Certificates Expiring Soon</h3>
|
||||||
|
<button onClick={() => navigate('/certificates')} className="text-xs text-blue-400 hover:text-blue-300">View all</button>
|
||||||
|
</div>
|
||||||
|
{!certs?.data?.length ? (
|
||||||
|
<p className="text-sm text-slate-500">No certificates</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{certs.data
|
||||||
|
.filter(c => c.status !== 'Archived')
|
||||||
|
.sort((a, b) => new Date(a.expires_at).getTime() - new Date(b.expires_at).getTime())
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(c => {
|
||||||
|
const days = daysUntil(c.expires_at);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.id}
|
||||||
|
onClick={() => navigate(`/certificates/${c.id}`)}
|
||||||
|
className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-700/50 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-slate-200">{c.common_name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{c.environment || 'no env'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={`text-sm ${expiryColor(days)}`}>
|
||||||
|
{days <= 0 ? 'Expired' : `${days} days`}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">{formatDate(c.expires_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Jobs */}
|
||||||
|
<div className="card p-5">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-slate-300">Recent Jobs</h3>
|
||||||
|
<button onClick={() => navigate('/jobs')} className="text-xs text-blue-400 hover:text-blue-300">View all</button>
|
||||||
|
</div>
|
||||||
|
{!jobs?.data?.length ? (
|
||||||
|
<p className="text-sm text-slate-500">No jobs</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{jobs.data.slice(0, 5).map(j => (
|
||||||
|
<div key={j.id} className="flex items-center justify-between py-2 px-3 rounded-lg hover:bg-slate-700/50 transition-colors">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-slate-200">{j.type}</div>
|
||||||
|
<div className="text-xs text-slate-500 font-mono">{j.certificate_id}</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={j.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pending Jobs Banner */}
|
||||||
|
{pendingJobs > 0 && (
|
||||||
|
<div className="bg-blue-500/10 border border-blue-500/20 rounded-lg px-5 py-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-blue-400">{pendingJobs} pending job{pendingJobs > 1 ? 's' : ''}</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Jobs are waiting to be processed</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => navigate('/jobs')} className="btn btn-primary text-xs">View Jobs</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { getJobs, cancelJob } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
import type { Column } from '../components/DataTable';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { formatDateTime } from '../api/utils';
|
||||||
|
import type { Job } from '../api/types';
|
||||||
|
|
||||||
|
export default function JobsPage() {
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
if (statusFilter) params.status = statusFilter;
|
||||||
|
if (typeFilter) params.type = typeFilter;
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['jobs', params],
|
||||||
|
queryFn: () => getJobs(params),
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cancelMutation = useMutation({
|
||||||
|
mutationFn: cancelJob,
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<Job>[] = [
|
||||||
|
{
|
||||||
|
key: 'id',
|
||||||
|
label: 'Job',
|
||||||
|
render: (j) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-mono text-xs text-slate-200">{j.id}</div>
|
||||||
|
<div className="text-xs text-slate-500">{j.type}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'status', label: 'Status', render: (j) => <StatusBadge status={j.status} /> },
|
||||||
|
{ key: 'cert', label: 'Certificate', render: (j) => <span className="text-xs text-slate-400 font-mono">{j.certificate_id}</span> },
|
||||||
|
{
|
||||||
|
key: 'attempts',
|
||||||
|
label: 'Attempts',
|
||||||
|
render: (j) => <span className="text-slate-300">{j.attempts}/{j.max_attempts}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-slate-400">{formatDateTime(j.scheduled_at)}</span> },
|
||||||
|
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-slate-400">{formatDateTime(j.completed_at)}</span> },
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
label: '',
|
||||||
|
render: (j) => (
|
||||||
|
j.status === 'Pending' || j.status === 'Running' ? (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); cancelMutation.mutate(j.id); }}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
) : null
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Jobs" subtitle={data ? `${data.total} jobs` : undefined} />
|
||||||
|
<div className="px-6 py-3 flex gap-3 border-b border-slate-700/50">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
|
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="Pending">Pending</option>
|
||||||
|
<option value="Running">Running</option>
|
||||||
|
<option value="Completed">Completed</option>
|
||||||
|
<option value="Failed">Failed</option>
|
||||||
|
<option value="Cancelled">Cancelled</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={e => setTypeFilter(e.target.value)}
|
||||||
|
className="bg-slate-800 border border-slate-600 rounded-lg px-3 py-1.5 text-sm text-slate-300"
|
||||||
|
>
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option value="Renewal">Renewal</option>
|
||||||
|
<option value="Issuance">Issuance</option>
|
||||||
|
<option value="Deployment">Deployment</option>
|
||||||
|
<option value="Validation">Validation</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{error ? (
|
||||||
|
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No jobs found" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getNotifications } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
import type { Column } from '../components/DataTable';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { formatDateTime } from '../api/utils';
|
||||||
|
import type { Notification } from '../api/types';
|
||||||
|
|
||||||
|
export default function NotificationsPage() {
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['notifications'],
|
||||||
|
queryFn: () => getNotifications(),
|
||||||
|
refetchInterval: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<Notification>[] = [
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
label: 'Type',
|
||||||
|
render: (n) => <span className="text-sm text-slate-200">{n.type.replace(/([A-Z])/g, ' $1').trim()}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'status', label: 'Status', render: (n) => <StatusBadge status={n.status} /> },
|
||||||
|
{ key: 'channel', label: 'Channel', render: (n) => <span className="text-xs text-slate-400">{n.channel}</span> },
|
||||||
|
{ key: 'recipient', label: 'Recipient', render: (n) => <span className="text-xs text-slate-300">{n.recipient}</span> },
|
||||||
|
{
|
||||||
|
key: 'message',
|
||||||
|
label: 'Message',
|
||||||
|
render: (n) => <span className="text-xs text-slate-400 truncate max-w-xs block">{n.message || n.subject}</span>,
|
||||||
|
},
|
||||||
|
{ key: 'cert', label: 'Certificate', render: (n) => <span className="text-xs text-slate-500 font-mono">{n.certificate_id || '—'}</span> },
|
||||||
|
{ key: 'created', label: 'Sent', render: (n) => <span className="text-xs text-slate-400">{formatDateTime(n.created_at)}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Notifications" subtitle={data ? `${data.total} notifications` : undefined} />
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{error ? (
|
||||||
|
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No notifications" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { getPolicies } from '../api/client';
|
||||||
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import DataTable from '../components/DataTable';
|
||||||
|
import type { Column } from '../components/DataTable';
|
||||||
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { formatDateTime } from '../api/utils';
|
||||||
|
import type { PolicyRule } from '../api/types';
|
||||||
|
|
||||||
|
const severityStyles: Record<string, string> = {
|
||||||
|
low: 'badge-info',
|
||||||
|
medium: 'badge-warning',
|
||||||
|
high: 'badge-danger',
|
||||||
|
critical: 'badge-danger',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PoliciesPage() {
|
||||||
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
|
queryKey: ['policies'],
|
||||||
|
queryFn: () => getPolicies(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns: Column<PolicyRule>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Rule',
|
||||||
|
render: (p) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-slate-200">{p.name}</div>
|
||||||
|
<div className="text-xs text-slate-500">{p.id}</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'type', label: 'Type', render: (p) => <span className="text-sm text-slate-300">{p.type.replace(/_/g, ' ')}</span> },
|
||||||
|
{
|
||||||
|
key: 'severity',
|
||||||
|
label: 'Severity',
|
||||||
|
render: (p) => <span className={`badge ${severityStyles[p.severity] || 'badge-neutral'}`}>{p.severity}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
label: 'Enabled',
|
||||||
|
render: (p) => (
|
||||||
|
<span className={p.enabled ? 'text-emerald-400' : 'text-slate-500'}>
|
||||||
|
{p.enabled ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ key: 'created', label: 'Created', render: (p) => <span className="text-xs text-slate-400">{formatDateTime(p.created_at)}</span> },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Policies" subtitle={data ? `${data.total} rules` : undefined} />
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{error ? (
|
||||||
|
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||||
|
) : (
|
||||||
|
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No policy rules" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8443',
|
||||||
|
'/health': 'http://localhost:8443',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user