Compare commits

..

8 Commits

Author SHA1 Message Date
Igor Gov
0409eb239d Report telemetry on develop and main branches (#195) 2021-08-10 18:10:02 +03:00
Igor Gov
cbe04af801 Revert "Policy rules remove redundant function (#193)" (#199)
This reverts commit c4afeee5b3.
2021-08-10 18:04:30 +03:00
Igor Gov
59dec1a547 Readme fixes (#194) 2021-08-10 16:45:57 +03:00
Igor Gov
c4afeee5b3 Policy rules remove redundant function (#193) 2021-08-10 16:45:47 +03:00
Selton Fiuza
8c9b8d3217 Redesign test rules entry component (#174) 2021-08-10 16:20:16 +03:00
RoyUP9
d705ae3eb6 added support of slice in set, removed support of allowed set flags (#191) 2021-08-10 16:16:58 +03:00
RoyUP9
c53b2148d1 add readonly tag (#190) 2021-08-10 09:51:35 +03:00
Igor Gov
ca897dd3c7 Update issue templates (#189) 2021-08-09 18:36:56 +03:00
16 changed files with 241 additions and 58 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Run mizu <command> '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Logs**
Upload logs:
1. Run the mizu command with `--set dump-logs=true` (e.g `mizu tap --set dump-logs=true`)
2. Try to reproduce the issue
3. CNTRL+C on terminal tab which runs mizu
4. Upload the logs zip file from ~/.mizu/mizu_logs_**.zip
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome]
**Additional context**
Add any other context about the problem here.

View File

@@ -2,16 +2,16 @@
# The API Traffic Viewer for Kubernetes
A simple-yet-powerful API traffic viewer for Kubernetes to help you troubleshoot and debug your microservices. Think TCPDump and Chrome Dev Tools combined.
A simple-yet-powerful API traffic viewer for Kubernetes to help you troubleshoot and debug your microservices. Think TCPDump and Chrome Dev Tools combined
![Simple UI](assets/mizu-ui.png)
## Features
- Simple and powerful CLI
- Real time view of all HTTP requests, REST and gRPC API calls
- Real-time view of all HTTP requests, REST and gRPC API calls
- No installation or code instrumentation
- Works completely on premises (on-prem)
- Works completely on premises
## Download
@@ -33,10 +33,10 @@ https://github.com/up9inc/mizu/releases/latest/download/mizu_linux_amd64 \
&& chmod 755 mizu
```
SHA256 checksums are available on the [Releases](https://github.com/up9inc/mizu/releases) page.
SHA256 checksums are available on the [Releases](https://github.com/up9inc/mizu/releases) page
### Development (unstable) Build
Pick one from the [Releases](https://github.com/up9inc/mizu/releases) page.
Pick one from the [Releases](https://github.com/up9inc/mizu/releases) page
## Prerequisites
1. Set `KUBECONFIG` environment variable to your Kubernetes configuration. If this is not set, Mizu assumes that configuration is at `${HOME}/.kube/config`
@@ -49,8 +49,8 @@ For detailed list of k8s permissions see [PERMISSIONS](PERMISSIONS.md) document
1. Find pods you'd like to tap to in your Kubernetes cluster
2. Run `mizu tap` or `mizu tap PODNAME`
3. Open browser on `http://localhost:8899/mizu` **or** as instructed in the CLI ..
4. Watch the API traffic flowing ..
3. Open browser on `http://localhost:8899/mizu` **or** as instructed in the CLI
4. Watch the API traffic flowing
5. Type ^C to stop
## Examples
@@ -107,33 +107,31 @@ To tap multiple pods using regex -
## Configuration
Mizu can work with config file which should be stored in ${HOME}/.mizu/config.yaml (macOS: ~/.mizu/config.yaml) <br />
In case no config file found, defaults will be used. <br />
In case of partial configuration defined, all other fields will be used with defaults. <br />
You can always override the defaults or config file with CLI flags.
In case no config file found, defaults will be used <br />
In case of partial configuration defined, all other fields will be used with defaults <br />
You can always override the defaults or config file with CLI flags
To get the default config params run `mizu config` <br />
To generate a new config file with default values use `mizu config -r`
Mizu has several undocumented flags which can be set by using --set flag (e.g., `mizu tap --set dump-logs=true`)
* **mizu-resources-namespace**: Type - String, See [Namespace-Restricted Mode](#namespace-restricted-mode)
* **telemetry**: Type - Boolean, Reports telemetry
* **dump-logs**: Type - Boolean, At the end of the execution it creates a zip file with logs (in .mizu folder)
* **kube-config-path**: Type - String, Setting the path to kube config (which isn't in standard path)
### Telemetry
By default, mizu reports usage telemetry. It can be disabled by adding a line of telemetry: false in the ${HOME}/.mizu/config.yaml file
## Advanced Usage
### Namespace-Restricted Mode
Some users have permission to only manage resources in one particular namespace assigned to them.
Some users have permission to only manage resources in one particular namespace assigned to them
By default `mizu tap` creates a new namespace `mizu` for all of its Kubernetes resources. In order to instead install
Mizu in an existing namespace, set the `mizu-resources-namespace` config option.
Mizu in an existing namespace, set the `mizu-resources-namespace` config option
If `mizu-resources-namespace` is set to a value other than the default `mizu`, Mizu will operate in a
Namespace-Restricted mode. It will only tap pods in `mizu-resources-namespace`. This way Mizu only requires permissions
to the namespace set by `mizu-resources-namespace`. The user must set the tapped namespace to the same namespace by
using the `--namespace` flag or by setting `tap.namespaces` in the config file.
using the `--namespace` flag or by setting `tap.namespaces` in the config file
Setting `mizu-resources-namespace=mizu` resets Mizu to its default behavior.
Setting `mizu-resources-namespace=mizu` resets Mizu to its default behavior
### User agent filtering

View File

@@ -56,12 +56,14 @@ type BaseEntryDetails struct {
type ApplicableRules struct {
Latency int64 `json:"latency,omitempty"`
Status bool `json:"status,omitempty"`
NumberOfRules int `json:"numberOfRules,omitempty"`
}
func NewApplicableRules(status bool, latency int64) ApplicableRules {
func NewApplicableRules(status bool, latency int64, number int) ApplicableRules {
ar := ApplicableRules{}
ar.Status = status
ar.Latency = latency
ar.NumberOfRules = number
return ar
}
@@ -218,7 +220,7 @@ func (fewp *FullEntryWithPolicy) UnmarshalData(entry *MizuEntry) error {
func RunValidationRulesState(harEntry har.Entry, service string) ApplicableRules {
numberOfRules, resultPolicyToSend := rules.MatchRequestPolicy(harEntry, service)
statusPolicyToSend, latency := rules.PassedValidationRules(resultPolicyToSend, numberOfRules)
ar := NewApplicableRules(statusPolicyToSend, latency)
statusPolicyToSend, latency, numberOfRules := rules.PassedValidationRules(resultPolicyToSend, numberOfRules)
ar := NewApplicableRules(statusPolicyToSend, latency, numberOfRules)
return ar
}

View File

@@ -92,19 +92,19 @@ func MatchRequestPolicy(harEntry har.Entry, service string) (int, []RulesMatched
return len(enforcePolicy.Rules), resultPolicyToSend
}
func PassedValidationRules(rulesMatched []RulesMatched, numberOfRules int) (bool, int64) {
func PassedValidationRules(rulesMatched []RulesMatched, numberOfRules int) (bool, int64, int) {
if len(rulesMatched) == 0 {
return false, 0
return false, 0, 0
}
for _, rule := range rulesMatched {
if rule.Matched == false {
return false, -1
return false, -1, len(rulesMatched)
}
}
for _, rule := range rulesMatched {
if strings.ToLower(rule.Rule.Type) == "latency" {
return true, rule.Rule.Latency
return true, rule.Rule.Latency, len(rulesMatched)
}
}
return true, -1
return true, -1, len(rulesMatched)
}

View File

@@ -60,10 +60,10 @@ func init() {
defaults.Set(&defaultTapConfig)
tapCmd.Flags().Uint16P(configStructs.GuiPortTapName, "p", defaultTapConfig.GuiPort, "Provide a custom port for the web interface webserver")
tapCmd.Flags().StringArrayP(configStructs.NamespacesTapName, "n", defaultTapConfig.Namespaces, "Namespaces selector")
tapCmd.Flags().StringSliceP(configStructs.NamespacesTapName, "n", defaultTapConfig.Namespaces, "Namespaces selector")
tapCmd.Flags().Bool(configStructs.AnalysisTapName, defaultTapConfig.Analysis, "Uploads traffic to UP9 for further analysis (Beta)")
tapCmd.Flags().BoolP(configStructs.AllNamespacesTapName, "A", defaultTapConfig.AllNamespaces, "Tap all namespaces")
tapCmd.Flags().StringArrayP(configStructs.PlainTextFilterRegexesTapName, "r", defaultTapConfig.PlainTextFilterRegexes, "List of regex expressions that are used to filter matching values from text/plain http bodies")
tapCmd.Flags().StringSliceP(configStructs.PlainTextFilterRegexesTapName, "r", defaultTapConfig.PlainTextFilterRegexes, "List of regex expressions that are used to filter matching values from text/plain http bodies")
tapCmd.Flags().Bool(configStructs.DisableRedactionTapName, defaultTapConfig.DisableRedaction, "Disables redaction of potentially sensitive request/response headers and body values")
tapCmd.Flags().String(configStructs.HumanMaxEntriesDBSizeTapName, defaultTapConfig.HumanMaxEntriesDBSize, "Override the default max entries db size")
tapCmd.Flags().String(configStructs.DirectionTapName, defaultTapConfig.Direction, "Record traffic that goes in this direction (relative to the tapped pod): in/any")

View File

@@ -70,7 +70,7 @@ func RunMizuTap() {
targetNamespaces := getNamespaces(kubernetesProvider)
var namespacesStr string
if targetNamespaces[0] != mizu.K8sAllNamespaces {
if !mizu.Contains(targetNamespaces, mizu.K8sAllNamespaces) {
namespacesStr = fmt.Sprintf("namespaces \"%s\"", strings.Join(targetNamespaces, "\", \""))
} else {
namespacesStr = "all namespaces"
@@ -85,7 +85,7 @@ func RunMizuTap() {
if len(state.currentlyTappedPods) == 0 {
var suggestionStr string
if targetNamespaces[0] != mizu.K8sAllNamespaces {
if !mizu.Contains(targetNamespaces, mizu.K8sAllNamespaces) {
suggestionStr = ". Select a different namespace with -n or tap all namespaces with -A"
}
mizu.Log.Warningf(uiUtils.Warning, fmt.Sprintf("Did not find any pods matching the regex argument%s", suggestionStr))

View File

@@ -21,6 +21,8 @@ import (
const (
Separator = "="
SetCommandName = "set"
FieldNameTag = "yaml"
ReadonlyTag = "readonly"
)
var allowedSetFlags = []string{
@@ -71,8 +73,8 @@ func GetConfigWithDefaults() (string, error) {
return "", err
}
// TODO: change to generic solution
defaultConf.AgentImage = ""
configElem := reflect.ValueOf(&defaultConf).Elem()
setZeroForReadonlyFields(configElem)
return uiUtils.PrettyYaml(defaultConf)
}
@@ -110,33 +112,44 @@ func initFlag(f *pflag.Flag) {
}
if f.Name == SetCommandName {
mergeSetFlag(sliceValue.GetSlice())
mergeSetFlag(configElem, sliceValue.GetSlice())
return
}
mergeFlagValues(configElem, f.Name, sliceValue.GetSlice())
}
func mergeSetFlag(setValues []string) {
configElem := reflect.ValueOf(&Config).Elem()
func mergeSetFlag(configElem reflect.Value, setValues []string) {
setMap := map[string][]string{}
for _, setValue := range setValues {
if !strings.Contains(setValue, Separator) {
Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: <flag name>=<flag value>)", setValue))
continue
}
split := strings.SplitN(setValue, Separator, 2)
if len(split) != 2 {
Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s (set argument format: <flag name>=<flag value>)", setValue))
continue
}
argumentKey, argumentValue := split[0], split[1]
setMap[argumentKey] = append(setMap[argumentKey], argumentValue)
}
for argumentKey, argumentValues := range setMap {
if !Contains(allowedSetFlags, argumentKey) {
Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument %s, flag name must be one of the following: \"%s\"", setValue, strings.Join(allowedSetFlags, "\", \"")))
Log.Warningf(uiUtils.Warning, fmt.Sprintf("Ignoring set argument name \"%s\", flag name must be one of the following: \"%s\"", argumentKey, strings.Join(allowedSetFlags, "\", \"")))
continue
}
mergeFlagValue(configElem, argumentKey, argumentValue)
if len(argumentValues) > 1 {
mergeFlagValues(configElem, argumentKey, argumentValues)
} else {
mergeFlagValue(configElem, argumentKey, argumentValues[0])
}
}
}
@@ -144,21 +157,25 @@ func mergeFlagValue(currentElem reflect.Value, flagKey string, flagValue string)
for i := 0; i < currentElem.NumField(); i++ {
currentField := currentElem.Type().Field(i)
currentFieldByName := currentElem.FieldByName(currentField.Name)
currentFieldKind := currentField.Type.Kind()
if currentField.Type.Kind() == reflect.Struct {
if currentFieldKind == reflect.Struct {
mergeFlagValue(currentFieldByName, flagKey, flagValue)
continue
}
if currentField.Tag.Get("yaml") != flagKey {
if getFieldNameByTag(currentField) != flagKey {
continue
}
flagValueKind := currentField.Type.Kind()
if currentFieldKind == reflect.Slice {
mergeFlagValues(currentElem, flagKey, []string{flagValue})
return
}
parsedValue, err := getParsedValue(flagValueKind, flagValue)
parsedValue, err := getParsedValue(currentFieldKind, flagValue)
if err != nil {
Log.Warningf(uiUtils.Red, fmt.Sprintf("Invalid value %v for flag name %s, expected %s", flagValue, flagKey, flagValueKind))
Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid value %s for flag name %s, expected %s", flagValue, flagKey, currentFieldKind))
return
}
@@ -170,23 +187,29 @@ func mergeFlagValues(currentElem reflect.Value, flagKey string, flagValues []str
for i := 0; i < currentElem.NumField(); i++ {
currentField := currentElem.Type().Field(i)
currentFieldByName := currentElem.FieldByName(currentField.Name)
currentFieldKind := currentField.Type.Kind()
if currentField.Type.Kind() == reflect.Struct {
if currentFieldKind == reflect.Struct {
mergeFlagValues(currentFieldByName, flagKey, flagValues)
continue
}
if currentField.Tag.Get("yaml") != flagKey {
if getFieldNameByTag(currentField) != flagKey {
continue
}
if currentFieldKind != reflect.Slice {
Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid values %s for flag name %s, expected %s", strings.Join(flagValues, ","), flagKey, currentFieldKind))
return
}
flagValueKind := currentField.Type.Elem().Kind()
parsedValues := reflect.MakeSlice(reflect.SliceOf(currentField.Type.Elem()), 0, 0)
for _, flagValue := range flagValues {
parsedValue, err := getParsedValue(flagValueKind, flagValue)
if err != nil {
Log.Warningf(uiUtils.Red, fmt.Sprintf("Invalid value %v for flag name %s, expected %s", flagValue, flagKey, flagValueKind))
Log.Warningf(uiUtils.Warning, fmt.Sprintf("Invalid value %s for flag name %s, expected %s", flagValue, flagKey, flagValueKind))
return
}
@@ -197,6 +220,10 @@ func mergeFlagValues(currentElem reflect.Value, flagKey string, flagValues []str
}
}
func getFieldNameByTag(field reflect.StructField) string {
return strings.Split(field.Tag.Get(FieldNameTag), ",")[0]
}
func getParsedValue(kind reflect.Kind, value string) (reflect.Value, error) {
switch kind {
case reflect.String:
@@ -282,3 +309,19 @@ func getParsedValue(kind reflect.Kind, value string) (reflect.Value, error) {
return reflect.ValueOf(nil), errors.New("value to parse does not match type")
}
func setZeroForReadonlyFields(currentElem reflect.Value) {
for i := 0; i < currentElem.NumField(); i++ {
currentField := currentElem.Type().Field(i)
currentFieldByName := currentElem.FieldByName(currentField.Name)
if currentField.Type.Kind() == reflect.Struct {
setZeroForReadonlyFields(currentFieldByName)
continue
}
if _, ok := currentField.Tag.Lookup(ReadonlyTag); ok {
currentFieldByName.Set(reflect.Zero(currentField.Type))
}
}
}

View File

@@ -19,7 +19,7 @@ type ConfigStruct struct {
Fetch configStructs.FetchConfig `yaml:"fetch"`
Version configStructs.VersionConfig `yaml:"version"`
View configStructs.ViewConfig `yaml:"view"`
AgentImage string `yaml:"agent-image,omitempty"`
AgentImage string `yaml:"agent-image,omitempty" readonly:""`
MizuResourcesNamespace string `yaml:"mizu-resources-namespace" default:"mizu"`
Telemetry bool `yaml:"telemetry" default:"true"`
DumpLogs bool `yaml:"dump-logs" default:"false"`

View File

@@ -34,7 +34,7 @@ type TapConfig struct {
Analysis bool `yaml:"analysis" default:"false"`
AllNamespaces bool `yaml:"all-namespaces" default:"false"`
PlainTextFilterRegexes []string `yaml:"regex-masking"`
HealthChecksUserAgentHeaders []string `yaml:"ignored-user-agents" default:"[]"`
HealthChecksUserAgentHeaders []string `yaml:"ignored-user-agents"`
DisableRedaction bool `yaml:"no-redact" default:"false"`
HumanMaxEntriesDBSize string `yaml:"max-entries-db-size" default:"200MB"`
Direction string `yaml:"direction" default:"in"`

39
cli/mizu/config_test.go Normal file
View File

@@ -0,0 +1,39 @@
package mizu_test
import (
"github.com/up9inc/mizu/cli/mizu"
"reflect"
"strings"
"testing"
)
func TestConfigWriteIgnoresReadonlyFields(t *testing.T) {
var readonlyFields []string
configElem := reflect.ValueOf(&mizu.ConfigStruct{}).Elem()
getFieldsWithReadonlyTag(configElem, &readonlyFields)
config, _ := mizu.GetConfigWithDefaults()
for _, readonlyField := range readonlyFields {
if strings.Contains(config, readonlyField) {
t.Errorf("unexpected result - readonly field: %v, config: %v", readonlyField, config)
}
}
}
func getFieldsWithReadonlyTag(currentElem reflect.Value, readonlyFields *[]string) {
for i := 0; i < currentElem.NumField(); i++ {
currentField := currentElem.Type().Field(i)
currentFieldByName := currentElem.FieldByName(currentField.Name)
if currentField.Type.Kind() == reflect.Struct {
getFieldsWithReadonlyTag(currentFieldByName, readonlyFields)
continue
}
if _, ok := currentField.Tag.Lookup(mizu.ReadonlyTag); ok {
fieldNameByTag := strings.Split(currentField.Tag.Get(mizu.FieldNameTag), ",")[0]
*readonlyFields = append(*readonlyFields, fieldNameByTag)
}
}
}

View File

@@ -15,6 +15,10 @@ func ReportRun(cmd string, args interface{}) {
return
}
if Branch != "main" && Branch != "develop" {
Log.Debugf("not reporting telemetry on private branches")
}
argsBytes, _ := json.Marshal(args)
argsMap := map[string]string{
"telemetry_type": "execution",

View File

@@ -25,7 +25,8 @@ interface HAREntry {
interface Rules {
status: boolean;
latency: number
latency: number;
numberOfRules: number;
}
interface HAREntryProps {
@@ -36,6 +37,7 @@ interface HAREntryProps {
export const HarEntry: React.FC<HAREntryProps> = ({entry, setFocusedEntryId, isSelected}) => {
const classification = getClassification(entry.statusCode)
const numberOfRules = entry.rules.numberOfRules
let ingoingIcon;
let outgoingIcon;
switch(classification) {
@@ -55,16 +57,36 @@ export const HarEntry: React.FC<HAREntryProps> = ({entry, setFocusedEntryId, isS
break;
}
}
let backgroundColor = "";
if ('latency' in entry.rules) {
let additionalRulesProperties = "";
let ruleSuccess: boolean;
let rule = 'latency' in entry.rules
if (rule) {
if (entry.rules.latency !== -1) {
backgroundColor = entry.rules.latency >= entry.latency ? styles.ruleSuccessRow : styles.ruleFailureRow
if (entry.rules.latency >= entry.latency) {
additionalRulesProperties = styles.ruleSuccessRow
ruleSuccess = true
} else {
additionalRulesProperties = styles.ruleFailureRow
ruleSuccess = false
}
if (isSelected) {
additionalRulesProperties += ` ${entry.rules.latency >= entry.latency ? styles.ruleSuccessRowSelected : styles.ruleFailureRowSelected}`
}
} else {
backgroundColor = entry.rules.status ? styles.ruleSuccessRow : styles.ruleFailureRow
if (entry.rules.status) {
additionalRulesProperties = styles.ruleSuccessRow
ruleSuccess = true
} else {
additionalRulesProperties = styles.ruleFailureRow
ruleSuccess = false
}
if (isSelected) {
additionalRulesProperties += ` ${entry.rules.status ? styles.ruleSuccessRowSelected : styles.ruleFailureRowSelected}`
}
}
}
return <>
<div id={entry.id} className={`${styles.row} ${isSelected ? styles.rowSelected : backgroundColor}`} onClick={() => setFocusedEntryId(entry.id)}>
<div id={entry.id} className={`${styles.row} ${isSelected && !rule ? styles.rowSelected : additionalRulesProperties}`} onClick={() => setFocusedEntryId(entry.id)}>
{entry.statusCode && <div>
<StatusCode statusCode={entry.statusCode}/>
</div>}
@@ -74,6 +96,13 @@ export const HarEntry: React.FC<HAREntryProps> = ({entry, setFocusedEntryId, isS
{entry.service}
</div>
</div>
{
rule ?
<div className={`${ruleSuccess ? styles.ruleNumberTextSuccess : styles.ruleNumberTextFailure}`}>
{`Rules (${numberOfRules})`}
</div>
: ""
}
<div className={styles.directionContainer}>
{entry.isOutgoing ?
<img src={outgoingIcon} alt="outgoing traffic" title="outgoing"/>

View File

@@ -43,7 +43,6 @@ const HarEntryTitle: React.FC<any> = ({har}) => {
<div style={{margin: "0 18px", opacity: 0.5}}>{formatSize(bodySize)}</div>
<div style={{marginRight: 18, opacity: 0.5}}>{status} {statusText}</div>
<div style={{marginRight: 18, opacity: 0.5}}>{Math.round(receive)}ms</div>
<div style={{opacity: 0.5}}>{'rulesMatched' in entries[0] ? entries[0].rulesMatched?.length : '0'} Rules Applied</div>
</div>;
};

View File

@@ -92,3 +92,6 @@
tr td:first-child
white-space: nowrap
padding-right: .5rem
.noRules
padding: 0 1rem 1rem

View File

@@ -260,7 +260,7 @@ export const HAREntryTablePolicySection: React.FC<HAREntryPolicySectionProps> =
</table>
</HAREntrySectionContainer>
</> : <span/>
</> : <span className={styles.noRules}>No rules could be applied to this request.</span>
}
</React.Fragment>
}

View File

@@ -24,12 +24,40 @@
margin-right: 3px
.ruleSuccessRow
border: 1px $success-color solid
border-left: 5px $success-color solid
background: #E8FFF1
.ruleSuccessRowSelected
border: 1px #6FCF97 solid
border-left: 5px #6FCF97 solid
margin-left: 10px
margin-right: 3px
.ruleFailureRow
background: #FFE9EF
.ruleFailureRowSelected
border: 1px $failure-color solid
border-left: 5px $failure-color solid
margin-left: 10px
margin-right: 3px
.ruleNumberTextFailure
color: #DB2156
font-family: Source Sans Pro;
font-style: normal;
font-weight: 600;
font-size: 12px;
line-height: 15px;
padding-right: 12px
.ruleNumberTextSuccess
color: #219653
font-family: Source Sans Pro;
font-style: normal;
font-weight: 600;
font-size: 12px;
line-height: 15px;
padding-right: 12px
.service
text-overflow: ellipsis