stoplight 4.1.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +289 -350
  3. data/lib/stoplight/admin/actions/action.rb +24 -0
  4. data/lib/stoplight/admin/actions/lock.rb +23 -0
  5. data/lib/stoplight/admin/actions/lock_all_green.rb +18 -0
  6. data/lib/stoplight/admin/actions/lock_green.rb +23 -0
  7. data/lib/stoplight/admin/actions/lock_red.rb +23 -0
  8. data/lib/stoplight/admin/actions/stats.rb +27 -0
  9. data/lib/stoplight/admin/actions/unlock.rb +23 -0
  10. data/lib/stoplight/admin/dependencies.rb +50 -0
  11. data/lib/stoplight/admin/helpers.rb +27 -0
  12. data/lib/stoplight/admin/lights_repository/light.rb +155 -0
  13. data/lib/stoplight/admin/lights_repository.rb +74 -0
  14. data/lib/stoplight/admin/lights_stats.rb +77 -0
  15. data/lib/stoplight/admin/views/_card.erb +120 -0
  16. data/lib/stoplight/admin/views/index.erb +36 -0
  17. data/lib/stoplight/admin/views/layout.erb +66 -0
  18. data/lib/stoplight/admin.rb +68 -0
  19. data/lib/stoplight/color.rb +3 -3
  20. data/lib/stoplight/config/config_provider.rb +62 -0
  21. data/lib/stoplight/config/library_default_config.rb +29 -0
  22. data/lib/stoplight/config/user_default_config.rb +83 -0
  23. data/lib/stoplight/data_store/base.rb +59 -33
  24. data/lib/stoplight/data_store/fail_safe.rb +105 -0
  25. data/lib/stoplight/data_store/memory.rb +257 -50
  26. data/lib/stoplight/data_store/redis/get_metadata.lua +38 -0
  27. data/lib/stoplight/data_store/redis/lua.rb +23 -0
  28. data/lib/stoplight/data_store/redis/record_failure.lua +36 -0
  29. data/lib/stoplight/data_store/redis/record_success.lua +35 -0
  30. data/lib/stoplight/data_store/redis/transition_to_green.lua +10 -0
  31. data/lib/stoplight/data_store/redis/transition_to_red.lua +10 -0
  32. data/lib/stoplight/data_store/redis/transition_to_yellow.lua +9 -0
  33. data/lib/stoplight/data_store/redis.rb +345 -106
  34. data/lib/stoplight/default.rb +11 -9
  35. data/lib/stoplight/error.rb +1 -13
  36. data/lib/stoplight/failure.rb +14 -13
  37. data/lib/stoplight/light/config.rb +118 -0
  38. data/lib/stoplight/light/configuration_builder_interface.rb +128 -0
  39. data/lib/stoplight/light/green_run_strategy.rb +53 -0
  40. data/lib/stoplight/light/red_run_strategy.rb +26 -0
  41. data/lib/stoplight/light/run_strategy.rb +30 -0
  42. data/lib/stoplight/light/yellow_run_strategy.rb +78 -0
  43. data/lib/stoplight/light.rb +164 -84
  44. data/lib/stoplight/metadata.rb +71 -0
  45. data/lib/stoplight/notifier/base.rb +14 -7
  46. data/lib/stoplight/notifier/fail_safe.rb +67 -0
  47. data/lib/stoplight/notifier/generic.rb +54 -5
  48. data/lib/stoplight/rspec/generic_notifier.rb +11 -12
  49. data/lib/stoplight/rspec.rb +1 -1
  50. data/lib/stoplight/state.rb +3 -3
  51. data/lib/stoplight/traffic_control/base.rb +35 -0
  52. data/lib/stoplight/traffic_control/consecutive_failures.rb +43 -0
  53. data/lib/stoplight/traffic_recovery/base.rb +51 -0
  54. data/lib/stoplight/traffic_recovery/single_success.rb +35 -0
  55. data/lib/stoplight/version.rb +1 -1
  56. data/lib/stoplight.rb +111 -51
  57. metadata +49 -98
  58. data/lib/stoplight/builder.rb +0 -70
  59. data/lib/stoplight/circuit_breaker.rb +0 -102
  60. data/lib/stoplight/configurable.rb +0 -95
  61. data/lib/stoplight/configuration.rb +0 -126
  62. data/lib/stoplight/light/deprecated.rb +0 -44
  63. data/lib/stoplight/light/lockable.rb +0 -45
  64. data/lib/stoplight/light/runnable.rb +0 -127
  65. data/lib/stoplight/notifier.rb +0 -6
  66. data/spec/spec_helper.rb +0 -22
  67. data/spec/stoplight/builder_spec.rb +0 -165
  68. data/spec/stoplight/circuit_breaker_spec.rb +0 -43
  69. data/spec/stoplight/color_spec.rb +0 -39
  70. data/spec/stoplight/configurable_spec.rb +0 -25
  71. data/spec/stoplight/data_store/base_spec.rb +0 -71
  72. data/spec/stoplight/data_store/memory_spec.rb +0 -22
  73. data/spec/stoplight/data_store/redis_spec.rb +0 -45
  74. data/spec/stoplight/data_store_spec.rb +0 -9
  75. data/spec/stoplight/default_spec.rb +0 -80
  76. data/spec/stoplight/error_spec.rb +0 -39
  77. data/spec/stoplight/failure_spec.rb +0 -108
  78. data/spec/stoplight/light/lockable_spec.rb +0 -93
  79. data/spec/stoplight/light/runnable_spec.rb +0 -38
  80. data/spec/stoplight/light_spec.rb +0 -156
  81. data/spec/stoplight/notifier/base_spec.rb +0 -18
  82. data/spec/stoplight/notifier/generic_spec.rb +0 -50
  83. data/spec/stoplight/notifier/io_spec.rb +0 -41
  84. data/spec/stoplight/notifier/logger_spec.rb +0 -75
  85. data/spec/stoplight/notifier_spec.rb +0 -9
  86. data/spec/stoplight/state_spec.rb +0 -39
  87. data/spec/stoplight/version_spec.rb +0 -9
  88. data/spec/stoplight_spec.rb +0 -32
  89. data/spec/support/configurable.rb +0 -69
  90. data/spec/support/data_store/base/clear_failures.rb +0 -18
  91. data/spec/support/data_store/base/clear_state.rb +0 -20
  92. data/spec/support/data_store/base/get_all.rb +0 -44
  93. data/spec/support/data_store/base/get_failures.rb +0 -30
  94. data/spec/support/data_store/base/get_state.rb +0 -7
  95. data/spec/support/data_store/base/names.rb +0 -29
  96. data/spec/support/data_store/base/record_failures.rb +0 -70
  97. data/spec/support/data_store/base/set_state.rb +0 -15
  98. data/spec/support/data_store/base/with_notification_lock.rb +0 -27
  99. data/spec/support/data_store/base.rb +0 -21
  100. data/spec/support/database_cleaner.rb +0 -26
  101. data/spec/support/exception_helpers.rb +0 -9
  102. data/spec/support/light/runnable/color.rb +0 -79
  103. data/spec/support/light/runnable/run.rb +0 -247
  104. data/spec/support/light/runnable/state.rb +0 -31
  105. data/spec/support/light/runnable.rb +0 -5
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ module Actions
6
+ # @abstract
7
+ class Action
8
+ # @!attribute lights_repository
9
+ # @return [Stoplight::Admin::LightsRepository]
10
+ attr_reader :lights_repository
11
+ private :lights_repository
12
+
13
+ # @return lights_repository [Stoplight::Admin::LightsRepository]
14
+ def initialize(lights_repository:)
15
+ @lights_repository = lights_repository
16
+ end
17
+
18
+ def call(params)
19
+ raise NotImplementedError
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ module Actions
6
+ # This action locks light
7
+ class Lock < Action
8
+ # @param params [Hash] query parameters
9
+ # @return [void]
10
+ def call(params)
11
+ light_names(params).each do |name|
12
+ lights_repository.lock(name)
13
+ end
14
+ end
15
+
16
+ private def light_names(params)
17
+ Array(params[:names])
18
+ .map { |name| CGI.unescape(name) }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ module Actions
6
+ # This action locks all lights green
7
+ class LockAllGreen < Action
8
+ # @return [void]
9
+ def call(*)
10
+ lights_repository
11
+ .with_color(RED, YELLOW)
12
+ .map(&:name)
13
+ .each { |name| lights_repository.lock(name, GREEN) }
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ module Actions
6
+ # This action locks light with the specific name green
7
+ class LockGreen < Action
8
+ # @param params [Hash] query parameters
9
+ # @return [void]
10
+ def call(params)
11
+ light_names(params).each do |name|
12
+ lights_repository.lock(name, GREEN)
13
+ end
14
+ end
15
+
16
+ private def light_names(params)
17
+ Array(params[:names])
18
+ .map { |name| CGI.unescape(name) }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ module Actions
6
+ # This action locks light with the specific name red
7
+ class LockRed < Action
8
+ # @param params [Hash] query parameters
9
+ # @return [void]
10
+ def call(params)
11
+ light_names(params).each do |name|
12
+ lights_repository.lock(name, RED)
13
+ end
14
+ end
15
+
16
+ private def light_names(params)
17
+ Array(params[:names])
18
+ .map { |name| CGI.unescape(name) }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ module Actions
6
+ class Stats < Action
7
+ # @!attribute lights_stats
8
+ # @return [Class<Stoplight::Admin::LightsStats>]
9
+ attr_reader :lights_stats
10
+ private :lights_stats
11
+
12
+ # @param lights_stats [Class<Stoplight::Admin::LightsStats>]
13
+ def initialize(lights_stats:, **deps)
14
+ super(**deps)
15
+ @lights_stats = lights_stats
16
+ end
17
+
18
+ # @return [(Stoplight::Admin::LightsRepository::Light)]
19
+ def call(*)
20
+ lights = lights_repository.all
21
+ stats = lights_stats.call(lights)
22
+ [lights, stats]
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ module Actions
6
+ # This action unlocks light
7
+ class Unlock < Action
8
+ # @param params [Hash] query parameters
9
+ # @return [void]
10
+ def call(params)
11
+ light_names(params).each do |name|
12
+ lights_repository.unlock(name)
13
+ end
14
+ end
15
+
16
+ private def light_names(params)
17
+ Array(params[:names])
18
+ .map { |name| CGI.unescape(name) }
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ class Dependencies
6
+ # @!attribute data_store
7
+ # @return [Stoplight::DataStore::Base]
8
+ attr_reader :data_store
9
+ private :data_store
10
+
11
+ # @param data_store [Stoplight::DataStore::Base]
12
+ def initialize(data_store:)
13
+ @data_store = data_store
14
+ end
15
+
16
+ # @return [Stoplight::Admin::LightsRepository]
17
+ def lights_repository
18
+ Stoplight::Admin::LightsRepository.new(data_store: data_store)
19
+ end
20
+
21
+ # @return [Stoplight::Admin::Actions::Stats]
22
+ def stats_action
23
+ Stoplight::Admin::Actions::Stats.new(
24
+ lights_repository: lights_repository,
25
+ lights_stats: Stoplight::Admin::LightsStats
26
+ )
27
+ end
28
+
29
+ # @return [Stoplight::Admin::Actions::Unlock]
30
+ def unlock_action
31
+ Stoplight::Admin::Actions::Unlock.new(lights_repository: lights_repository)
32
+ end
33
+
34
+ # @return [Stoplight::Admin::Actions::LockGreen]
35
+ def green_action
36
+ Stoplight::Admin::Actions::LockGreen.new(lights_repository: lights_repository)
37
+ end
38
+
39
+ # @return [Stoplight::Admin::Actions::LockRed]
40
+ def red_action
41
+ Stoplight::Admin::Actions::LockRed.new(lights_repository: lights_repository)
42
+ end
43
+
44
+ # @return [Stoplight::Admin::Actions::LockAllGreen]
45
+ def green_all_action
46
+ Stoplight::Admin::Actions::LockAllGreen.new(lights_repository: lights_repository)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ module Helpers
6
+ COLORS = [
7
+ GREEN = Stoplight::Color::GREEN,
8
+ YELLOW = Stoplight::Color::YELLOW,
9
+ RED = Stoplight::Color::RED
10
+ ].freeze
11
+
12
+ # @return [Stoplight::Admin::Dependencies]
13
+ def dependencies
14
+ Dependencies.new(data_store:)
15
+ end
16
+
17
+ private def data_store
18
+ settings.data_store.tap do |data_store|
19
+ if data_store.is_a?(Stoplight::DataStore::Memory)
20
+ raise "Stoplight Admin requires a persistent data store, but the current data store is Memory. " \
21
+ "Please configure a different data store in your Stoplight configuration."
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Stoplight
6
+ class Admin
7
+ class LightsRepository
8
+ class Light
9
+ COLORS = [
10
+ GREEN = Stoplight::Color::GREEN,
11
+ YELLOW = Stoplight::Color::YELLOW,
12
+ RED = Stoplight::Color::RED
13
+ ].freeze
14
+
15
+ # @!attribute id
16
+ # @return [String]
17
+ attr_reader :id
18
+
19
+ # @!attribute name
20
+ # @return [String]
21
+ attr_reader :name
22
+
23
+ # @!attribute color
24
+ # @return [String]
25
+ attr_reader :color
26
+
27
+ # @!attribute state
28
+ # @return [String]
29
+ attr_reader :state
30
+
31
+ # @!attribute failures
32
+ # @return [<Stoplight::Failure>]
33
+ attr_reader :failures
34
+
35
+ # @param name [String]
36
+ # @param color [String]
37
+ # @param state [String]
38
+ # @param failures [<Stoplight::Failure>]
39
+ def initialize(name:, color:, state:, failures:)
40
+ @id = SecureRandom.uuid
41
+ @name = name
42
+ @color = color
43
+ @state = state
44
+ @failures = failures
45
+ end
46
+
47
+ def latest_failure
48
+ failures.first
49
+ end
50
+
51
+ # @return [Boolean]
52
+ def locked?
53
+ !unlocked?
54
+ end
55
+
56
+ # @return [Boolean]
57
+ def unlocked?
58
+ state == Stoplight::State::UNLOCKED
59
+ end
60
+
61
+ # @return [Hash]
62
+ def as_json
63
+ {
64
+ name: name,
65
+ color: color,
66
+ failures: failures,
67
+ locked: locked?
68
+ }
69
+ end
70
+
71
+ # @return [Array]
72
+ def default_sort_key
73
+ [-COLORS.index(color), name]
74
+ end
75
+
76
+ # @return [String, nil]
77
+ def last_check_in_words
78
+ last_error_time = latest_failure&.time
79
+ return unless last_error_time
80
+
81
+ time_difference = Time.now - last_error_time
82
+ if time_difference < 1
83
+ "just now"
84
+ elsif time_difference < 60
85
+ "#{time_difference.to_i}s ago"
86
+ elsif time_difference < 3600
87
+ "#{(time_difference / 60).to_i}m ago"
88
+ else
89
+ "#{(time_difference / 3600).to_i}h ago"
90
+ end
91
+ end
92
+
93
+ # @return [String]
94
+ def description_title
95
+ case color
96
+ when RED
97
+ if locked? && failures.empty?
98
+ "Locked Open"
99
+ else
100
+ "Last Error"
101
+ end
102
+ when Stoplight::Color::YELLOW
103
+ "Testing Recovery"
104
+ when GREEN
105
+ if locked?
106
+ "Forced Healthy"
107
+ else
108
+ "Healthy"
109
+ end
110
+ end
111
+ end
112
+
113
+ # @return [String]
114
+ def description_message
115
+ case color
116
+ when RED
117
+ if locked? && failures.empty?
118
+ "Circuit manually locked open"
119
+ else
120
+ "#{latest_failure.error_class}: #{latest_failure.error_message}"
121
+ end
122
+ when Stoplight::Color::YELLOW
123
+ "#{latest_failure.error_class}: #{latest_failure.error_message}"
124
+ when GREEN
125
+ if locked?
126
+ "Circuit manually locked closed"
127
+ else
128
+ "No recent errors"
129
+ end
130
+ end
131
+ end
132
+
133
+ # @return [String]
134
+ def description_comment
135
+ case color
136
+ when RED
137
+ if locked?
138
+ "Override active - all requests blocked"
139
+ else
140
+ "Will attempt recovery after cooling period"
141
+ end
142
+ when YELLOW
143
+ "Allowing limited test traffic (0 of 1 requests)"
144
+ when GREEN
145
+ if locked?
146
+ "Override active - all requests processed"
147
+ else
148
+ "Operating normally"
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ class LightsRepository
6
+ # @!attribute data_store
7
+ # @return [Stoplight::DataStore::Base]
8
+ attr_reader :data_store
9
+ private :data_store
10
+
11
+ # @param data_store [Stoplight::DataStore::Base]
12
+ def initialize(data_store:)
13
+ @data_store = data_store
14
+ end
15
+
16
+ # @return [<Stoplight::Admin::LightsRepository::Light>]
17
+ def all
18
+ data_store
19
+ .names
20
+ .map { |name| load_light(name) }
21
+ .sort_by(&:default_sort_key)
22
+ end
23
+
24
+ # @param colors <String>] colors name
25
+ # @return [<Stoplight::Admin::LightsRepository::Light>] lights with the requested colors
26
+ #
27
+ def with_color(*colors)
28
+ requested_colors = Array(colors)
29
+
30
+ all.select do |light|
31
+ requested_colors.include?(light.color)
32
+ end
33
+ end
34
+
35
+ # @param name [String] locks light by its name
36
+ # @param color [String, nil] locks to this color. When nil is given, locks to the current
37
+ # color
38
+ # @return [void]
39
+ def lock(name, color = nil)
40
+ light = build_light(name)
41
+
42
+ case color || light.color
43
+ when Stoplight::Color::GREEN
44
+ light.lock(Stoplight::Color::GREEN)
45
+ else
46
+ light.lock(Stoplight::Color::RED)
47
+ end
48
+ end
49
+
50
+ # @param name [String] unlocks light by its name
51
+ # @return [void]
52
+ def unlock(name)
53
+ build_light(name).unlock
54
+ end
55
+
56
+ private def load_light(name)
57
+ light = build_light(name)
58
+ # failures, state
59
+ metadata = data_store.get_metadata(light.config)
60
+
61
+ Light.new(
62
+ name: name,
63
+ color: light.color,
64
+ state: metadata.locked_state,
65
+ failures: [metadata.last_error].compact
66
+ )
67
+ end
68
+
69
+ private def build_light(name)
70
+ Stoplight(name, data_store: data_store)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stoplight
4
+ class Admin
5
+ class LightsStats
6
+ EMPTY_STATS = {
7
+ count_red: 0, count_yellow: 0, count_green: 0,
8
+ percent_red: 0, percent_yellow: 0, percent_green: 0
9
+ }.freeze
10
+
11
+ # @!attribute lights
12
+ # @return [<Stoplight::Admin::LightsRepository::Light>]
13
+ attr_reader :lights
14
+ private :lights
15
+
16
+ class << self
17
+ def call(lights)
18
+ new(lights).call
19
+ end
20
+ end
21
+
22
+ # @param lights [<Stoplight::Admin::LightsRepository::Light>]
23
+ def initialize(lights)
24
+ @lights = lights
25
+ end
26
+
27
+ def call
28
+ return EMPTY_STATS if size.zero?
29
+
30
+ EMPTY_STATS.merge(
31
+ count_red: count_red,
32
+ count_yellow: count_yellow,
33
+ count_green: count_green,
34
+ percent_red: percent_red,
35
+ percent_yellow: percent_yellow,
36
+ percent_green: percent_green
37
+ )
38
+ end
39
+
40
+ private def count_red
41
+ count_lights(RED)
42
+ end
43
+
44
+ private def percent_red
45
+ percent_lights(RED)
46
+ end
47
+
48
+ private def count_green
49
+ count_lights(GREEN)
50
+ end
51
+
52
+ private def percent_green
53
+ percent_lights(GREEN)
54
+ end
55
+
56
+ private def count_yellow
57
+ count_lights(YELLOW)
58
+ end
59
+
60
+ private def percent_yellow
61
+ percent_lights(YELLOW)
62
+ end
63
+
64
+ private def count_lights(color)
65
+ lights.count { |l| l.color == color }
66
+ end
67
+
68
+ private def percent_lights(color)
69
+ (100 * count_lights(color).fdiv(size)).ceil
70
+ end
71
+
72
+ private def size
73
+ lights.size
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,120 @@
1
+ <div class="max-w-xl mr-5 p-6 border border-gray-200 rounded-lg shadow-sm dark:bg-gray-800 dark:border-gray-700">
2
+ <div class="flex items-center">
3
+ <% light_name = ERB::Util.html_escape(light.name) %>
4
+
5
+ <div class="relative">
6
+ <div class="relative inline-flex items-center justify-center w-10 h-10 overflow-hidden bg-<%= color %>-100 rounded-full dark:bg-<%= color %>-600">
7
+ <span class="font-medium text-<%= color %>-600 dark:text-<%= color %>-300"><%= color[0].upcase %></span>
8
+ </div>
9
+
10
+ <div class="absolute inline-flex w-4 h-4 bg-white dark:bg-gray-800 border-2 border-white rounded-full bottom-0 right-0 dark:border-gray-900">
11
+ <svg class="w-3 h-3 text-<%=color %>-900 dark:text-<%=color %>-200" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
12
+ <% if light.locked? %>
13
+ <circle cx="12" cy="16" r="1"/>
14
+ <rect x="3" y="10" width="18" height="12" rx="2"/>
15
+ <path d="M7 10V7a5 5 0 0 1 10 0v3"/>
16
+ <% else %>
17
+ <circle cx="12" cy="16" r="1"/>
18
+ <rect width="18" height="12" x="3" y="10" rx="2"/>
19
+ <path d="M7 10V7a5 5 0 0 1 9.33-2.5"/>
20
+ <% end %>
21
+ </svg>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="flex-1 min-w-0 ms-4">
26
+ <p class="text-sm font-medium text-gray-900 truncate dark:text-white">
27
+ <%= light.name %>
28
+ </p>
29
+ <span class="flex items-center text-sm font-medium text-gray-500 dark:text-gray-400 truncate me-3">
30
+ <span class="flex w-2.5 h-2.5 bg-<%= color %>-600 rounded-full me-1.5 shrink-0"></span>
31
+ <% if light.color == "red" %>
32
+ Open
33
+ <% elsif light.color == "yellow" %>
34
+ Half-Open
35
+ <% else %>
36
+ Closed
37
+ <% end %>
38
+
39
+ <% if light.color == "yellow" %>
40
+ (Recovering)
41
+ <% elsif light.locked? %>
42
+ (Locked)
43
+ <% end %>
44
+ </span>
45
+ </div>
46
+
47
+ <button id="dropdownMenuIconHorizontalButton" data-dropdown-toggle="dropdownDotsHorizontal-<%= light.id %>" class="inline-flex items-center p-2 text-sm font-medium text-center text-gray-900 bg-white rounded-lg hover:bg-gray-100 focus:ring-4 focus:outline-none dark:text-white focus:ring-gray-50 dark:bg-gray-800 dark:hover:bg-gray-700 dark:focus:ring-gray-600" type="button">
48
+ <svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 3">
49
+ <path d="M2 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Zm6.041 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3ZM14 0a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3Z"/>
50
+ </svg>
51
+ </button>
52
+
53
+ <!-- Dropdown menu -->
54
+ <div id="dropdownDotsHorizontal-<%= light.id %>" class="z-10 hidden bg-white divide-y divide-gray-100 rounded-lg shadow-sm w-44 dark:bg-gray-700 dark:divide-gray-600">
55
+ <ul class="py-2 text-sm text-gray-700 dark:text-gray-200" aria-labelledby="dropdownMenuIconHorizontalButton">
56
+ <li>
57
+ <a href="<%= url("/unlock?names=#{light_name}") %>" data-turbo-method="post" class="flex items-center py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">
58
+ <svg class="flex w-4 h-4 me-1.5 shrink-0" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
59
+ <circle cx="12" cy="16" r="1"/>
60
+ <rect width="18" height="12" x="3" y="10" rx="2"/>
61
+ <path d="M7 10V7a5 5 0 0 1 9.33-2.5"/>
62
+ </svg>
63
+ Unlock
64
+ </a>
65
+ </li>
66
+ <li>
67
+ <a href="<%= url("/red?names=#{light_name}") %>" data-turbo-method="post" class="flex items-center py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">
68
+ <svg class="flex w-4 h-4 me-1.5 text-red-600 shrink-0" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
69
+ <circle cx="12" cy="16" r="1"/>
70
+ <rect x="3" y="10" width="18" height="12" rx="2"/>
71
+ <path d="M7 10V7a5 5 0 0 1 10 0v3"/>
72
+ </svg>
73
+ Lock Red
74
+ </a>
75
+ </li>
76
+ <li>
77
+ <a href="<%= url("/green?names=#{light_name}") %>" data-turbo-method="post" class="flex items-center py-2 px-4 text-sm hover:bg-gray-100 dark:hover:bg-gray-600 dark:hover:text-white">
78
+ <svg class="flex w-4 h-4 me-1.5 text-green-600 shrink-0" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
79
+ <circle cx="12" cy="16" r="1"/>
80
+ <rect x="3" y="10" width="18" height="12" rx="2"/>
81
+ <path d="M7 10V7a5 5 0 0 1 10 0v3"/>
82
+ </svg>
83
+ Lock Green
84
+ </a>
85
+ </li>
86
+ </ul>
87
+ </div>
88
+ </div>
89
+
90
+ <div class="p-4 my-4 text-sm text-<%= color %>-800 rounded-lg bg-<%= color %>-50 dark:bg-gray-800 dark:text-<%= color %>-400">
91
+ <div class="flex justify-between items-center">
92
+ <div class="pr-4">
93
+ <p class="font-semibold"><%= light.description_title %></p>
94
+ <p class="font-medium"><%= light.description_message %></p>
95
+ <p><%= light.description_comment %></p>
96
+ </div>
97
+ <% if light.latest_failure %>
98
+ <div class="whitespace-nowrap">
99
+ <%= Time.at(light.latest_failure.time).strftime("%T") %>
100
+ </div>
101
+ <% end %>
102
+ </div>
103
+ </div>
104
+
105
+ <!-- Stats Row -->
106
+ <div class="flex justify-between text-xs text-gray-500 dark:text-gray-400 mt-3">
107
+ <div>
108
+ <span class="font-medium">Failures:</span> <%= light.failures.count %>
109
+ </div>
110
+
111
+ <% light.last_check_in_words.then do |last_check| %>
112
+ <% if last_check %>
113
+ <div>
114
+ <span class="font-medium">Last Check:</span> <%= light.last_check_in_words %>
115
+ </div>
116
+ <% end %>
117
+ <% end %>
118
+ </div>
119
+ </div>
120
+