canvas_sync 0.17.17.beta1 → 0.17.23.beta1

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -1
  3. data/lib/canvas_sync/concerns/sync_mapping.rb +112 -0
  4. data/lib/canvas_sync/generators/install_generator.rb +1 -0
  5. data/lib/canvas_sync/generators/templates/migrations/create_grading_period_groups.rb +22 -0
  6. data/lib/canvas_sync/generators/templates/migrations/create_grading_periods.rb +22 -0
  7. data/lib/canvas_sync/generators/templates/migrations/create_user_observers.rb +17 -0
  8. data/lib/canvas_sync/generators/templates/models/grading_period.rb +8 -0
  9. data/lib/canvas_sync/generators/templates/models/grading_period_group.rb +9 -0
  10. data/lib/canvas_sync/generators/templates/models/user_observer.rb +11 -0
  11. data/lib/canvas_sync/importers/bulk_importer.rb +27 -16
  12. data/lib/canvas_sync/job_batches/batch.rb +9 -0
  13. data/lib/canvas_sync/job_batches/chain_builder.rb +9 -1
  14. data/lib/canvas_sync/job_batches/hier_batch_ids.lua +25 -0
  15. data/lib/canvas_sync/job_batches/sidekiq/web/batches_assets/css/styles.less +178 -0
  16. data/lib/canvas_sync/job_batches/sidekiq/web/batches_assets/js/batch_tree.js +106 -0
  17. data/lib/canvas_sync/job_batches/sidekiq/web/batches_assets/js/util.js +2 -0
  18. data/lib/canvas_sync/job_batches/sidekiq/web/views/_batch_tree.erb +6 -0
  19. data/lib/canvas_sync/job_batches/sidekiq/web/views/_common.erb +13 -0
  20. data/lib/canvas_sync/job_batches/sidekiq/web/views/batch.erb +15 -88
  21. data/lib/canvas_sync/job_batches/sidekiq/web.rb +93 -0
  22. data/lib/canvas_sync/jobs/begin_sync_chain_job.rb +1 -1
  23. data/lib/canvas_sync/jobs/report_checker.rb +37 -4
  24. data/lib/canvas_sync/jobs/report_starter.rb +2 -2
  25. data/lib/canvas_sync/processors/assignment_groups_processor.rb +1 -7
  26. data/lib/canvas_sync/processors/assignments_processor.rb +1 -7
  27. data/lib/canvas_sync/processors/context_module_items_processor.rb +1 -7
  28. data/lib/canvas_sync/processors/context_modules_processor.rb +1 -7
  29. data/lib/canvas_sync/processors/model_mappings.yml +68 -0
  30. data/lib/canvas_sync/processors/normal_processor.rb +3 -3
  31. data/lib/canvas_sync/processors/provisioning_report_processor.rb +21 -63
  32. data/lib/canvas_sync/processors/report_processor.rb +14 -9
  33. data/lib/canvas_sync/processors/submissions_processor.rb +1 -7
  34. data/lib/canvas_sync/record.rb +4 -0
  35. data/lib/canvas_sync/version.rb +1 -1
  36. data/lib/canvas_sync.rb +4 -1
  37. data/spec/canvas_sync/processors/provisioning_report_processor_spec.rb +40 -0
  38. data/spec/dummy/app/models/grading_period.rb +14 -0
  39. data/spec/dummy/app/models/grading_period_group.rb +15 -0
  40. data/spec/dummy/app/models/user_observer.rb +17 -0
  41. data/spec/dummy/db/migrate/20210907233329_create_user_observers.rb +23 -0
  42. data/spec/dummy/db/migrate/20210907233330_create_grading_periods.rb +28 -0
  43. data/spec/dummy/db/migrate/20210907233331_create_grading_period_groups.rb +28 -0
  44. data/spec/dummy/db/schema.rb +42 -1
  45. data/spec/dummy/log/development.log +1167 -0
  46. data/spec/dummy/log/test.log +2775 -0
  47. data/spec/support/fixtures/reports/grading_period_groups.csv +2 -0
  48. data/spec/support/fixtures/reports/grading_periods.csv +3 -0
  49. data/spec/support/fixtures/reports/user_observers.csv +3 -0
  50. metadata +38 -17
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 63d76061493c5aa510709d95f5d1e9828f47e675d0571c9a2afc76e2a2b92807
4
- data.tar.gz: 320ec24b43c5004c137287a11989dd6982436509f76c8745331ecb1ab6ad4702
3
+ metadata.gz: 6fe925c97d18da5736681b55e51a1702d727ef2900097c9058de6f247748af9f
4
+ data.tar.gz: 27c0bd4eb8d57efc705cbe3affdfa6ce4af9580b1b2374e11a8e7e84ddf2b8bd
5
5
  SHA512:
