canvas_sync 0.17.17.beta1 → 0.17.23.beta1

Sign up to get free protection for your applications and to get access to all the features.
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
+ }