funktor 0.5.0 → 0.6.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +26 -11
- data/funktor-testapp/Gemfile.lock +2 -2
- data/funktor-testapp/app/services/job_flood.rb +1 -1
- data/funktor-testapp/app/workers/single_thread_audit_worker.rb +3 -0
- data/funktor-testapp/funktor_config/environment.yml +2 -2
- data/funktor-testapp/funktor_config/function_definitions/default_queue_handler.yml +3 -1
- data/funktor-testapp/funktor_config/function_definitions/incoming_job_handler.yml +3 -1
- data/funktor-testapp/funktor_config/function_definitions/job_activator.yml +1 -2
- data/funktor-testapp/funktor_config/function_definitions/low_concurrency_queue_handler.yml +13 -0
- data/funktor-testapp/funktor_config/funktor.yml +25 -25
- data/funktor-testapp/funktor_config/iam_permissions/{single_thread_queue.yml → low_concurrency_queue.yml} +1 -1
- data/funktor-testapp/funktor_config/resources/cloudwatch_dashboard.yml +22 -17
- data/funktor-testapp/funktor_config/resources/default_queue.yml +2 -2
- data/funktor-testapp/funktor_config/resources/incoming_job_queue.yml +2 -2
- data/funktor-testapp/funktor_config/resources/jobs_table.yml +16 -4
- data/funktor-testapp/funktor_config/resources/low_concurrency_queue.yml +22 -0
- data/funktor-testapp/funktor_init.yml +16 -8
- data/funktor-testapp/lambda_event_handlers/{single_thread_queue_handler.rb → low_concurrency_queue_handler.rb} +0 -0
- data/funktor-testapp/serverless.yml +4 -3
- data/funktor.gemspec +3 -1
- data/lib/funktor/activity_tracker.rb +6 -2
- data/lib/funktor/cli/templates/funktor_config/function_definitions/incoming_job_handler.yml +3 -1
- data/lib/funktor/cli/templates/funktor_config/function_definitions/job_activator.yml +1 -2
- data/lib/funktor/cli/templates/funktor_config/function_definitions/work_queue_handler.yml +3 -1
- data/lib/funktor/cli/templates/funktor_config/funktor.yml +6 -6
- data/lib/funktor/cli/templates/funktor_config/resources/cloudwatch_dashboard.yml +3 -2
- data/lib/funktor/cli/templates/funktor_config/resources/incoming_job_queue.yml +2 -2
- data/lib/funktor/cli/templates/funktor_config/resources/jobs_table.yml +16 -4
- data/lib/funktor/cli/templates/funktor_config/resources/work_queue.yml +2 -2
- data/lib/funktor/cli/templates/funktor_init.yml.tt +14 -8
- data/lib/funktor/cli/templates/serverless.yml +1 -0
- data/lib/funktor/incoming_job_handler.rb +11 -15
- data/lib/funktor/job.rb +50 -5
- data/lib/funktor/job_activator.rb +52 -26
- data/lib/funktor/shard_utils.rb +6 -0
- data/lib/funktor/testing.rb +1 -0
- data/lib/funktor/version.rb +1 -1
- data/lib/funktor/web/application.rb +139 -0
- data/lib/funktor/web/views/index.erb +3 -0
- data/lib/funktor/web/views/layout.erb +58 -0
- data/lib/funktor/web/views/processing.erb +29 -0
- data/lib/funktor/web/views/queued.erb +29 -0
- data/lib/funktor/web/views/retries.erb +35 -0
- data/lib/funktor/web/views/scheduled.erb +26 -0
- data/lib/funktor/web/views/stats.erb +9 -0
- data/lib/funktor/web/views/table_stats_with_buttons.erb +11 -0
- data/lib/funktor/web.rb +1 -0
- data/lib/funktor/work_queue_handler.rb +41 -0
- data/lib/funktor/worker/funktor_options.rb +3 -1
- data/lib/funktor/worker.rb +8 -11
- data/lib/funktor.rb +16 -16
- metadata +46 -6
- data/funktor-testapp/funktor_config/function_definitions/single_thread_queue_handler.yml +0 -11
- data/funktor-testapp/funktor_config/resources/single_thread_queue.yml +0 -22
@@ -26,6 +26,7 @@ provider:
|
|
26
26
|
lambdaHashingVersion: 20201221
|
27
27
|
environment: ${file(funktor_config/environment.yml)}
|
28
28
|
versionFunctions: false # Reduces the amount of storage used since all Lambdas together are limited to 75GB
|
29
|
+
logRetentionInDays: 7
|
29
30
|
iamRoleStatements:
|
30
31
|
<%- all_iam_permissions.each do |iam_permission| -%>
|
31
32
|
- ${file(<%= iam_permission %>)}
|
@@ -30,6 +30,11 @@ module Funktor
|
|
30
30
|
# TODO : This number should be configurable via ENV var
|
31
31
|
if job.delay < 60 # for now we're testing with just one minute * 5 # 5 minutes
|
32
32
|
Funktor.logger.debug "pushing to work queue for delay = #{job.delay}"
|
33
|
+
# We push to the jobs table first becauase the work queue handler will expect to be able
|
34
|
+
# to update the stats of a record that's already in the table.
|
35
|
+
# TODO : For time sensitive jobs this is probably less than optimal. Can we update the
|
36
|
+
# work queue handler to be ok with a job that's not yet in the table?
|
37
|
+
push_to_jobs_table(job, "queued")
|
33
38
|
push_to_work_queue(job)
|
34
39
|
if job.is_retry?
|
35
40
|
@tracker.track(:retryActivated, job)
|
@@ -38,7 +43,7 @@ module Funktor
|
|
38
43
|
end
|
39
44
|
else
|
40
45
|
Funktor.logger.debug "pushing to jobs table for delay = #{job.delay}"
|
41
|
-
push_to_jobs_table(job)
|
46
|
+
push_to_jobs_table(job, nil)
|
42
47
|
if job.is_retry?
|
43
48
|
# do nothing for tracking
|
44
49
|
else
|
@@ -49,18 +54,10 @@ module Funktor
|
|
49
54
|
end
|
50
55
|
end
|
51
56
|
|
52
|
-
def queue_for_job(job)
|
53
|
-
queue_name = job.queue || 'default'
|
54
|
-
queue_constant = "FUNKTOR_#{queue_name.underscore.upcase}_QUEUE"
|
55
|
-
Funktor.logger.debug "queue_constant = #{queue_constant}"
|
56
|
-
Funktor.logger.debug "ENV value = #{ENV[queue_constant]}"
|
57
|
-
ENV[queue_constant] || ENV['FUNKTOR_DEFAULT_QUEUE']
|
58
|
-
end
|
59
|
-
|
60
57
|
def push_to_work_queue(job)
|
61
58
|
Funktor.logger.debug "job = #{job.to_json}"
|
62
59
|
sqs_client.send_message({
|
63
|
-
queue_url:
|
60
|
+
queue_url: job.work_queue_url,
|
64
61
|
message_body: job.to_json,
|
65
62
|
delay_seconds: job.delay
|
66
63
|
})
|
@@ -70,16 +67,15 @@ module Funktor
|
|
70
67
|
ENV['FUNKTOR_JOBS_TABLE']
|
71
68
|
end
|
72
69
|
|
73
|
-
def push_to_jobs_table(job)
|
74
|
-
perform_at = (Time.now + job.delay).utc
|
70
|
+
def push_to_jobs_table(job, category = nil)
|
75
71
|
resp = dynamodb_client.put_item({
|
76
72
|
item: {
|
77
73
|
payload: job.to_json,
|
78
74
|
jobId: job.job_id,
|
79
|
-
performAt: perform_at.iso8601,
|
75
|
+
performAt: job.perform_at.iso8601,
|
80
76
|
jobShard: job.shard,
|
81
|
-
|
82
|
-
category: job.is_retry? ? "retry" : "scheduled"
|
77
|
+
queueable: category.present? ? "false" : "true",
|
78
|
+
category: category || (job.is_retry? ? "retry" : "scheduled")
|
83
79
|
},
|
84
80
|
table_name: delayed_job_table
|
85
81
|
})
|
data/lib/funktor/job.rb
CHANGED
@@ -1,5 +1,8 @@
|
|
1
|
+
require_relative 'shard_utils'
|
2
|
+
|
1
3
|
module Funktor
|
2
4
|
class Job
|
5
|
+
include ShardUtils
|
3
6
|
attr_accessor :job_string
|
4
7
|
attr_accessor :job_data
|
5
8
|
def initialize(job_string)
|
@@ -11,7 +14,15 @@ module Funktor
|
|
11
14
|
end
|
12
15
|
|
13
16
|
def queue
|
14
|
-
job_data["queue"]
|
17
|
+
job_data["queue"] || 'default'
|
18
|
+
end
|
19
|
+
|
20
|
+
def work_queue_url
|
21
|
+
queue_name = self.queue
|
22
|
+
queue_constant = "FUNKTOR_#{queue_name.underscore.upcase}_QUEUE"
|
23
|
+
Funktor.logger.debug "queue_constant = #{queue_constant}"
|
24
|
+
Funktor.logger.debug "ENV value = #{ENV[queue_constant]}"
|
25
|
+
ENV[queue_constant] || ENV['FUNKTOR_DEFAULT_QUEUE']
|
15
26
|
end
|
16
27
|
|
17
28
|
def worker_class_name
|
@@ -23,8 +34,7 @@ module Funktor
|
|
23
34
|
end
|
24
35
|
|
25
36
|
def shard
|
26
|
-
|
27
|
-
job_data["job_id"].hash % 64
|
37
|
+
calculate_shard(job_data["job_id"])
|
28
38
|
end
|
29
39
|
|
30
40
|
def worker_params
|
@@ -43,12 +53,43 @@ module Funktor
|
|
43
53
|
job_data["retries"] = retries
|
44
54
|
end
|
45
55
|
|
56
|
+
def perform_at
|
57
|
+
if job_data["perform_at"].present?
|
58
|
+
job_data["perform_at"].is_a?(Time) ? job_data["perform_at"] : Time.parse(job_data["perform_at"])
|
59
|
+
else
|
60
|
+
Time.now.utc
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
46
64
|
def delay
|
47
|
-
|
65
|
+
delay = (perform_at - Time.now.utc).to_i
|
66
|
+
if delay < 0
|
67
|
+
delay = 0
|
68
|
+
end
|
69
|
+
return delay
|
48
70
|
end
|
49
71
|
|
50
72
|
def delay=(delay)
|
51
|
-
job_data["
|
73
|
+
job_data["perform_at"] = Time.now.utc + delay
|
74
|
+
end
|
75
|
+
|
76
|
+
def error_class
|
77
|
+
job_data["error_class"]
|
78
|
+
end
|
79
|
+
|
80
|
+
def error_message
|
81
|
+
job_data["error_message"]
|
82
|
+
end
|
83
|
+
|
84
|
+
def error_backtrace
|
85
|
+
job_data["error_backtrace"].present? ? Funktor.parse_json(job_data["error_backtrace"]) : []
|
86
|
+
end
|
87
|
+
|
88
|
+
def error=(error)
|
89
|
+
# TODO We should maybe compress this?
|
90
|
+
job_data["error_class"] = error.class.name
|
91
|
+
job_data["error_message"] = error.message
|
92
|
+
job_data["error_backtrace"] = Funktor.dump_json(error.backtrace)
|
52
93
|
end
|
53
94
|
|
54
95
|
def execute
|
@@ -84,6 +125,10 @@ module Funktor
|
|
84
125
|
|
85
126
|
def retry_queue_url
|
86
127
|
worker_class&.custom_queue_url || ENV['FUNKTOR_INCOMING_JOB_QUEUE']
|
128
|
+
rescue NameError, TypeError
|
129
|
+
# In the web ui we may not have access to the the worker classes
|
130
|
+
# TODO : We should mayb handle this differently somehow? This just feels a bit icky...
|
131
|
+
ENV['FUNKTOR_INCOMING_JOB_QUEUE']
|
87
132
|
end
|
88
133
|
end
|
89
134
|
end
|
@@ -16,23 +16,25 @@ module Funktor
|
|
16
16
|
@sqs_client ||= ::Aws::SQS::Client.new
|
17
17
|
end
|
18
18
|
|
19
|
-
def active_job_queue
|
20
|
-
ENV['FUNKTOR_ACTIVE_JOB_QUEUE']
|
21
|
-
end
|
22
|
-
|
23
19
|
def delayed_job_table
|
24
20
|
ENV['FUNKTOR_JOBS_TABLE']
|
25
21
|
end
|
26
22
|
|
27
23
|
def jobs_to_activate
|
28
|
-
|
24
|
+
# TODO : The lookahead time here should be configurable
|
25
|
+
# If this doesn't match the setting in the IncomingJobHandler some jobs
|
26
|
+
# might be activated and then immediately re-scheduled instead of being
|
27
|
+
# queued, which leads to kind of confusing stats for the "incoming" stat.
|
28
|
+
# (Come to think of it, the incoming stat is kind of confusting anyway since
|
29
|
+
# it reflects retries and scheduled jobs activations...)
|
30
|
+
target_time = (Time.now + 60).utc
|
29
31
|
query_params = {
|
30
32
|
expression_attribute_values: {
|
31
|
-
":
|
33
|
+
":queueable" => "true",
|
32
34
|
":targetTime" => target_time.iso8601
|
33
35
|
},
|
34
|
-
key_condition_expression: "
|
35
|
-
projection_expression: "
|
36
|
+
key_condition_expression: "queueable = :queueable AND performAt < :targetTime",
|
37
|
+
projection_expression: "jobId, jobShard, category",
|
36
38
|
table_name: delayed_job_table,
|
37
39
|
index_name: "performAtIndex"
|
38
40
|
}
|
@@ -49,38 +51,62 @@ module Funktor
|
|
49
51
|
end
|
50
52
|
|
51
53
|
def handle_item(item)
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
delay = (Time.parse(item["performAt"]) - Time.now.utc).to_i
|
56
|
-
if delay < 0
|
57
|
-
delay = 0
|
58
|
-
end
|
54
|
+
job_shard = item["jobShard"]
|
55
|
+
job_id = item["jobId"]
|
56
|
+
current_category = item["category"]
|
59
57
|
Funktor.logger.debug "jobShard = #{item['jobShard']}"
|
60
58
|
Funktor.logger.debug "jobId = #{item['jobId']}"
|
61
|
-
|
62
|
-
|
63
|
-
|
59
|
+
Funktor.logger.debug "current_category = #{current_category}"
|
60
|
+
activate_job(job_shard, job_id, current_category)
|
61
|
+
end
|
62
|
+
|
63
|
+
def activate_job(job_shard, job_id, current_category, queue_immediately = false)
|
64
|
+
# First we conditionally update the item in Dynamo to be sure that another scheduler hasn't gotten
|
65
|
+
# to it, and if that works then send to SQS. This is basically how Sidekiq scheduler works.
|
66
|
+
response = dynamodb_client.update_item({
|
64
67
|
key: {
|
65
|
-
"jobShard" =>
|
66
|
-
"jobId" =>
|
68
|
+
"jobShard" => job_shard,
|
69
|
+
"jobId" => job_id
|
70
|
+
},
|
71
|
+
update_expression: "SET category = :category, queueable = :queueable",
|
72
|
+
condition_expression: "category = :current_category",
|
73
|
+
expression_attribute_values: {
|
74
|
+
":current_category" => current_category,
|
75
|
+
":queueable" => "false",
|
76
|
+
":category" => "queued"
|
67
77
|
},
|
68
78
|
table_name: delayed_job_table,
|
69
79
|
return_values: "ALL_OLD"
|
70
80
|
})
|
71
|
-
if response.attributes # this means the record was still there
|
81
|
+
if response.attributes # this means the record was still there in the state we expected
|
82
|
+
Funktor.logger.debug "response.attributes ====== "
|
83
|
+
Funktor.logger.debug response.attributes
|
84
|
+
job = Funktor::Job.new(response.attributes["payload"])
|
85
|
+
Funktor.logger.debug "we created a job from payload"
|
86
|
+
Funktor.logger.debug response.attributes["payload"]
|
87
|
+
Funktor.logger.debug "queueing to #{job.retry_queue_url}"
|
88
|
+
if queue_immediately
|
89
|
+
job.delay = 0
|
90
|
+
end
|
72
91
|
sqs_client.send_message({
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
delay_seconds: delay
|
92
|
+
queue_url: job.retry_queue_url,
|
93
|
+
message_body: job.to_json
|
94
|
+
#delay_seconds: job.delay
|
77
95
|
})
|
78
96
|
if job.is_retry?
|
79
|
-
|
97
|
+
# We don't track here because we send stuff back to the incoming job queue and we track the
|
98
|
+
# :retryActivated even there.
|
99
|
+
# TODO - Once we're sure this is all working right we can delete the commented out line.
|
100
|
+
#@tracker.track(:retryActivated, job)
|
80
101
|
else
|
81
102
|
@tracker.track(:scheduledJobActivated, job)
|
82
103
|
end
|
83
104
|
end
|
105
|
+
rescue ::Aws::DynamoDB::Errors::ConditionalCheckFailedException => e
|
106
|
+
# This means that a different instance of the JobActivator (or someone doing stuff in the web UI)
|
107
|
+
# got to the job first.
|
108
|
+
Funktor.logger.debug "#{e.to_s} : #{e.message}"
|
109
|
+
Funktor.logger.debug e.backtrace.join("\n")
|
84
110
|
end
|
85
111
|
|
86
112
|
def call(event:, context:)
|
data/lib/funktor/testing.rb
CHANGED
data/lib/funktor/version.rb
CHANGED
@@ -0,0 +1,139 @@
|
|
1
|
+
require 'sinatra/base'
|
2
|
+
require 'aws-sdk-dynamodb'
|
3
|
+
require_relative '../../funktor'
|
4
|
+
require_relative '../../funktor/shard_utils'
|
5
|
+
require_relative '../../funktor/activity_tracker'
|
6
|
+
|
7
|
+
module Funktor
|
8
|
+
module Web
|
9
|
+
class Application < Sinatra::Base
|
10
|
+
include ShardUtils
|
11
|
+
|
12
|
+
get '/' do
|
13
|
+
erb :index, layout: :layout, locals: {
|
14
|
+
activity_data: get_activity_data
|
15
|
+
}
|
16
|
+
end
|
17
|
+
|
18
|
+
get '/scheduled' do
|
19
|
+
erb :scheduled, layout: :layout, locals: {
|
20
|
+
activity_data: get_activity_data,
|
21
|
+
jobs: get_jobs('scheduled')
|
22
|
+
}
|
23
|
+
end
|
24
|
+
|
25
|
+
get '/retries' do
|
26
|
+
erb :retries, layout: :layout, locals: {
|
27
|
+
activity_data: get_activity_data,
|
28
|
+
jobs: get_jobs('retry')
|
29
|
+
}
|
30
|
+
end
|
31
|
+
|
32
|
+
get '/queued' do
|
33
|
+
erb :queued, layout: :layout, locals: {
|
34
|
+
activity_data: get_activity_data,
|
35
|
+
jobs: get_jobs('queued')
|
36
|
+
}
|
37
|
+
end
|
38
|
+
|
39
|
+
get '/processing' do
|
40
|
+
erb :processing, layout: :layout, locals: {
|
41
|
+
activity_data: get_activity_data,
|
42
|
+
jobs: get_jobs('processing')
|
43
|
+
}
|
44
|
+
end
|
45
|
+
|
46
|
+
post '/update_jobs' do
|
47
|
+
job_ids = params[:job_id]
|
48
|
+
if job_ids.is_a?(String)
|
49
|
+
job_ids = [job_ids]
|
50
|
+
end
|
51
|
+
job_ids ||= []
|
52
|
+
puts "params[:submit] = #{params[:submit]}"
|
53
|
+
puts "job_ids = #{job_ids}"
|
54
|
+
puts "params[:source] = #{params[:source]}"
|
55
|
+
if params[:submit] == "Delete Selected Jobs"
|
56
|
+
delete_jobs(job_ids, params[:source])
|
57
|
+
elsif params[:submit] == "Queue Selected Jobs"
|
58
|
+
queue_jobs(job_ids, params[:source])
|
59
|
+
end
|
60
|
+
redirect request.referrer
|
61
|
+
end
|
62
|
+
|
63
|
+
def get_jobs(category)
|
64
|
+
"Jobs of type #{category}"
|
65
|
+
query_params = {
|
66
|
+
expression_attribute_values: {
|
67
|
+
":category" => category
|
68
|
+
},
|
69
|
+
key_condition_expression: "category = :category",
|
70
|
+
projection_expression: "payload, performAt, jobId, jobShard",
|
71
|
+
table_name: ENV['FUNKTOR_JOBS_TABLE'],
|
72
|
+
index_name: "categoryIndex"
|
73
|
+
}
|
74
|
+
resp = dynamodb_client.query(query_params)
|
75
|
+
@items = resp.items
|
76
|
+
@jobs = @items.map{ |item| Funktor::Job.new(item["payload"]) }
|
77
|
+
return @jobs
|
78
|
+
end
|
79
|
+
|
80
|
+
def get_activity_data
|
81
|
+
query_params = {
|
82
|
+
expression_attribute_values: {
|
83
|
+
":category" => "stat"
|
84
|
+
},
|
85
|
+
key_condition_expression: "category = :category",
|
86
|
+
projection_expression: "statName, stat_value",
|
87
|
+
table_name: ENV['FUNKTOR_ACTIVITY_TABLE']
|
88
|
+
}
|
89
|
+
resp = dynamodb_client.query(query_params)
|
90
|
+
@activity_stats = {}
|
91
|
+
resp.items.each do |item|
|
92
|
+
@activity_stats[item["statName"]] = item["stat_value"].to_i
|
93
|
+
end
|
94
|
+
return @activity_stats
|
95
|
+
end
|
96
|
+
|
97
|
+
def queue_jobs(job_ids, source)
|
98
|
+
job_activator = Funktor::JobActivator.new
|
99
|
+
job_ids.each do |job_id|
|
100
|
+
job_shard = calculate_shard(job_id)
|
101
|
+
job_activator.activate_job(job_shard, job_id, source, true)
|
102
|
+
end
|
103
|
+
end
|
104
|
+
|
105
|
+
def delete_jobs(job_ids, source)
|
106
|
+
@tracker = Funktor::ActivityTracker.new
|
107
|
+
job_ids.each do |job_id|
|
108
|
+
delete_single_job(job_id, source)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def delete_single_job(job_id, source)
|
113
|
+
response = dynamodb_client.delete_item({
|
114
|
+
key: {
|
115
|
+
"jobShard" => calculate_shard(job_id),
|
116
|
+
"jobId" => job_id
|
117
|
+
},
|
118
|
+
table_name: ENV['FUNKTOR_JOBS_TABLE'],
|
119
|
+
return_values: "ALL_OLD"
|
120
|
+
})
|
121
|
+
if response.attributes # this means the record was still there
|
122
|
+
if source == "scheduled"
|
123
|
+
@tracker.track(:scheduledJobDeleted, nil)
|
124
|
+
elsif source == "retry"
|
125
|
+
@tracker.track(:retryDeleted, nil)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def dynamodb_client
|
131
|
+
@dynamodb_client ||= ::Aws::DynamoDB::Client.new
|
132
|
+
end
|
133
|
+
|
134
|
+
# start the server if ruby file executed directly
|
135
|
+
run! if app_file == $0
|
136
|
+
end
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
@@ -0,0 +1,58 @@
|
|
1
|
+
<html>
|
2
|
+
<head>
|
3
|
+
<link rel="stylesheet" href="https://unpkg.com/@picocss/pico@latest/css/pico.min.css">
|
4
|
+
<style type="text/css" media="screen">
|
5
|
+
/* Green Light scheme (Default) */
|
6
|
+
/* Can be forced with data-theme="light" */
|
7
|
+
[data-theme="light"],
|
8
|
+
:root:not([data-theme="dark"]) {
|
9
|
+
--primary: #43a047;
|
10
|
+
--primary-hover: #388e3c;
|
11
|
+
--primary-focus: rgba(67, 160, 71, 0.125);
|
12
|
+
--primary-inverse: #FFF;
|
13
|
+
}
|
14
|
+
|
15
|
+
/* Green Dark scheme (Auto) */
|
16
|
+
/* Automatically enabled if user has Dark mode enabled */
|
17
|
+
@media only screen and (prefers-color-scheme: dark) {
|
18
|
+
:root:not([data-theme="light"]) {
|
19
|
+
--primary: #43a047;
|
20
|
+
--primary-hover: #4caf50;
|
21
|
+
--primary-focus: rgba(67, 160, 71, 0.25);
|
22
|
+
--primary-inverse: #FFF;
|
23
|
+
}
|
24
|
+
}
|
25
|
+
|
26
|
+
/* Green Dark scheme (Forced) */
|
27
|
+
/* Enabled if forced with data-theme="dark" */
|
28
|
+
[data-theme="dark"] {
|
29
|
+
--primary: #43a047;
|
30
|
+
--primary-hover: #4caf50;
|
31
|
+
--primary-focus: rgba(67, 160, 71, 0.25);
|
32
|
+
--primary-inverse: #FFF;
|
33
|
+
}
|
34
|
+
|
35
|
+
/* Green (Common styles) */
|
36
|
+
:root {
|
37
|
+
--form-element-active-border-color: var(--primary);
|
38
|
+
--form-element-focus-color: var(--primary-focus);
|
39
|
+
--switch-color: var(--primary-inverse);
|
40
|
+
--switch-checked-background-color: var(--primary);
|
41
|
+
}
|
42
|
+
|
43
|
+
/* custom stuff for the funktor dashboard */
|
44
|
+
table.header h5{
|
45
|
+
margin-bottom: 0;
|
46
|
+
}
|
47
|
+
body > main{
|
48
|
+
padding: 0;
|
49
|
+
}
|
50
|
+
</style>
|
51
|
+
</head>
|
52
|
+
<body>
|
53
|
+
<%= erb :stats %>
|
54
|
+
<main class="container-fluid">
|
55
|
+
<%= yield %>
|
56
|
+
</main>
|
57
|
+
</body>
|
58
|
+
</html>
|