6
- metadata.gz: dd07a46eb04eeb95a069d516af6a361ca8051d1dce5254a41560ff072472e5090db1c049495b9f2a57a48cba456f127bd7e1df2e8510b205fc053e2398aceb3e
7
- data.tar.gz: 62767cc36c1cf7f6850072a9b7ea502e805da4218a4ae63f152f6453d1f02fbf144cca7f791f3ce848eb29c65af57832529f4b6a444e12342cd3aea4c4e77cf2
6
+ metadata.gz: 82f682b5e2b713ba2adc45483f215c1de8103dc16bdd3872a1656649527053a8819de35b50d1f4bc8e9961bedbc647fd7a03dbebbe4f2ad0fb3235d6754788be
7
+ data.tar.gz: 674159253800095ba66e15d9d14f58fd41d46f7fa70a33a8303160488544d721f7e8c83700c8a5e4978436e01d34e070b7bc22ff7433219e68fe00d4620dc38f
data/README.md CHANGED
@@ -190,7 +190,30 @@ Overrides are useful for two scenarios:
190
190
  - You have an existing application where the column names do not match up with what CanvasSync expects
191
191
  - You want to sync some other column in the report that CanvasSync is not configured to sync
192
192
 
193
- In order to create an override, place a file called `canvas_sync_provisioning_mapping.yml` in your Rails `config` directory. Define the tables and columns you want to override using the following format:
193
+ Mappings can be modified by editing the Model class like such:
194
+ ```ruby
195
+ class User < ApplicationRecord
196
+ include CanvasSync::Record
197
+
198
+ sync_mapping(reset: false) do # `reset: false` is the default
199
+ # The mapping can be totally cleared with `reset: true` in the `sync_mapping` call, or like such:
200
+ reset_links
201
+
202
+ # Add a new column:
203
+ link_column :column_in_report => :column_in_database, type: :datetime
204
+
205
+ # If the column name on the report and in the DB are the same, a shorthand can be used:
206
+ link_column :omit_from_final_grade, type: :datetime
207
+
208
+ # If the defaults define a column you don't want synced, you can remove it from the mapping:
209
+ unlink_column :column_in_database
210
+ end
211
+
212
+ # ...
213
+ end
214
+ ```
215
+
216
+ You can also create a file called `canvas_sync_provisioning_mapping.yml` in your Rails `config` directory. However, this approach requires you to re-specify the complete table in order to modify a table. Define the tables and columns you want to override using the following format:
194
217
 
