mirror of
https://github.com/kyverno/policy-reporter.git
synced 2024-12-14 11:57:32 +00:00
API to render Violations Report (#429)
* API to render Violations Report Signed-off-by: Frank Jogeleit <frank.jogeleit@lovoo.com>
This commit is contained in:
parent
dd150ee3b6
commit
836d6fe436
13 changed files with 194 additions and 24 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -4,8 +4,7 @@
|
||||||
/config.yaml
|
/config.yaml
|
||||||
build
|
build
|
||||||
/test.yaml
|
/test.yaml
|
||||||
**/test.db
|
*.db
|
||||||
sqlite-database*.db
|
|
||||||
values*.yaml
|
values*.yaml
|
||||||
coverage.out*
|
coverage.out*
|
||||||
heap*
|
heap*
|
||||||
|
|
|
@ -67,6 +67,7 @@ spec:
|
||||||
- --rest-enabled={{ or .Values.rest.enabled .Values.ui.enabled }}
|
- --rest-enabled={{ or .Values.rest.enabled .Values.ui.enabled }}
|
||||||
- --profile={{ .Values.profiling.enabled }}
|
- --profile={{ .Values.profiling.enabled }}
|
||||||
- --lease-name={{ include "policyreporter.fullname" . }}
|
- --lease-name={{ include "policyreporter.fullname" . }}
|
||||||
|
- --template-dir=/app/templates
|
||||||
ports:
|
ports:
|
||||||
- name: {{ .Values.port.name }}
|
- name: {{ .Values.port.name }}
|
||||||
containerPort: {{ .Values.port.number }}
|
containerPort: {{ .Values.port.number }}
|
||||||
|
|
|
@ -76,7 +76,7 @@ func newRunCMD(version string) *cobra.Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Info("REST api enabled")
|
logger.Info("REST api enabled")
|
||||||
server.RegisterV1Handler(store)
|
server.RegisterV1Handler(store, resolver.ViolationsReporter())
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Metrics.Enabled {
|
if c.Metrics.Enabled {
|
||||||
|
@ -175,6 +175,7 @@ func newRunCMD(version string) *cobra.Command {
|
||||||
cmd.PersistentFlags().Bool("profile", false, "Enable application profiling with pprof")
|
cmd.PersistentFlags().Bool("profile", false, "Enable application profiling with pprof")
|
||||||
cmd.PersistentFlags().String("lease-name", "policy-reporter", "name of the LeaseLock")
|
cmd.PersistentFlags().String("lease-name", "policy-reporter", "name of the LeaseLock")
|
||||||
cmd.PersistentFlags().String("pod-name", "policy-reporter", "name of the pod, used for leaderelection")
|
cmd.PersistentFlags().String("pod-name", "policy-reporter", "name of the pod, used for leaderelection")
|
||||||
|
cmd.PersistentFlags().StringP("template-dir", "t", "./templates", "template directory")
|
||||||
cmd.PersistentFlags().Int("worker", 5, "amount of queue worker")
|
cmd.PersistentFlags().Int("worker", 5, "amount of queue worker")
|
||||||
cmd.PersistentFlags().Float32("qps", 20, "K8s RESTClient QPS")
|
cmd.PersistentFlags().Float32("qps", 20, "K8s RESTClient QPS")
|
||||||
cmd.PersistentFlags().Int("burst", 50, "K8s RESTClient burst")
|
cmd.PersistentFlags().Int("burst", 50, "K8s RESTClient burst")
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"go.uber.org/zap/zapcore"
|
"go.uber.org/zap/zapcore"
|
||||||
|
|
||||||
v1 "github.com/kyverno/policy-reporter/pkg/api/v1"
|
v1 "github.com/kyverno/policy-reporter/pkg/api/v1"
|
||||||
|
"github.com/kyverno/policy-reporter/pkg/email/violations"
|
||||||
"github.com/kyverno/policy-reporter/pkg/target"
|
"github.com/kyverno/policy-reporter/pkg/target"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -25,7 +26,7 @@ type Server interface {
|
||||||
// RegisterMetricsHandler adds the optional metrics endpoint
|
// RegisterMetricsHandler adds the optional metrics endpoint
|
||||||
RegisterMetricsHandler()
|
RegisterMetricsHandler()
|
||||||
// RegisterV1Handler adds the optional v1 REST APIs
|
// RegisterV1Handler adds the optional v1 REST APIs
|
||||||
RegisterV1Handler(v1.PolicyReportFinder)
|
RegisterV1Handler(v1.PolicyReportFinder, *violations.Reporter)
|
||||||
// RegisterProfilingHandler adds the optional pprof profiling APIs
|
// RegisterProfilingHandler adds the optional pprof profiling APIs
|
||||||
RegisterProfilingHandler()
|
RegisterProfilingHandler()
|
||||||
}
|
}
|
||||||
|
@ -54,7 +55,7 @@ func (s *httpServer) RegisterLifecycleHandler() {
|
||||||
s.mux.HandleFunc("/ready", ReadyHandler(s.synced))
|
s.mux.HandleFunc("/ready", ReadyHandler(s.synced))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *httpServer) RegisterV1Handler(finder v1.PolicyReportFinder) {
|
func (s *httpServer) RegisterV1Handler(finder v1.PolicyReportFinder, reporter *violations.Reporter) {
|
||||||
handler := v1.NewHandler(finder)
|
handler := v1.NewHandler(finder)
|
||||||
|
|
||||||
s.mux.HandleFunc("/v1/targets", s.middleware(handler.TargetsHandler(s.targets)))
|
s.mux.HandleFunc("/v1/targets", s.middleware(handler.TargetsHandler(s.targets)))
|
||||||
|
@ -83,6 +84,10 @@ func (s *httpServer) RegisterV1Handler(finder v1.PolicyReportFinder) {
|
||||||
s.mux.HandleFunc("/v1/cluster-resources/status-counts", s.middleware(handler.ClusterResourcesStatusCountHandler()))
|
s.mux.HandleFunc("/v1/cluster-resources/status-counts", s.middleware(handler.ClusterResourcesStatusCountHandler()))
|
||||||
s.mux.HandleFunc("/v1/cluster-resources/results", s.middleware(handler.ClusterResourcesResultHandler()))
|
s.mux.HandleFunc("/v1/cluster-resources/results", s.middleware(handler.ClusterResourcesResultHandler()))
|
||||||
s.mux.HandleFunc("/v1/cluster-resources/categories", s.middleware(handler.ClusterCategoryListHandler()))
|
s.mux.HandleFunc("/v1/cluster-resources/categories", s.middleware(handler.ClusterCategoryListHandler()))
|
||||||
|
|
||||||
|
htmlHandler := v1.NewHTMLHandler(finder, reporter)
|
||||||
|
|
||||||
|
s.mux.HandleFunc("/v1/html-report/violations", s.middleware(htmlHandler.HTMLReport()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *httpServer) RegisterMetricsHandler() {
|
func (s *httpServer) RegisterMetricsHandler() {
|
||||||
|
|
|
@ -33,7 +33,7 @@ func Test_NewServer(t *testing.T) {
|
||||||
)
|
)
|
||||||
|
|
||||||
server.RegisterMetricsHandler()
|
server.RegisterMetricsHandler()
|
||||||
server.RegisterV1Handler(nil)
|
server.RegisterV1Handler(nil, nil)
|
||||||
server.RegisterProfilingHandler()
|
server.RegisterProfilingHandler()
|
||||||
|
|
||||||
serviceRunning := make(chan struct{})
|
serviceRunning := make(chan struct{})
|
||||||
|
@ -81,6 +81,6 @@ func Test_SetupServerWithAuth(t *testing.T) {
|
||||||
)
|
)
|
||||||
|
|
||||||
server.RegisterMetricsHandler()
|
server.RegisterMetricsHandler()
|
||||||
server.RegisterV1Handler(nil)
|
server.RegisterV1Handler(nil, nil)
|
||||||
server.RegisterProfilingHandler()
|
server.RegisterProfilingHandler()
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,6 +14,7 @@ import (
|
||||||
v1 "github.com/kyverno/policy-reporter/pkg/api/v1"
|
v1 "github.com/kyverno/policy-reporter/pkg/api/v1"
|
||||||
"github.com/kyverno/policy-reporter/pkg/crd/api/policyreport/v1alpha2"
|
"github.com/kyverno/policy-reporter/pkg/crd/api/policyreport/v1alpha2"
|
||||||
"github.com/kyverno/policy-reporter/pkg/database"
|
"github.com/kyverno/policy-reporter/pkg/database"
|
||||||
|
"github.com/kyverno/policy-reporter/pkg/email/violations"
|
||||||
"github.com/kyverno/policy-reporter/pkg/target"
|
"github.com/kyverno/policy-reporter/pkg/target"
|
||||||
"github.com/kyverno/policy-reporter/pkg/target/loki"
|
"github.com/kyverno/policy-reporter/pkg/target/loki"
|
||||||
)
|
)
|
||||||
|
@ -143,6 +144,7 @@ func Test_V1_API(t *testing.T) {
|
||||||
store.Add(ctx, creport)
|
store.Add(ctx, creport)
|
||||||
|
|
||||||
handl := v1.NewHandler(store)
|
handl := v1.NewHandler(store)
|
||||||
|
htmlHandl := v1.NewHTMLHandler(store, violations.NewReporter("../../../templates", "Cluster", "Report"))
|
||||||
|
|
||||||
t.Run("ClusterPolicyListHandler", func(t *testing.T) {
|
t.Run("ClusterPolicyListHandler", func(t *testing.T) {
|
||||||
req, err := http.NewRequest("GET", "/v1/cluster-policies", nil)
|
req, err := http.NewRequest("GET", "/v1/cluster-policies", nil)
|
||||||
|
@ -593,6 +595,21 @@ func Test_V1_API(t *testing.T) {
|
||||||
t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected)
|
t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("HTMLReport", func(t *testing.T) {
|
||||||
|
req, err := http.NewRequest("GET", "/v1/html-report/violations", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler := htmlHandl.HTMLReport()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if status := rr.Code; status != http.StatusOK {
|
||||||
|
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_TargetsAPI(t *testing.T) {
|
func Test_TargetsAPI(t *testing.T) {
|
||||||
|
|
146
pkg/api/v1/html_report.go
Normal file
146
pkg/api/v1/html_report.go
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/kyverno/policy-reporter/pkg/email/violations"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HTMLHandler struct {
|
||||||
|
reporter *violations.Reporter
|
||||||
|
finder PolicyReportFinder
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *HTMLHandler) HTMLReport() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
sources := make([]violations.Source, 0)
|
||||||
|
|
||||||
|
namespaced, err := h.finder.FetchNamespacedSources(req.Context())
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("failed to load data", zap.Error(err))
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster, err := h.finder.FetchClusterSources(req.Context())
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("failed to load data", zap.Error(err))
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list := append(namespaced, cluster...)
|
||||||
|
slices.Sort(list)
|
||||||
|
list = slices.Compact(list)
|
||||||
|
|
||||||
|
for _, source := range list {
|
||||||
|
cPass, err := h.finder.CountClusterResults(req.Context(), Filter{
|
||||||
|
Sources: []string{source},
|
||||||
|
Status: []string{"pass"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCounts, err := h.finder.FetchNamespacedStatusCounts(req.Context(), Filter{
|
||||||
|
Sources: []string{source},
|
||||||
|
Status: []string{"pass"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nsPass := make(map[string]int, len(statusCounts))
|
||||||
|
for _, s := range statusCounts[0].Items {
|
||||||
|
nsPass[s.Namespace] = s.Count
|
||||||
|
}
|
||||||
|
|
||||||
|
clusterResults, err := h.finder.FetchClusterResults(req.Context(), Filter{
|
||||||
|
Sources: []string{source},
|
||||||
|
Status: []string{"warn", "fail", "error"},
|
||||||
|
}, Pagination{SortBy: defaultOrder})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
cResults := make(map[string][]violations.Result)
|
||||||
|
for _, r := range clusterResults {
|
||||||
|
if _, ok := cResults[r.Status]; !ok {
|
||||||
|
cResults[r.Status] = make([]violations.Result, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
cResults[r.Status] = append(cResults[r.Status], violations.Result{
|
||||||
|
Kind: r.Kind,
|
||||||
|
Name: r.Name,
|
||||||
|
Policy: r.Policy,
|
||||||
|
Rule: r.Rule,
|
||||||
|
Status: r.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
namespaces, err := h.finder.FetchNamespaces(req.Context(), Filter{
|
||||||
|
Sources: []string{source},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nsResults := make(map[string]map[string][]violations.Result)
|
||||||
|
for _, ns := range namespaces {
|
||||||
|
results, err := h.finder.FetchNamespacedResults(req.Context(), Filter{
|
||||||
|
Sources: []string{source},
|
||||||
|
Status: []string{"warn", "fail", "error"},
|
||||||
|
Namespaces: []string{ns},
|
||||||
|
}, Pagination{SortBy: defaultOrder})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
mapping := make(map[string][]violations.Result)
|
||||||
|
mapping["warn"] = make([]violations.Result, 0)
|
||||||
|
mapping["fail"] = make([]violations.Result, 0)
|
||||||
|
mapping["error"] = make([]violations.Result, 0)
|
||||||
|
|
||||||
|
for _, r := range results {
|
||||||
|
mapping[r.Status] = append(mapping[r.Status], violations.Result{
|
||||||
|
Kind: r.Kind,
|
||||||
|
Name: r.Name,
|
||||||
|
Policy: r.Policy,
|
||||||
|
Rule: r.Rule,
|
||||||
|
Status: r.Status,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nsResults[ns] = mapping
|
||||||
|
}
|
||||||
|
|
||||||
|
sources = append(sources, violations.Source{
|
||||||
|
Name: source,
|
||||||
|
ClusterReports: len(cluster) > 0,
|
||||||
|
ClusterPassed: cPass,
|
||||||
|
ClusterResults: cResults,
|
||||||
|
NamespacePassed: nsPass,
|
||||||
|
NamespaceResults: nsResults,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.reporter.Report(sources, "HTML")
|
||||||
|
if err != nil {
|
||||||
|
zap.L().Error("failed to load data", zap.Error(err))
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write([]byte(data.Message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTMLHandler(finder PolicyReportFinder, reporter *violations.Reporter) *HTMLHandler {
|
||||||
|
return &HTMLHandler{
|
||||||
|
finder: finder,
|
||||||
|
reporter: reporter,
|
||||||
|
}
|
||||||
|
}
|
|
@ -243,14 +243,13 @@ type EmailReport struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailReport configuration
|
// EmailReport configuration
|
||||||
type EmailTemplates struct {
|
type Templates struct {
|
||||||
Dir string `mapstructure:"dir"`
|
Dir string `mapstructure:"dir"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmailReports configuration
|
// EmailReports configuration
|
||||||
type EmailReports struct {
|
type EmailReports struct {
|
||||||
SMTP SMTP `mapstructure:"smtp"`
|
SMTP SMTP `mapstructure:"smtp"`
|
||||||
Templates EmailTemplates `mapstructure:"templates"`
|
|
||||||
Summary EmailReport `mapstructure:"summary"`
|
Summary EmailReport `mapstructure:"summary"`
|
||||||
Violations EmailReport `mapstructure:"violations"`
|
Violations EmailReport `mapstructure:"violations"`
|
||||||
ClusterName string `mapstructure:"clusterName"`
|
ClusterName string `mapstructure:"clusterName"`
|
||||||
|
@ -387,4 +386,5 @@ type Config struct {
|
||||||
Logging Logging `mapstructure:"logging"`
|
Logging Logging `mapstructure:"logging"`
|
||||||
Database Database `mapstructure:"database"`
|
Database Database `mapstructure:"database"`
|
||||||
SourceConfig map[string]SourceConfig `mapstructure:"sourceConfig"`
|
SourceConfig map[string]SourceConfig `mapstructure:"sourceConfig"`
|
||||||
|
Templates Templates `mapstructure:"templates"`
|
||||||
}
|
}
|
||||||
|
|
|
@ -74,7 +74,7 @@ func Load(cmd *cobra.Command) (*Config, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if flag := cmd.Flags().Lookup("template-dir"); flag != nil {
|
if flag := cmd.Flags().Lookup("template-dir"); flag != nil {
|
||||||
v.BindPFlag("emailReports.templates.dir", flag)
|
v.BindPFlag("templates.dir", flag)
|
||||||
}
|
}
|
||||||
|
|
||||||
if flag := cmd.Flags().Lookup("lease-name"); flag != nil {
|
if flag := cmd.Flags().Lookup("lease-name"); flag != nil {
|
||||||
|
|
|
@ -58,8 +58,8 @@ func Test_Load(t *testing.T) {
|
||||||
if c.Profiling.Enabled != true {
|
if c.Profiling.Enabled != true {
|
||||||
t.Errorf("Unexpected Profiling Config: %v", c.Profiling.Enabled)
|
t.Errorf("Unexpected Profiling Config: %v", c.Profiling.Enabled)
|
||||||
}
|
}
|
||||||
if c.EmailReports.Templates.Dir != "/app/templates" {
|
if c.Templates.Dir != "/app/templates" {
|
||||||
t.Errorf("Unexpected TemplateDir Config: %s", c.EmailReports.Templates.Dir)
|
t.Errorf("Unexpected TemplateDir Config: %s", c.Templates.Dir)
|
||||||
}
|
}
|
||||||
if c.DBFile != "sqlite-database.db" {
|
if c.DBFile != "sqlite-database.db" {
|
||||||
t.Errorf("Unexpected DBFile Config: %s", c.DBFile)
|
t.Errorf("Unexpected DBFile Config: %s", c.DBFile)
|
||||||
|
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"github.com/kyverno/policy-reporter/pkg/email"
|
"github.com/kyverno/policy-reporter/pkg/email"
|
||||||
"github.com/kyverno/policy-reporter/pkg/email/summary"
|
"github.com/kyverno/policy-reporter/pkg/email/summary"
|
||||||
"github.com/kyverno/policy-reporter/pkg/email/violations"
|
"github.com/kyverno/policy-reporter/pkg/email/violations"
|
||||||
|
"github.com/kyverno/policy-reporter/pkg/helper"
|
||||||
"github.com/kyverno/policy-reporter/pkg/kubernetes"
|
"github.com/kyverno/policy-reporter/pkg/kubernetes"
|
||||||
"github.com/kyverno/policy-reporter/pkg/kubernetes/secrets"
|
"github.com/kyverno/policy-reporter/pkg/kubernetes/secrets"
|
||||||
"github.com/kyverno/policy-reporter/pkg/leaderelection"
|
"github.com/kyverno/policy-reporter/pkg/leaderelection"
|
||||||
|
@ -384,9 +385,9 @@ func (r *Resolver) SummaryGenerator() (*summary.Generator, error) {
|
||||||
|
|
||||||
func (r *Resolver) SummaryReporter() *summary.Reporter {
|
func (r *Resolver) SummaryReporter() *summary.Reporter {
|
||||||
return summary.NewReporter(
|
return summary.NewReporter(
|
||||||
r.config.EmailReports.Templates.Dir,
|
r.config.Templates.Dir,
|
||||||
r.config.EmailReports.ClusterName,
|
r.config.EmailReports.ClusterName,
|
||||||
r.config.EmailReports.TitlePrefix,
|
helper.Defaults(r.config.EmailReports.TitlePrefix, "Report"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -405,7 +406,7 @@ func (r *Resolver) ViolationsGenerator() (*violations.Generator, error) {
|
||||||
|
|
||||||
func (r *Resolver) ViolationsReporter() *violations.Reporter {
|
func (r *Resolver) ViolationsReporter() *violations.Reporter {
|
||||||
return violations.NewReporter(
|
return violations.NewReporter(
|
||||||
r.config.EmailReports.Templates.Dir,
|
r.config.Templates.Dir,
|
||||||
r.config.EmailReports.ClusterName,
|
r.config.EmailReports.ClusterName,
|
||||||
r.config.EmailReports.TitlePrefix,
|
r.config.EmailReports.TitlePrefix,
|
||||||
)
|
)
|
||||||
|
|
|
@ -164,10 +164,10 @@ var testConfig = &config.Config{
|
||||||
Prefix: "prefix",
|
Prefix: "prefix",
|
||||||
Channels: []*config.GCS{{}},
|
Channels: []*config.GCS{{}},
|
||||||
},
|
},
|
||||||
EmailReports: config.EmailReports{
|
Templates: config.Templates{
|
||||||
Templates: config.EmailTemplates{
|
|
||||||
Dir: "../../templates",
|
Dir: "../../templates",
|
||||||
},
|
},
|
||||||
|
EmailReports: config.EmailReports{
|
||||||
SMTP: config.SMTP{
|
SMTP: config.SMTP{
|
||||||
Host: "localhost",
|
Host: "localhost",
|
||||||
Port: 465,
|
Port: 465,
|
||||||
|
|
|
@ -56,7 +56,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 24px 16px 16px;" align="left" bgcolor="#ffffff">
|
<td style="line-height: 24px; font-size: 16px; width: 100%; margin: 0; padding: 24px 16px 16px;" align="left" bgcolor="#ffffff">
|
||||||
<h2 class="h2" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 32px; line-height: 38.4px; margin: 0;" align="left">{{ $source.Name }}</h2>
|
<h2 class="h2" style="padding-top: 0; padding-bottom: 0; font-weight: 500; vertical-align: baseline; font-size: 32px; line-height: 38.4px; margin: 0;" align="left">{{ title $source.Name }}</h2>
|
||||||
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
|
<table class="s-6 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
|
|
Loading…
Reference in a new issue