195
218
  ```ruby
196
219
  users:
@@ -383,6 +406,27 @@ Available config options (if you add more, please update this!):
383
406
 
384
407
  * `config.classes_to_only_log_errors_on` - use this if you are utilizing the `CanvasSync::JobLog` table, but want certain classes to only persist in the `job_logs` table if an error is encountered. This is useful if you've got a very frequently used job that's filling up your database, and only really care about tracking failures.
385
408
 
409
+ ## Global Options
410
+ You can pass in global_options to a job chain. Global options are added to the batch_context and referenced by
411
+ various internal processes.
412
+
413
+ Pass global options into a job chain, using the options param nested in a :global key.
414
+ options: { global: {...} }
415
+
416
+ report_timeout (integer): Number of days until a Canvas report should timeout. Default is 1.
417
+ report_compilation_timeout (integer): Number of days until a Canvas report should timeout. Default is 1 hour.
418
+ You can likely pass a float to achieve sub-day timeouts, but not tested.
419
+ report_max_tries (integer): The number of times to attempt a report before giving up. A report is considered failed
420
+ if it has an 'error' status in Canvas or is deleted.
421
+
422
+ This is an example job chain with global options:
423
+ job_chain = CanvasSync.default_provisioning_report_chain(
424
+ MODELS_TO_SYNC,
425
+ term_scope: :active,
426
+ full_sync_every: 'sunday',
427
+ options: { global: { report_timeout: 2 } }
428
+ )
429
+
386
430
  ## Handling Job errors
387
431
 
388
432
  If you need custom handling for when a CanvasSync Job fails, you can add an `:on_failure` option to you Job Chain's `:global_options`.
@@ -0,0 +1,112 @@
1
+ module CanvasSync::Concerns
2
+ module SyncMapping
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ def sync_mapping(key = nil, reset: false, &blk)
7
+ key ||= Mapping.normalize_model_name(self)
8
+ key = key.to_s
9
+ existing_map = get_sync_mapping(key)
10
+ mapper = Mapping.new(existing_map&.deep_dup || {}.with_indifferent_access)
11
+ mapper.reset_links if reset
12
+ mapper.instance_exec(&blk)
13
+ @sync_mappings[key] = mapper.map_def.freeze
14
+ end
15
+
16
+ def get_sync_mapping(key = nil)
17
+ key ||= Mapping.normalize_model_name(self)
18
+ key = key.to_s
19
+ @sync_mappings ||= {}
20
+ @sync_mappings[key] || superclass.try(:get_sync_mapping, key) || Mapping.default_for(key)
21
+ end
22
+ end
23
+
24
+ class Mapping
25
+ attr_reader :map_def
26
+
27
+ def initialize(map_def = {}, model: nil)
28
+ @map_def = map_def
29
+ @model = model
30
+ end
31
+
32
+ def self.normalize_model_name(model)
33
+ model = model.name unless model.is_a?(String)
34
+ model.pluralize.underscore
35
+ end
36
+
37
+ def self.default_for(key)
38
+ default_mappings[key]
39
+ end
40
+
41
+ def self.default_mappings
42
+ @mappings ||= begin
43
+ maps = {}
44
+ default_v1_mappings.each do |mname, legacy|
45
+ m = maps[mname] = {}
46
+
47
+ m[:conflict_target] = Array(legacy[:conflict_target]).map(&:to_sym).map do |lct|
48
+ legacy[:report_columns][lct][:database_column_name]
49
+ end
50
+
51
+ m[:report_columns] = {}
52
+ legacy[:report_columns].each do |rcol, opts|
53
+ m[:report_columns][opts[:database_column_name]] = opts.except(:database_column_name).merge!(
54
+ report_column: rcol,
55
+ ).freeze
56
+ end
57
+ end
58
+ maps.with_indifferent_access.freeze
59
+ end
60
+ end
61
+
62
+ def self.default_v1_mappings
63
+ @legacy_mappings ||= begin
64
+ mapping = YAML.load_file(File.join(__dir__, '../processors', "model_mappings.yml")).deep_symbolize_keys!
65
+ override_filepath = Rails.root.join("config/canvas_sync_provisioning_mapping.yml")
66
+
67
+ if File.file?(override_filepath)
68
+ override = YAML.load_file(override_filepath).deep_symbolize_keys!
69
+ mapping = mapping.merge(override)
70
+ end
71
+
72
+ mapping.freeze
73
+ end
74
+ end
75
+
76
+ def conflict_target(*columns)
77
+ if columns.count == 0
78
+ @map_def[:conflict_target]
79
+ else
80
+ @map_def[:conflict_target] = columns.flatten.compact
81
+ end
82
+ end
83
+
84
+ def reset_links
85
+ @map_def = {}
86
+ end
87
+
88
+ def unlink_column(key)
89
+ @map_def.delete(key)
90
+ end
91
+
92
+ def link_column(m, type: nil, &blk)
93
+ if m.is_a?(Hash)
94
+ raise "Hash should have exactly 1 entry" if m && m.count != 1
95
+ @map_def[:report_columns][m.values[0]] = {
96
+ report_column: m.keys[0],
97
+ type: type,
98
+ transform: blk,
99
+ }
100
+ elsif m.is_a?(Symbol)
101
+ @map_def[:report_columns][m] = {
102
+ report_column: m,
103
+ type: type,
104
+ transform: blk,
105
+ }
106
+ else
107
+ raise "Cannot handle argument of type #{m.class}"
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -48,6 +48,7 @@ module CanvasSync
48
48
  models.each do |model|
49
49
  migration_template "migrations/create_#{model}.rb", "db/migrate/create_#{model}.rb"
50
50
  template "models/#{model.singularize}.rb", "app/models/#{model.singularize}.rb"
51
+ rescue
51
52
  end
52
53
  end
53
54
  end
@@ -0,0 +1,22 @@
1
+ # <%= autogenerated_migration_warning %>
2
+
3
+ class CreateGradingPeriodGroups < ActiveRecord::Migration[5.1]
4
+ def change
5
+ create_table :grading_period_groups do |t|
6
+ t.bigint :canvas_id, null: false
7
+ t.bigint :canvas_course_id
8
+ t.bigint :canvas_account_id
9
+ t.string :title
10
+ t.boolean :weighted
11
+ t.boolean :display_totals_for_all_grading_periods
12
+
13
+ t.string :workflow_state
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :grading_period_groups, :canvas_id, unique: true
19
+ add_index :grading_period_groups, :canvas_course_id
20
+ add_index :grading_period_groups, :canvas_account_id
21
+ end
22
+ end
@@ -0,0 +1,22 @@
1
+ # <%= autogenerated_migration_warning %>
2
+
3
+ class CreateGradingPeriods < ActiveRecord::Migration[5.1]
4
+ def change
5
+ create_table :grading_periods do |t|
6
+ t.bigint :canvas_id, null: false
7
+ t.string :title
8
+ t.float :weight
9
+ t.datetime :start_date
10
+ t.datetime :end_date
11
+ t.datetime :close_date
12
+ t.bigint :canvas_grading_period_group_id
13
+
14
+ t.string :workflow_state
15
+
16
+ t.timestamps
17
+ end
18
+
19
+ add_index :grading_periods, :canvas_id, unique: true
20
+ add_index :grading_periods, :canvas_grading_period_group_id
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ # <%= autogenerated_migration_warning %>
2
+
3
+ class CreateUserObservers < ActiveRecord::Migration[5.1]
4
+ def change
5
+ create_table :user_observers do |t|
6
+ t.bigint :observing_user_id
7
+ t.bigint :observed_user_id
8
+ t.string :workflow_state
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :user_observers, [:observed_user_id, :observing_user_id], unique: true
14
+ add_index :user_observers, :observing_user_id
15
+ add_index :user_observers, :observed_user_id
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ # <%= autogenerated_model_warning %>
2
+
3
+ class GradingPeriod < ApplicationRecord
4
+ include CanvasSync::Record
5
+
6
+ validates :canvas_id, uniqueness: true, presence: true
7
+ belongs_to :grading_period_group, primary_key: :canvas_id, foreign_key: :canvas_grading_period_group_id, optional: true
8
+ end
@@ -0,0 +1,9 @@
1
+ # <%= autogenerated_model_warning %>
2
+
3
+ class GradingPeriodGroup < ApplicationRecord
4
+ include CanvasSync::Record
5
+
6
+ validates :canvas_id, uniqueness: true, presence: true
7
+ belongs_to :course, primary_key: :canvas_id, foreign_key: :canvas_course_id, optional: true
8
+ belongs_to :account, primary_key: :canvas_id, foreign_key: :canvas_account_id, optional: true
9
+ end
@@ -0,0 +1,11 @@
1
+ # <%= autogenerated_model_warning %>
2
+
3
+ class UserObserver < ApplicationRecord
4
+ include CanvasSync::Record
5
+ include CanvasSync::Concerns::ApiSyncable
6
+
7
+ validates :canvas_id, uniqueness: true, presence: true
8
+
9
+ belongs_to :observing_user, primary_key: :canvas_id, foreign_key: :observing_user_id, class_name: 'User', optional: true
10
+ belongs_to :observed_user, primary_key: :canvas_id, foreign_key: :observed_user_id, class_name: 'User', optional: true
11
+ end
@@ -24,30 +24,41 @@ module CanvasSync
24
24
  end
25
25
 
26
26
  def self.perform_in_batches(report_file_path, mapping, klass, conflict_target, import_args: {})
27
- csv_column_names = mapping.keys
28
- database_column_names = mapping.values.map { |value| value[:database_column_name] }
29
- database_conflict_column_name = conflict_target ? mapping[conflict_target][:database_column_name] : nil
27
+ csv_column_names = mapping.values.map { |value| value[:report_column].to_s }
28
+ database_column_names = mapping.keys
29
+
30
+ conflict_target = Array(conflict_target).map(&:to_s)
31
+ conflict_target_indices = conflict_target.map{|ct| database_column_names.index(ct) }
30
32
 
31
33
  row_ids = {}
32
34
  batcher = CanvasSync::BatchProcessor.new(of: batch_size) do |batch|
33
35
  row_ids = {}
34
- perform_import(klass, database_column_names, batch, database_conflict_column_name, import_args)
36
+ perform_import(klass, database_column_names, batch, conflict_target, import_args)
35
37
  end
36
38
 
37
39
  row_buffer_out = ->(row) {
38
- if conflict_target
39
- next if row_ids[row[conflict_target]]
40
- row_ids[row[conflict_target]] = true
40
+ formatted_row = mapping.map do |db_col, col_def|
41
+ value = nil
42
+ value = row[col_def[:report_column]] if col_def[:report_column]
43
+
44
+ if col_def[:type]
45
+ if col_def[:type].to_sym == :datetime
46
+ # TODO: add some timezone config to the mapping.
47
+ # In cases where the timestamp or date doesn't include a timezone, you should be able to specify one
48
+ value = DateTime.parse(value).utc rescue nil # rubocop:disable Style/RescueModifier
49
+ end
50
+ end
51
+
52
+ value = col_def[:transform].call(value, row) if col_def[:transform]
53
+
54
+ value
41
55
  end
42
56
 
43
- formatted_row = csv_column_names.map do |column|
44
- if mapping[column][:type].to_sym == :datetime
45
- # TODO: add some timezone config to the mapping.
46
- # In cases where the timestamp or date doesn't include a timezone, you should be able to specify one
47
- DateTime.parse(row[column]).utc rescue nil # rubocop:disable Style/RescueModifier
48
- else
49
- row[column]
50
- end
57
+ if conflict_target.present?
58
+ key = conflict_target_indices.map{|ct| formatted_row[ct] }
59
+ next if row_ids[key]
60
+
61
+ row_ids[key] = true
51
62
  end
52
63
 
53
64
  batcher << formatted_row
@@ -79,7 +90,7 @@ module CanvasSync
79
90
  condition: condition_sql(klass, columns, import_args[:sync_start_time]),
80
91
  columns: columns
81
92
  }
82
- update_conditions[:conflict_target] = conflict_target if conflict_target
93
+ update_conditions[:conflict_target] = conflict_target if conflict_target.present?
83
94
 
84
95
  options = { validate: false, on_duplicate_key_update: update_conditions }.merge(import_args)
85
96
  options.delete(:on_duplicate_key_update) if options.key?(:on_duplicate_key_ignore)
@@ -28,6 +28,7 @@ module CanvasSync
28
28
 
29
29
  BID_EXPIRE_TTL = 2_592_000
30
30
  SCHEDULE_CALLBACK = RedisScript.new(Pathname.new(__FILE__) + "../schedule_callback.lua")
31
+ BID_HIERARCHY = RedisScript.new(Pathname.new(__FILE__) + "../hier_batch_ids.lua")
31
32
 
32
33
  attr_reader :bid
33
34
 
@@ -423,6 +424,14 @@ module CanvasSync
423
424
  def push_callbacks(args, queue)
424
425
  Batch::Callback::worker_class.enqueue_all(args, queue)
425
426
  end
427
+
428
+ def bid_hierarchy(bid, depth: 4, per_depth: 5, slice: nil)
429
+ args = [bid, depth, per_depth]
430
+ args << slice if slice
431
+ redis do |r|
432
+ BID_HIERARCHY.call(r, [], args)
433
+ end
434
+ end
426
435
  end
427
436
  end
428
437
 
@@ -40,7 +40,7 @@ module CanvasSync
40
40
  def insert_at(position, new_jobs)
41
41
  chain = self.class.get_chain_parameter(base_job)
42
42
  new_jobs = [new_jobs] unless new_jobs.is_a?(Array)
43
- chain.insert(-1, *new_jobs)
43
+ chain.insert(position, *new_jobs)
44
44
  end
45
45
 
46
46
  def insert(new_jobs, **kwargs)
@@ -172,6 +172,14 @@ module CanvasSync
172
172
  mapper[key] ||= []
173
173
  end
174
174
 
175
+ # TODO: Add a Chain progress web View
176
+ # Augment Batch tree-view with Chain data
177
+ # > [DONE] Tree view w/o Chain will only show Parent/Current batches and Job Counts
178
+ # > If augmented with Chain data, the above will be annotated with Chain-related info and will be able to show Jobs defined in the Chain
179
+ # > Chain-jobs will be supplied chain_id and chain_step_id metadata
180
+ # > Using server-middleware, if a Chain-job (has chain_id and chain_step_id) creates a Batch, tag the Batch w/ the chain_id and chain_step_id
181
+ # > UI will map Batches to Chain-steps using the chain_step_id. UI will add entries for any Chain-steps that were not tied to a Batch
182
+ # > [DONE] Use a Lua script to find child batch IDs. Support max_depth, items_per_depth, top_depth_slice parameters
175
183
  def enqueue_job(job_def)
176
184
  job_class = job_def[:job].constantize
177
185
  job_options = job_def[:parameters] || []
@@ -0,0 +1,25 @@
1
+
2
+ local function add_bids(root, depth)
3
+ local result_data = {}
4
+
5
+ if depth > 0 then
6
+ local sbids
7
+ if depth == tonumber(ARGV[2]) and ARGV[4] then
8
+ local min, max = ARGV[4]:match('(%d+):(%d+)')
9
+ sbids = redis.call('ZRANGE', 'BID-' .. root .. '-bids', min, max)
10
+ else
11
+ sbids = redis.call('ZRANGE', 'BID-' .. root .. '-bids', 0, tonumber(ARGV[3]) - 1)
12
+ end
13
+
14
+ local sub_data = {}
15
+ for _,v in ipairs(sbids) do
16
+ table.insert(sub_data, add_bids(v, depth - 1))
17
+ end
18
+
19
+ return { root, sub_data }
20
+ end
21
+
22
+ return { root, result_data}
23
+ end
24
+
25
+ return add_bids(ARGV[1], tonumber(ARGV[2]))
@@ -0,0 +1,178 @@
1
+
2
+ @color-green: #25c766;
3
+ @color-red: #c7254e;
4
+ @color-yellow: #c4c725;
5
+
6
+ .code-wrap.batch-context .args-extended {
7
+ white-space: pre;
8
+
9
+ .key {
10
+ white-space: pre-wrap;
11
+ margin-left: 2em;
12
+ text-indent: -2em;
13
+ display: inline-block;
14
+ }
15
+
16
+ .own {
17
+ color: @color-green;
18
+ }
19
+ }
20
+
21
+
22
+ .batch-tree {
23
+ .status-block {
24
+ .tree-stat {
25
+ margin: 0 4px;
26
+
27
+ &.pending {
28
+ color: @color-yellow;
29
+ }
30
+ &.failed {
31
+ color: @color-red;
32
+ }
33
+ &.success {
34
+ color: @color-green;
35
+ }
36
+ &.total {
37
+
38
+ }
39
+ }
40
+ }
41
+
42
+ .text-inactive {
43
+ color: darken(#fff, 35%);
44
+ font-size: 80%;
45
+ }
46
+
47
+ .tree-header {
48
+ position: relative;
49
+
50
+ .status-block {
51
+ position: absolute;
52
+ bottom: 0;
53
+ width: 100%;
54
+
55
+ margin-right: 8px;
56
+ font-size: 90%;
57
+ text-align: right;
58
+
59
+ .tree-stat {
60
+ font-style: italic;
61
+ }
62
+ }
63
+ }
64
+
65
+ .tree-entry {
66
+ > .header {
67
+ display: flex;
68
+ align-items: center;
69
+
70
+ .header-inner {
71
+ padding: 4px 0;
72
+ border-bottom: 1px dashed white;
73
+ display: flex;
74
+ align-items: center;
75
+ flex: 1;
76
+ }
77
+
78
+ &:hover {
79
+ background-color: rgba(0,0,0,0.20);
80
+ border-radius: 3px;
81
+ }
82
+
83
+ .row-toggle {
84
+ width: 16px;
85
+ height: 16px;
86
+ text-align: center;
87
+ align-self: center;
88
+ border-radius: 50%;
89
+ border: 1px solid #999;
90
+ text-decoration: none;
91
+ margin: 0 4px;
92
+ font-size: 16px;
93
+ line-height: 15px;
94
+
95
+ &.not_applicable {
96
+ opacity: 0;
97
+ pointer-events: none;
98
+ }
99
+ }
100
+
101
+ .main {
102
+ flex: 1;
103
+ display: flex;
104
+ align-items: baseline;
105
+
106
+ .bid {
107
+ font-family: monospace;
108
+ padding: 3px 6px;
109
+ background: rgba(0,0,0,0.2);
110
+ border-radius: 3px;
111
+ font-size: 12px;
112
+ margin: 0 12px 0 0;
113
+
114
+ &:hover {
115
+ .bid-goto {
116
+ display: inline-block;
117
+ padding: 0 0 0 4px;
118
+ font-size: 200%;
119
+ line-height: 10px;
120
+ vertical-align: sub;
121
+ text-decoration: dotted;
122
+ }
123
+ }
124
+
125
+ .bid-goto {
126
+ display: none;
127
+ }
128
+ }
129
+ }
130
+
131
+ .goto-link {
132
+ margin: 0 8px;
133
+ display: inline-block;
134
+ height: 16px;
135
+ font-size: 90%;
136
+ border-bottom: 1px dotted white;
137
+ }
138
+
139
+ .status-label {
140
+ font-family: monospace;
141
+ padding: 3px 6px;
142
+ background: rgba(0,0,0,0.2);
143
+ border-radius: 3px;
144
+ font-size: 12px;
145
+ margin: 0 12px 0 0;
146
+
147
+ &.deleted {
148
+ background: #99999933;
149
+ }
150
+ &.failed, &.complete {
151
+ background: #99000033;
152
+ }
153
+ &.success {
154
+ background: #00990033;
155
+ }
156
+ }
157
+
158
+ .status-block {
159
+ width: 10em;
160
+ text-align: center;
161
+ }
162
+ }
163
+
164
+ > .subitems {
165
+ padding-left: 16px;
166
+
167
+ >.load-more {
168
+ padding: 4px 0;
169
+ text-align: center;
170
+ border-bottom: 1px dashed white;
171
+ a {
172
+ border-bottom: 1px dotted white;
173
+ text-decoration: none;
174
+ }
175
+ }
176
+ }
177
+ }
178
+ }