このコミットが含まれているのは:
2026-11-01 18:18:54 +09:00
コミット 22e57564a5
18個のファイルの変更、313行の追加、121行の削除
+70 -63
ファイルの表示
@@ -18,7 +18,7 @@ class CountsExportsController < ApplicationController
render json: { error: 'private_condition' }, status: :forbidden render json: { error: 'private_condition' }, status: :forbidden
rescue CountsTableQuery::TooManyColumns rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS }, render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity status: :unprocessable_entity
rescue ActiveRecord::StatementTimeout rescue ActiveRecord::StatementTimeout
render json: { error: 'counts_query_timeout' }, status: :service_unavailable render json: { error: 'counts_query_timeout' }, status: :service_unavailable
rescue ArgumentError, TypeError, KeyError, JSON::ParserError rescue ArgumentError, TypeError, KeyError, JSON::ParserError
@@ -86,68 +86,6 @@ class CountsExportsController < ApplicationController
disposition: 'attachment' disposition: 'attachment'
end end
def confirmed_download
owner = session[:counts_export_owner]
arguments = { file_token: params[:token], attempt: params[:attempt] }
return head :not_found unless arguments[:attempt].present? &&
CountsExportJobs.download_attempt(params[:job], owner, **arguments)
file = CountsExportDownload.find(params[:token], owner)
unless file
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'unavailable')
return head :not_found
end
job = params[:job]
authority = HistoryAccess.user
first_check = true
stream = CountsExportDownload::ResponseBody.new(file.fetch('path')) do |status|
if status == 'active'
if first_check
first_check = false
fresh = authority && User.find_by(id: authority.id)
next false if authority && (!fresh || fresh.banned?)
authority = fresh
HistoryAccess.set(user: authority) do
CountsExportDownload.find(arguments[:file_token], owner) &&
CountsExportJobs.stream_active?(job, owner, **arguments)
end
else
CountsExportJobs.stream_active?(job, owner, **arguments)
end
else
HistoryAccess.set(user: authority) do
CountsExportJobs.download_attempt(job, owner, **arguments, status:)
end
end
end
unless CountsExportJobs.download_attempt(job, owner, **arguments, status: 'streaming')
stream.close
return head :conflict
end
response.headers['Cache-Control'] = 'private, no-store'
send_file_headers! filename: "counts-#{ file.fetch('resource') }.#{ file.fetch('format') }",
type: file['format'] == 'json' ? 'application/json' : 'text/csv',
disposition: 'attachment'
response.headers['Last-Modified'] = File.mtime(file.fetch('path')).httpdate
response.headers['X-Accel-Buffering'] = 'no'
self.response_body = stream
rescue IOError, SystemCallError
stream&.close
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'failed')
head :service_unavailable
end
private :confirmed_download
def job_state(cancel:)
result = CountsExportJobs.status(params[:token], session[:counts_export_owner], cancel:)
return head :not_found unless result
response.headers['Cache-Control'] = 'private, no-store'
render json: result
end
private :job_state
def index def index
locale = resolve_locale! locale = resolve_locale!
payload = request.request_parameters.except('authenticity_token') payload = request.request_parameters.except('authenticity_token')
@@ -175,4 +113,73 @@ class CountsExportsController < ApplicationController
rescue ArgumentError, TypeError, KeyError, JSON::ParserError rescue ArgumentError, TypeError, KeyError, JSON::ParserError
head :bad_request head :bad_request
end end
private
def confirmed_download
owner = session[:counts_export_owner]
arguments = { file_token: params[:token], attempt: params[:attempt] }
if (!(arguments[:attempt].present?) ||
!(CountsExportJobs.download_attempt(params[:job], owner, **arguments)))
return head :not_found
end
file = CountsExportDownload.find(params[:token], owner)
unless file
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'unavailable')
return head :not_found
end
job = params[:job]
authority = HistoryAccess.user
first_check = true
state = lambda do |status|
if status == 'active'
if first_check
first_check = false
fresh = authority && User.find_by(id: authority.id)
next false if authority && (!fresh || fresh.banned?)
authority = fresh
HistoryAccess.set(user: authority) do
CountsExportDownload.find(arguments[:file_token], owner) &&
CountsExportJobs.stream_active?(job, owner, **arguments)
end
else
CountsExportJobs.stream_active?(job, owner, **arguments)
end
else
HistoryAccess.set(user: authority) do
CountsExportJobs.download_attempt(job, owner, **arguments, status:)
end
end
end
unless CountsExportJobs.download_attempt(job, owner, **arguments, status: 'streaming')
return head :conflict
end
response.headers['Cache-Control'] = 'private, no-store'
response.headers['Last-Modified'] = File.mtime(file.fetch('path')).httpdate
response.headers['X-Accel-Buffering'] = 'no'
send_file file.fetch('path'),
filename: "counts-#{ file.fetch('resource') }.#{ file.fetch('format') }",
type: file['format'] == 'json' ? 'application/json' : 'text/csv',
disposition: 'attachment'
# Keep Rails' bounded FileBody reader, but hide to_path from Rack::Sendfile:
# offloading would bypass the GET acknowledgement and cancellation checks.
stream = CountsExportDownload::ResponseBody.new(response.stream, &state)
self.response_body = stream
rescue IOError, SystemCallError
stream&.close
CountsExportJobs.download_attempt(params[:job], owner, **arguments, status: 'failed')
raise if response.committed?
head :service_unavailable
end
def job_state cancel:
result = CountsExportJobs.status(params[:token], session[:counts_export_owner], cancel:)
return head :not_found unless result
response.headers['Cache-Control'] = 'private, no-store'
render json: result
end
end end
+5
ファイルの表示
@@ -25,6 +25,11 @@ class CountsTablesController < ApplicationController
rescue CountsTableQuery::TooManyColumns rescue CountsTableQuery::TooManyColumns
render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS }, render json: { error: 'too_many_columns', max_columns: CountsTableQuery::MAX_COLUMNS },
status: :unprocessable_entity status: :unprocessable_entity
rescue CountsPivotTotals::TimedOut
Rails.logger.warn('counts_query_timeout: pivot_totals')
render json: { error: 'counts_query_timeout', stage: 'totals',
timeout_seconds: CountsTableQuery::SQL_TIMEOUT_SECONDS },
status: :service_unavailable
rescue ActiveRecord::StatementTimeout => error rescue ActiveRecord::StatementTimeout => error
Rails.logger.warn("counts_query_timeout: #{ error.class }") Rails.logger.warn("counts_query_timeout: #{ error.class }")
render json: { error: 'counts_query_timeout' }, status: :service_unavailable render json: { error: 'counts_query_timeout' }, status: :service_unavailable
+21 -14
ファイルの表示
@@ -15,26 +15,38 @@ class CountsExportDownload
# Rack body deliberately has no to_path: sendfile middleware must not bypass ACK. # Rack body deliberately has no to_path: sendfile middleware must not bypass ACK.
class ResponseBody class ResponseBody
def initialize(path, &state) def initialize body, &state
File.open(path, 'rb') { |file| file.stat } @body = body
@path = path
@stream = nil
@closed = false @closed = false
@state = state @state = state
end end
def each def each &block
return enum_for(:each) unless block
return if @closed return if @closed
@stream = File.open(@path, 'rb') transmit(&block)
end
def close
return if @closed
@closed = true
@body.close if @body.respond_to?(:close)
end
private
def transmit
first = true first = true
while (chunk = @stream.read(64 * 1024)) @body.each do |chunk|
raise IOError, 'Download no longer authorised' unless @state.call('active') raise IOError, 'Download no longer authorised' unless @state.call('active')
yield chunk yield chunk
if first if first
# This confirms yielding to Rack, never browser receipt or saving. # Rack accepted a chunk; browser receipt and saving are not guaranteed.
@state.call('started') raise IOError, 'Download acknowledgement failed' unless @state.call('started')
first = false first = false
end end
end end
@@ -46,11 +58,6 @@ class CountsExportDownload
ensure ensure
close close
end end
def close
@closed = true
@stream.close if @stream && !@stream.closed?
end
end end
def self.directory def self.directory
+1 -1
ファイルの表示
@@ -157,7 +157,7 @@ class CountsExportProjection
def csv_display(value, key) def csv_display(value, key)
if key.start_with?('aliases_') && value.is_a?(Array) if key.start_with?('aliases_') && value.is_a?(Array)
return value.map { fetch(_1, 'name') }.compact.join("\n") return value.map { fetch(_1, 'name') }.compact.join(' ')
end end
return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil? return @labels.fetch('bot', '') if key == 'created_by_user' && value.nil?
return @labels.fetch("event_type:#{ value }", value) if key == 'event_type' return @labels.fetch("event_type:#{ value }", value) if key == 'event_type'
+18 -9
ファイルの表示
@@ -1,5 +1,6 @@
# Re-aggregate distinct membership; tag row tuples alone use additive contributions. # Re-aggregate distinct membership; tag row tuples alone use additive contributions.
class CountsPivotTotals class CountsPivotTotals
class TimedOut < ActiveRecord::StatementTimeout; end
def initialize(population, axes, row_headers, column_headers) def initialize(population, axes, row_headers, column_headers)
@population = population @population = population
@axes = axes @axes = axes
@@ -27,20 +28,26 @@ class CountsPivotTotals
retained = additive ? (0...@axes.rows.length).to_a | indexes : indexes retained = additive ? (0...@axes.rows.length).to_a | indexes : indexes
joins = @axes.joins(retained.map { @axes.all[_1].fetch('field') }) joins = @axes.joins(retained.map { @axes.all[_1].fetch('field') })
scope = scope.joins(joins) unless joins.empty? scope = scope.joins(joins) unless joins.empty?
predicates = []
if row_depth.positive? if row_depth.positive?
tuples = headers.map { _1[:values] }.uniq tuples = headers.map { _1[:values] }.uniq
predicate = tuples.map { Array.new(row_depth) { |i| predicate = tuples.map { Array.new(row_depth) { |i|
"(#{ expressions[i] }) <=> ?" "a#{ indexes[i] } <=> ?"
}.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ') }.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ')
scope = scope.where(predicate, *tuples.flatten) predicates << @population.definition.model.sanitize_sql_array([
tuples.empty? ? 'FALSE' : "(#{ predicate })", *tuples.flatten])
end end
if column_depth.positive? if column_depth.positive?
tuples = columns.map { _1[:values] }.uniq tuples = columns.map { _1[:values] }.uniq
predicate = tuples.map { Array.new(column_depth) { |i| predicate = tuples.map { Array.new(column_depth) { |i|
"(#{ expressions[row_depth + i] }) <=> ?" "a#{ indexes[row_depth + i] } <=> ?"
}.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ') }.join(' AND ') }.map { "(#{ _1 })" }.join(' OR ')
scope = scope.where(predicate, *tuples.flatten) predicates << @population.definition.model.sanitize_sql_array([
tuples.empty? ? 'FALSE' : "(#{ predicate })", *tuples.flatten])
end end
# Filter projected coordinates, not repeated masking/time expressions.
# Each member still participates in the complete DISTINCT population.
selection = predicates.empty? ? '' : " WHERE #{ predicates.join(' AND ') }"
grouped = indexes.each_with_index.map { |key, index| grouped = indexes.each_with_index.map { |key, index|
"a#{ key } AS v#{ index }" "a#{ key } AS v#{ index }"
} }
@@ -56,19 +63,19 @@ class CountsPivotTotals
output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] + output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
projected + padding + ['SUM(count) AS count'] projected + padding + ['SUM(count) AS count']
grouping = coordinates.empty? ? '' : " GROUP BY #{ coordinates.join(', ') }" grouping = coordinates.empty? ? '' : " GROUP BY #{ coordinates.join(', ') }"
"SELECT #{ output.join(', ') } FROM (#{ inner }) leaf#{ grouping }" "SELECT #{ output.join(', ') } FROM (#{ inner }) leaf#{ selection }#{ grouping }"
else else
inner = @axes.grouped_sql(scope, indexes) inner = @axes.grouped_sql(scope, indexes)
"SELECT #{ fields.join(', ') } FROM (#{ inner }) grouped" "SELECT #{ fields.join(', ') } FROM (#{ inner }) grouped#{ selection }"
end end
end end
end end
return [] if queries.empty? return [] if queries.empty?
sql = queries.map { "(#{ _1 })" }.join(' UNION ALL ') sql = queries.map { "(#{ _1 })" }.join(' UNION ALL ')
@population.definition.model.connection.select_all( statement = "WITH population_members AS (#{ basis }) " +
"WITH population_members AS (#{ basis }) " + "SELECT /*+ #{ CountsTableQuery::SQL_HINTS.join(' ') } */ * FROM (#{ sql }) totals"
"SELECT /*+ MAX_EXECUTION_TIME(10000) */ * FROM (#{ sql }) totals").map do |record| @population.definition.model.connection.select_all(statement).map do |record|
row_depth = record['row_depth'].to_i row_depth = record['row_depth'].to_i
column_depth = record['column_depth'].to_i column_depth = record['column_depth'].to_i
{ row: (0...row_depth).map { record["v#{ _1 }"] }, { row: (0...row_depth).map { record["v#{ _1 }"] },
@@ -78,5 +85,7 @@ class CountsPivotTotals
row_depth, column_depth), row_depth, column_depth),
count: record['count'].to_i } count: record['count'].to_i }
end end
rescue ActiveRecord::StatementTimeout
raise TimedOut, 'Counts pivot totals exceeded the query time limit'
end end
end end
+25 -3
ファイルの表示
@@ -1,6 +1,7 @@
# Common entrypoint for six version resources; legacy endpoints keep their contracts. # Common entrypoint for six version resources; legacy endpoints keep their contracts.
class CountsTableQuery class CountsTableQuery
SQL_HINTS = ['MAX_EXECUTION_TIME(10000)', SQL_TIMEOUT_SECONDS = 30
SQL_HINTS = ["MAX_EXECUTION_TIME(#{ SQL_TIMEOUT_SECONDS * 1000 })",
"SET_VAR(optimizer_switch='semijoin=off')"].freeze "SET_VAR(optimizer_switch='semijoin=off')"].freeze
MAX_COLUMNS = 100 MAX_COLUMNS = 100
class TooManyColumns < ArgumentError; end class TooManyColumns < ArgumentError; end
@@ -176,7 +177,7 @@ class CountsTableQuery
end end
def details def details
query = population.scope.select(Arel.sql( query = population.scope.optimizer_hints(*SQL_HINTS).select(Arel.sql(
"(#{ population.current_latest_sql }) AS counts_latest")) "(#{ population.current_latest_sql }) AS counts_latest"))
order = sort_sql { detail_expression(_1) } order = sort_sql { detail_expression(_1) }
query = query.order(Arel.sql((order + ['v.id ASC']).join(', '))) query = query.order(Arel.sql((order + ['v.id ASC']).join(', ')))
@@ -373,7 +374,9 @@ class CountsTableQuery
return "#{ column } IS NULL ASC, (#{ column } <=> #{ masked }) ASC, " + return "#{ column } IS NULL ASC, (#{ column } <=> #{ masked }) ASC, " +
"#{ expression } #{ direction.upcase }" "#{ expression } #{ direction.upcase }"
end end
if field == 'category' if field == 'event_type'
expression = event_order_sql(column)
elsif field == 'category'
# Tag's enum preserves the same category order as TagsController. # Tag's enum preserves the same category order as TagsController.
cases = Tag.categories.keys.each_with_index.map { |category, index| cases = Tag.categories.keys.each_with_index.map { |category, index|
"WHEN #{ @connection.quote(category) } THEN #{ index }" "WHEN #{ @connection.quote(category) } THEN #{ index }"
@@ -398,6 +401,7 @@ class CountsTableQuery
def detail_expression(key) def detail_expression(key)
return "(#{ population.current_latest_sql })" if key == 'latest' return "(#{ population.current_latest_sql })" if key == 'latest'
return event_order_sql(@definition.expression(key)) if key == 'event_type'
return @definition.name_expression(key) if @definition.resource == 'tags' && return @definition.name_expression(key) if @definition.resource == 'tags' &&
@definition.name_column(key) @definition.name_column(key)
aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ? aliases = { 'tags' => 'tags_json', 'parents' => @definition.resource == 'posts' ?
@@ -409,6 +413,11 @@ class CountsTableQuery
"CAST(#{ expression } AS CHAR) COLLATE utf8mb4_bin" "CAST(#{ expression } AS CHAR) COLLATE utf8mb4_bin"
end end
def event_order_sql(expression)
"CASE #{ expression } WHEN 'create' THEN 0 WHEN 'update' THEN 1 " +
"WHEN 'discard' THEN 2 WHEN 'restore' THEN 3 ELSE 4 END"
end
def sort_sql def sort_sql
@sort.flat_map do |key, direction| @sort.flat_map do |key, direction|
expression = yield key expression = yield key
@@ -423,6 +432,7 @@ class CountsTableQuery
values = conditions.map { _1['value'] }.compact.uniq values = conditions.map { _1['value'] }.compact.uniq
labels = case field labels = case field
when 'tags' then tag_labels(values) when 'tags' then tag_labels(values)
when 'parent_tag_ids' then parent_tag_labels(values)
when 'user' when 'user'
actors = HistoryAccess.visible(population.scope, 'v').except(:select, :order) actors = HistoryAccess.visible(population.scope, 'v').except(:select, :order)
.select('v.created_by_user_id') .select('v.created_by_user_id')
@@ -452,6 +462,7 @@ class CountsTableQuery
labels = tag_labels(values) labels = tag_labels(values)
@axis_names["a#{ index }"] = @tag_names @axis_names["a#{ index }"] = @tag_names
labels labels
when 'parent_tag_ids' then parent_tag_labels(values)
else {} else {}
end end
result["a#{ index }"] = labels result["a#{ index }"] = labels
@@ -459,6 +470,17 @@ class CountsTableQuery
result result
end end
def parent_tag_labels(values)
ids = values.flat_map { _1.to_s.split }.uniq
tag_labels(ids.map { "internal:#{ _1 }" })
values.to_h do |value|
names = value.to_s.split.map do |id|
@tag_names.fetch("internal:#{ id }", {}).dig(locale.language_code, 'name')
end
[value.to_s, names.compact.join(' ')]
end
end
def tag_labels(values) def tag_labels(values)
internal = values.grep(/\Ainternal:/).map { _1.split(':').last.to_i } internal = values.grep(/\Ainternal:/).map { _1.split(':').last.to_i }
external = values.grep(/\Aexternal:/).map { _1.split(':').last.to_i } external = values.grep(/\Aexternal:/).map { _1.split(':').last.to_i }
+24 -2
ファイルの表示
@@ -14,7 +14,7 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do
allow(CountsExportDownload).to receive(:directory).and_return(@counts_download_dir) allow(CountsExportDownload).to receive(:directory).and_return(@counts_download_dir)
end end
def ready_job(format) def ready_job(format, payload: 'payload')
CountsExportJobs.pulse CountsExportJobs.pulse
post '/counts/export/jobs', params: { resource: 'posts', definition: 'history', post '/counts/export/jobs', params: { resource: 'posts', definition: 'history',
mode: 'details', columns: 'resource_id', locale: 'ja', format: } mode: 'details', columns: 'resource_id', locale: 'ja', format: }
@@ -22,7 +22,7 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do
job = response.parsed_body.fetch('job') job = response.parsed_body.fetch('job')
signature = CountsExportJobs.verifier.verified(job, purpose: 'counts_export_job') signature = CountsExportJobs.verifier.verified(job, purpose: 'counts_export_job')
owner = signature.fetch('owner') owner = signature.fetch('owner')
exporter = instance_double(CountsExporter, resource: 'posts', format:, body: ['payload']) exporter = instance_double(CountsExporter, resource: 'posts', format:, body: [payload])
file = HistoryAccess.set(user: nil) { CountsExportDownload.prepare(exporter, owner) } file = HistoryAccess.set(user: nil) { CountsExportDownload.prepare(exporter, owner) }
state = CountsExportJobs.read(signature.fetch('id')) state = CountsExportJobs.read(signature.fetch('id'))
state.merge!('status' => 'ready', 'token' => file) state.merge!('status' => 'ready', 'token' => file)
@@ -33,6 +33,28 @@ RSpec.describe 'Counts native GET acknowledgement', type: :request do
end end
['csv', 'json'].each do |format| ['csv', 'json'].each do |format|
it "delivers every byte of a multi-chunk #{ format } file through HTTP" do
sign_in_as(create(:user, :member))
rows = Array.new(5000) { |index| { id: index, name: '日本語,名称' } }
payload = if format == 'json'
rows.to_json
else
"id,name\r\n" + rows.map { CSV.generate_line(_1.values) }.join
end
job, file, attempt = ready_job(format, payload:)
owner = CountsExportJobs.verifier.verified(job, purpose: 'counts_export_job').fetch('owner')
metadata = HistoryAccess.set(user: nil) { CountsExportDownload.find(file, owner) }
expected = File.binread(metadata.fetch('path'))
expect(expected.bytesize).to be > 64 * 1024
get "/counts/export/files/#{ file }", params: { job:, attempt: }
expect(response).to have_http_status(:ok)
expect(response.body.bytesize).to eq(expected.bytesize)
expect(response.body.b).to eq(expected)
expect(response.body.b).to eq(payload.b)
get "/counts/export/jobs/#{ job }"
expect(response.parsed_body['download_status']).to eq('started')
end
it "acknowledges only an authorised #{ format } GET body, not HEAD or acceptance" do it "acknowledges only an authorised #{ format } GET body, not HEAD or acceptance" do
job, file, attempt = ready_job(format) job, file, attempt = ready_job(format)
get "/counts/export/jobs/#{ job }" get "/counts/export/jobs/#{ job }"
+26
ファイルの表示
@@ -50,6 +50,32 @@ RSpec.describe 'Counts table API', type: :request do
def versions(result) = result.fetch('versions').map { _1.fetch('version_no') } def versions(result) = result.fetch('versions').map { _1.fetch('version_no') }
it 'identifies a totals timeout without exposing SQL in the error response' do
allow_any_instance_of(CountsPivotTotals).to receive(:call)
.and_raise(CountsPivotTotals::TimedOut, 'private SQL')
get '/counts/query', params: base.merge(mode: 'summary', rows: 'created_at:month')
expect(response).to have_http_status(:service_unavailable)
expect(response.parsed_body).to eq('error' => 'counts_query_timeout',
'stage' => 'totals', 'timeout_seconds' => 30)
expect(response.body).not_to include('private SQL')
end
it 'applies the same optimizer safeguards to the paginated detail SELECT' do
statements = []
listener = lambda do |_name, _start, _finish, _id, payload|
statements << payload[:sql]
end
ActiveSupport::Notifications.subscribed(listener, 'sql.active_record') do
result = query(tags: 'alpha', limit: 1)
expect(result['count']).to eq(2)
expect(versions(result)).to eq([1])
end
details = statements.find { _1.include?('AS counts_latest') }
expect(details).not_to be_nil
expect(details).to include('MAX_EXECUTION_TIME(30000)',
"SET_VAR(optimizer_switch='semijoin=off')")
end
it 'separates latest and history and defaults to stable detail ordering and limit 100' do it 'separates latest and history and defaults to stable detail ordering and limit 100' do
history = query history = query
expect(versions(history)).to eq([1, 2, 3]) expect(versions(history)).to eq([1, 2, 3])
+23 -3
ファイルの表示
@@ -211,18 +211,37 @@ RSpec.describe 'Counts download acknowledgement' do
end end
RSpec.describe CountsExportDownload::ResponseBody do RSpec.describe CountsExportDownload::ResponseBody do
it 'survives Rails buffer enumeration without closing before the first byte' do
Dir.mktmpdir('counts-body-spec') do |directory|
path = File.join(directory, 'file')
payload = 'data' * 20_000
File.binwrite(path, payload)
states = []
source = ActionDispatch::Response::FileBody.new(path)
body = described_class.new(source) { |state| states << state; true }
response = ActionDispatch::Response.new
response.body = body
deferred = response.stream.to_ary
expect(states).to eq([])
expect(deferred.to_a.join).to eq(payload)
expect(states.count('started')).to eq(1)
end
end
it 'acknowledges only after the first Rack yield and closes its bounded file body' do it 'acknowledges only after the first Rack yield and closes its bounded file body' do
Dir.mktmpdir('counts-body-spec') do |directory| Dir.mktmpdir('counts-body-spec') do |directory|
path = File.join(directory, 'file') path = File.join(directory, 'file')
File.binwrite(path, 'x' * (64 * 1024 + 3)) File.binwrite(path, 'x' * (64 * 1024 + 3))
states = [] states = []
body = described_class.new(path) { |state| states << state; true } source = ActionDispatch::Response::FileBody.new(path)
body = described_class.new(source) { |state| states << state; true }
chunks = [] chunks = []
body.each do |chunk| body.each do |chunk|
expect(states).not_to include('started') if chunks.empty? expect(states).not_to include('started') if chunks.empty?
chunks << chunk chunks << chunk
end end
expect(chunks.map(&:bytesize)).to eq([64 * 1024, 3]) expect(chunks.join).to eq('x' * (64 * 1024 + 3))
expect(chunks.map(&:bytesize).max).to be <= 64 * 1024
expect(states.count('started')).to eq(1) expect(states.count('started')).to eq(1)
expect(body).not_to respond_to(:to_path) expect(body).not_to respond_to(:to_path)
expect { body.close }.not_to raise_error expect { body.close }.not_to raise_error
@@ -234,7 +253,8 @@ RSpec.describe CountsExportDownload::ResponseBody do
path = File.join(directory, 'file') path = File.join(directory, 'file')
File.binwrite(path, 'data') File.binwrite(path, 'data')
states = [] states = []
body = described_class.new(path) { |state| states << state; true } source = ActionDispatch::Response::FileBody.new(path)
body = described_class.new(source) { |state| states << state; true }
expect { body.each { raise IOError, 'connection closed' } }.to raise_error(IOError) expect { body.each { raise IOError, 'connection closed' } }.to raise_error(IOError)
expect(states).to include('failed') expect(states).to include('failed')
expect(states).not_to include('started') expect(states).not_to include('started')
+14
ファイルの表示
@@ -44,6 +44,20 @@ RSpec.describe 'Counts masked pivot contracts' do
result.fetch(:pivot_totals).find { _1[:row].empty? && _1[:column].empty? }.fetch(:count) result.fetch(:pivot_totals).find { _1[:row].empty? && _1[:column].empty? }.fetch(:count)
end end
it 'preserves monthly name subtotals and grand totals across pages' do
settings = { 'resource' => 'tags', 'rows' => 'created_at:month name_ja' }
complete = result(admin, settings)
paged = result(admin, settings.merge('limit' => '1'))
expect(complete[:group_count]).to eq(3)
expect(grand(complete)).to eq(3)
expect(grand(paged)).to eq(3)
subtotal = paged[:pivot_totals].find do |cell|
cell[:row] == ['2023-01'] && cell[:column].empty?
end
expect(subtotal[:count]).to eq(3)
expect(paged[:cells].sum { _1[:count] }).to eq(1)
end
it 'preserves five tag occurrences after merging private tags and across pages' do it 'preserves five tag occurrences after merging private tags and across pages' do
[nil, guest, member, admin].each do |user| [nil, guest, member, admin].each do |user|
page = result(user) page = result(user)
+3 -2
ファイルの表示
@@ -3,7 +3,8 @@ import { countsSummaryValue } from '@/lib/countsDisplay'
import PrefetchLink from '@/components/PrefetchLink' import PrefetchLink from '@/components/PrefetchLink'
import CountsSelectableTable from '@/components/counts/CountsSelectableTable' import CountsSelectableTable from '@/components/counts/CountsSelectableTable'
import CountsSortHeader from '@/components/counts/CountsSortHeader' import CountsSortHeader from '@/components/counts/CountsSortHeader'
import { changeCountSort, countsMaskedValue, countsRequestParams, readCountSort, writeCountSort } import { changeCountSort, countsMaskedValue, countsRequestParams, countsResourcePath,
readCountSort, writeCountSort }
from '@/lib/counts' from '@/lib/counts'
import { countsAxisLabel, useCountsDefinition } import { countsAxisLabel, useCountsDefinition }
from '@/lib/countsDefinitions' from '@/lib/countsDefinitions'
@@ -97,7 +98,7 @@ const CountsSummaryTable = (
next.set ('mode', 'details') next.set ('mode', 'details')
next.delete ('sort') next.delete ('sort')
next.delete ('page') next.delete ('page')
return `/counts/${resource}?${next}` return `${countsResourcePath (resource)}?${next}`
} }
const rowLabels = rowHeaders.map (header => { const rowLabels = rowHeaders.map (header => {
const labels = names (header, rows, 0) const labels = names (header, rows, 0)
+25 -1
ファイルの表示
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams, import { columnsForCounts, countColumns, countsCSV, countsDefinitionParams, countsResourcePath,
countsRequestParams, normaliseCountsParams, selectedCountChildren } from '@/lib/counts' countsRequestParams, normaliseCountsParams, selectedCountChildren } from '@/lib/counts'
import { countsErrorMessage, countsConditionValue, countsNestedIsScalar, countsNestedScalarText } import { countsErrorMessage, countsConditionValue, countsNestedIsScalar, countsNestedScalarText }
from '@/lib/countsDisplay' from '@/lib/countsDisplay'
@@ -14,6 +14,30 @@ import type { CountsCondition } from '@/lib/countsDefinitions'
describe ('Counts URL contract', () => { describe ('Counts URL contract', () => {
it ('distinguishes HTTP, network and internal failures without server details', () => {
const http = { isAxiosError: true, response: { status: 503, data: '<private SQL>' } }
expect (countsErrorMessage (http, 'ja').text).toContain ('HTTP 503')
expect (countsErrorMessage (http, 'ja').text).not.toContain ('private SQL')
expect (countsErrorMessage ({ isAxiosError: true }, 'ja').text)
.toBe ('APIサーバーとの通信に失敗しました。')
expect (countsErrorMessage (new Error ('private stack'), 'ja').text)
.toBe ('内部処理で問題が発生しました。')
})
it ('explains a totals timeout using the server time limit', () => {
const failure = { isAxiosError: true, response: { status: 503,
data: { error: 'counts_query_timeout', stage: 'totals', timeout_seconds: 30 } } }
const text = countsErrorMessage (failure, 'ja').text
expect (text).toContain ('集計処理が制限時間を超えました。')
expect (text).toContain ('小計・総計の算出が30秒以内に完了しませんでした。')
expect (text).toContain ('集計期間や行・列の項目を減らして')
expect (countsErrorMessage (failure, 'ain-Kana').language).toBe ('ja')
})
it ('uses hyphenated page paths for internal resource identifiers', () => {
expect (countsResourcePath ('nico_tags')).toBe ('/counts/nico-tags')
expect (countsResourcePath ('tag_descriptions')).toBe ('/counts/tag-descriptions')
expect (countsResourcePath ('tag_groups')).toBe ('/counts/tag-groups')
expect (countsResourcePath ('posts')).toBe ('/counts/posts')
})
it ('defaults to latest summary, limit 100, and no axes', () => { it ('defaults to latest summary, limit 100, and no axes', () => {
const params = normaliseCountsParams ('posts', new URLSearchParams (), postDefinition) const params = normaliseCountsParams ('posts', new URLSearchParams (), postDefinition)
expect (params.get ('definition')).toBe ('latest') expect (params.get ('definition')).toBe ('latest')
+4
ファイルの表示
@@ -12,6 +12,10 @@ export const countsMaskedValue = '__counts_private__'
export type CountsResource = 'posts' | 'tags' | 'nico_tags' export type CountsResource = 'posts' | 'tags' | 'nico_tags'
| 'tag_descriptions' | 'tag_groups' | 'materials' | 'tag_descriptions' | 'tag_groups' | 'materials'
export const countsResourcePath = (resource: CountsResource): string =>
`/counts/${resource.replaceAll ('_', '-')}`
export type CountsName = TagNameSnapshot & { export type CountsName = TagNameSnapshot & {
languageCode: string; languageName?: string | null; scriptName?: string | null } languageCode: string; languageName?: string | null; scriptName?: string | null }
export type CountsTag = { export type CountsTag = {
+13 -3
ファイルの表示
@@ -17,7 +17,8 @@ import type { LocaleCode } from '@/lib/locales'
export const countsErrorMessage = (failure: unknown, locale: LocaleCode) => { export const countsErrorMessage = (failure: unknown, locale: LocaleCode) => {
const response = isApiError (failure) ? failure.response : null const response = isApiError (failure) ? failure.response : null
const body = (response?.data ?? failure) as { const body = (response?.data ?? failure) as {
error?: unknown; max_axes?: unknown; maxAxes?: unknown error?: unknown; stage?: unknown; timeout_seconds?: unknown; timeoutSeconds?: unknown
max_axes?: unknown; maxAxes?: unknown
max_columns?: unknown; maxColumns?: unknown } | null max_columns?: unknown; maxColumns?: unknown } | null
const code = body != null && typeof body === 'object' ? body.error : null const code = body != null && typeof body === 'object' ? body.error : null
const message = (key: string, values?: Record<string, string | number>) => const message = (key: string, values?: Record<string, string | number>) =>
@@ -31,6 +32,11 @@ export const countsErrorMessage = (failure: unknown, locale: LocaleCode) => {
max: maximum }) max: maximum })
} }
if (code === 'private_condition') return message ('privateCondition') if (code === 'private_condition') return message ('privateCondition')
if (code === 'counts_query_timeout' && body?.stage === 'totals')
{
const seconds = body.timeoutSeconds ?? body.timeout_seconds
if (typeof seconds === 'number') return message ('errors.totalsTimeout', { seconds })
}
if (code === 'counts_query_timeout' || code === 'generation_timeout') if (code === 'counts_query_timeout' || code === 'generation_timeout')
return message ('errors.queryTimeout') return message ('errors.queryTimeout')
if (code === 'export_worker_unavailable' || code === 'worker_unavailable') if (code === 'export_worker_unavailable' || code === 'worker_unavailable')
@@ -40,7 +46,9 @@ export const countsErrorMessage = (failure: unknown, locale: LocaleCode) => {
if (response?.status === 401) return message ('errors.authentication') if (response?.status === 401) return message ('errors.authentication')
if (response?.status === 403) return message ('errors.permission') if (response?.status === 403) return message ('errors.permission')
if (response?.status === 400 || response?.status === 422) return message ('errors.input') if (response?.status === 400 || response?.status === 422) return message ('errors.input')
return message ('common:fetchError') if (response != null) return message ('errors.http', { status: response.status })
if (isApiError (failure)) return message ('errors.network')
return message ('errors.internal')
} }
@@ -158,7 +166,7 @@ export const countsLogicalName = (
if (value == null) return { text: '' } if (value == null) return { text: '' }
if (field.startsWith ('aliases_') && Array.isArray (value)) if (field.startsWith ('aliases_') && Array.isArray (value))
return { text: value.map (entry => typeof entry === 'string' ? entry return { text: value.map (entry => typeof entry === 'string' ? entry
: String (entry?.name ?? '')).join ('\n') } : String (entry?.name ?? '')).join (' ') }
if (field === 'sections') if (field === 'sections')
return { text: (countsSectionRanges (value, locale) ?? []).join ( return { text: (countsSectionRanges (value, locale) ?? []).join (
translateUIMessage ('sections.separator', 'counts', locale).text) } translateUIMessage ('sections.separator', 'counts', locale).text) }
@@ -528,6 +536,8 @@ export const countsSummaryValue = (
const logical = data.axisLabels?.[`a${index}`]?.[String (value)] const logical = data.axisLabels?.[`a${index}`]?.[String (value)]
if (axis.field === 'user') return message ('actorWithId', { if (axis.field === 'user') return message ('actorWithId', {
name: logical || message ('notRegistered'), id: value }) name: logical || message ('notRegistered'), id: value })
if (axis.field === 'parent_tag_ids')
return String (value) === '' ? '' : logical || message ('notRegistered')
if (logical != null) return logical if (logical != null) return logical
if (axis.field === 'tags') if (axis.field === 'tags')
return message ('notRegistered') return message ('notRegistered')
+2 -2
ファイルの表示
@@ -1,4 +1,4 @@
import { isCountsState, selectedCountFields } from '@/lib/counts' import { countsResourcePath, isCountsState, selectedCountFields } from '@/lib/counts'
import { translateUIMessage } from '@/lib/i18n' import { translateUIMessage } from '@/lib/i18n'
import type { CountField, CountsResource, CountsResult } from '@/lib/counts' import type { CountField, CountsResource, CountsResult } from '@/lib/counts'
@@ -90,5 +90,5 @@ export const countsSummaryDrill = (
next.set ('before', next.set ('before',
(before != null && Date.parse (before) < Date.parse (last)) ? before : last) (before != null && Date.parse (before) < Date.parse (last)) ? before : last)
} }
return `/counts/${resource}?${next}` return `${countsResourcePath (resource)}?${next}`
} }
+7
ファイルの表示
@@ -1,4 +1,6 @@
{ {
"_untranslated": ["errors.totalsTimeout", "errors.http", "errors.network",
"errors.internal", "errors.exportStart"],
"titles": { "titles": {
"posts": "ミンタㇻ ウコピㇱキ", "posts": "ミンタㇻ ウコピㇱキ",
"tags": "シロシ ウコピㇱキ", "tags": "シロシ ウコピㇱキ",
@@ -301,6 +303,11 @@
"axisLimit": "ウコピㇱキ アㇰシㇱ {{max}} パㇰノ ネ。イキㇼ ネワ コラム オルㇱペ ポンノ カㇻ ヤン。", "axisLimit": "ウコピㇱキ アㇰシㇱ {{max}} パㇰノ ネ。イキㇼ ネワ コラム オルㇱペ ポンノ カㇻ ヤン。",
"privateCondition": "ソモ アヌカレ オルㇱペ アシッケウ クス、タン イフナラ イアヌ アニ ウコピㇱキ エアイカㇷ゚。", "privateCondition": "ソモ アヌカレ オルㇱペ アシッケウ クス、タン イフナラ イアヌ アニ ウコピㇱキ エアイカㇷ゚。",
"errors": { "errors": {
"http": "HTTP {{status}} エラーが発生しました。管理者にサーバーログの確認を依頼してください。",
"network": "APIサーバーとの通信に失敗しました。",
"internal": "内部処理で問題が発生しました。",
"exportStart": "エクスポートを開始できませんでした。",
"totalsTimeout": "集計処理が制限時間を超えました。\n\n小計・総計の算出が{{seconds}}秒以内に完了しませんでした。集計期間や行・列の項目を減らして再実行してください。",
"queryTimeout": "ウコピㇱキ イカㇻ トキ オケレ。イフナラ イアヌ ネ ヤ ウコピㇱキ アㇰシㇱ ポンノ カㇻ ワ スイ キ ヤン。", "queryTimeout": "ウコピㇱキ イカㇻ トキ オケレ。イフナラ イアヌ ネ ヤ ウコピㇱキ アㇰシㇱ ポンノ カㇻ ワ スイ キ ヤン。",
"workerUnavailable": "エクスポート ワーカー イカㇻ ソモ キ。", "workerUnavailable": "エクスポート ワーカー イカㇻ ソモ キ。",
"stopped": "エクスポート イカㇻ オケレ ソモ キ。スイ キ ヤン。", "stopped": "エクスポート イカㇻ オケレ ソモ キ。スイ キ ヤン。",
+7 -2
ファイルの表示
@@ -181,7 +181,7 @@
"blank": "空欄", "blank": "空欄",
"additionalConditions": "追加条件", "additionalConditions": "追加条件",
"changedField": "変更項目", "changedField": "変更項目",
"eventType": "異動区分", "eventType": "異動等の事実",
"count": "件数", "count": "件数",
"outputTotal": "件数:{{value}} 件", "outputTotal": "件数:{{value}} 件",
"excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)", "excludedRecords": "(閲覧権限のないレコード {{count}} 件が除外されました)",
@@ -246,7 +246,7 @@
"sourceLanguage": "翻訳元の言語", "sourceLanguage": "翻訳元の言語",
"source_language_code": "翻訳元の言語", "source_language_code": "翻訳元の言語",
"source_version_no": "翻訳元の版番号", "source_version_no": "翻訳元の版番号",
"event_type": "異動区分", "event_type": "異動等の事実",
"body": "本文", "body": "本文",
"linked_tags": "対応タグ", "linked_tags": "対応タグ",
"description": "説明", "description": "説明",
@@ -301,6 +301,11 @@
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。", "axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。", "privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。",
"errors": { "errors": {
"http": "HTTP {{status}} エラーが発生しました。管理者にサーバーログの確認を依頼してください。",
"network": "APIサーバーとの通信に失敗しました。",
"internal": "内部処理で問題が発生しました。",
"exportStart": "エクスポートを開始できませんでした。",
"totalsTimeout": "集計処理が制限時間を超えました。\n\n小計・総計の算出が{{seconds}}秒以内に完了しませんでした。集計期間や行・列の項目を減らして再実行してください。",
"queryTimeout": "集計処理が時間制限を超えました。抽出条件や集計軸を減らして再実行してください。", "queryTimeout": "集計処理が時間制限を超えました。抽出条件や集計軸を減らして再実行してください。",
"workerUnavailable": "エクスポートワーカーが稼働していません。", "workerUnavailable": "エクスポートワーカーが稼働していません。",
"stopped": "エクスポート処理が停止しました。再実行してください。", "stopped": "エクスポート処理が停止しました。再実行してください。",
+25 -16
ファイルの表示
@@ -1,4 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { LoaderCircle } from 'lucide-react'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
@@ -15,7 +16,8 @@ import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea' import MainArea from '@/components/layout/MainArea'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { countsRequestParams, countsDefinitionParams, countsExportPayload, fieldsForCounts, fetchCounts, import { countsResourcePath, countsRequestParams, countsDefinitionParams,
countsExportPayload, fieldsForCounts, fetchCounts,
isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts' isCountsState, normaliseCountsParams, selectedCountColumns } from '@/lib/counts'
import { countsErrorMessage, countsExportLabels } from '@/lib/countsDisplay' import { countsErrorMessage, countsExportLabels } from '@/lib/countsDisplay'
import { isApiError } from '@/lib/api' import { isApiError } from '@/lib/api'
@@ -52,10 +54,10 @@ const tooManyColumns = (failure: unknown): number | null => {
const CountsResourceContent = ( const CountsResourceContent = (
{ resource, definition, definitionError }: { { resource, definition, definitionError, definitionFailure }: {
resource: CountsResource resource: CountsResource
definition?: CountsDefinition definition?: CountsDefinition
definitionError: boolean }, definitionError: boolean; definitionFailure?: unknown },
) => { ) => {
const queryClient = useQueryClient () const queryClient = useQueryClient ()
const [mounted, setMounted] = useState (false) const [mounted, setMounted] = useState (false)
@@ -106,7 +108,7 @@ const CountsResourceContent = (
const requestKey = request.toString () const requestKey = request.toString ()
const [exporting, setExporting] = useState (false) const [exporting, setExporting] = useState (false)
const exportInFlight = useRef (false) const exportInFlight = useRef (false)
const [exportError, setExportError] = useState (false) const [exportError, setExportError] = useState<{ failure: unknown } | null> (null)
const { data: cachedData, error: queryError, dataUpdatedAt, isFetching: fetching, const { data: cachedData, error: queryError, dataUpdatedAt, isFetching: fetching,
isPending: pending, isError: errorFlg, isPending: pending, isError: errorFlg,
isPlaceholderData: placeholder } = useQuery ({ isPlaceholderData: placeholder } = useQuery ({
@@ -236,7 +238,7 @@ const CountsResourceContent = (
const waiting = running || (!definitionError && (definition == null const waiting = running || (!definitionError && (definition == null
|| pending || fetching || placeholder || pending || fetching || placeholder
|| (!params.has ('execution_at') && !errorFlg))) || (!params.has ('execution_at') && !errorFlg)))
const failure = runError ? runFailure : queryError const failure = definitionError ? definitionFailure : (runError ? runFailure : queryError)
const failureMessage = countsErrorMessage (failure, locale) const failureMessage = countsErrorMessage (failure, locale)
const columnLimit = runColumnLimit ?? tooManyColumns (queryError) const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
@@ -263,7 +265,7 @@ const CountsResourceContent = (
exportInFlight.current = true exportInFlight.current = true
setExporting (true) setExporting (true)
setExportError (false) setExportError (null)
try try
{ {
const axes = [...readCountState<CountsAxis[]> (params, 'rows', []), const axes = [...readCountState<CountsAxis[]> (params, 'rows', []),
@@ -291,9 +293,9 @@ const CountsResourceContent = (
handle={handle} controls={controls}/> }) handle={handle} controls={controls}/> })
} }
catch catch (failure)
{ {
setExportError (true) setExportError ({ failure })
} }
finally finally
{ {
@@ -344,7 +346,7 @@ const CountsResourceContent = (
return ( return (
<li key={definition}> <li key={definition}>
<PrefetchLink <PrefetchLink
to={`/counts/${target}?${next}`} to={`${countsResourcePath (target)}?${next}`}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
className={cn ('block py-1', active && 'font-bold underline')}> className={cn ('block py-1', active && 'font-bold underline')}>
{label (definition)} {label (definition)}
@@ -385,16 +387,20 @@ const CountsResourceContent = (
{label (`exportFormats.${format}`)}</Button>)} {label (`exportFormats.${format}`)}</Button>)}
</div>}/>} </div>}/>}
{exportError && <p role="alert">{label ('common:fetchError')}</p>} {exportError != null && <p role="alert">
{label ('errors.exportStart')}<br/>
{countsErrorMessage (exportError.failure, locale).text}</p>}
</div> </div>
{columnLimit != null ? <div role="alert"> {columnLimit != null ? <div role="alert">
<p>{label ('columnLimit.title')}</p> <p>{label ('columnLimit.title')}</p>
<p>{label ('columnLimit.message', { max: columnLimit })}</p> <p>{label ('columnLimit.message', { max: columnLimit })}</p>
</div> : ((definitionError || errorFlg || runError) </div> : ((definitionError || errorFlg || runError)
&& <p role="alert" lang={failureMessage.language}>{failureMessage.text}</p>)} && <p role="alert" lang={failureMessage.language}
className="whitespace-pre-line">{failureMessage.text}</p>)}
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden" <div className="relative flex min-h-0 flex-1 flex-col overflow-hidden"
aria-busy={waiting}> aria-busy={waiting}>
<div inert={waiting} className="flex min-h-0 flex-1 flex-col"> <div inert={waiting} aria-hidden={running || undefined}
className={cn ('flex min-h-0 flex-1 flex-col', running && 'invisible')}>
{mounted && definition != null && data != null && <> {mounted && definition != null && data != null && <>
<p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm"> <p className="my-1 flex flex-wrap items-baseline gap-x-2 text-sm">
<span>{label (resultMode === 'summary' ? 'outputRows' : 'outputTotal', { value: data.outputCount ?? '—' })}</span> <span>{label (resultMode === 'summary' ? 'outputRows' : 'outputTotal', { value: data.outputCount ?? '—' })}</span>
@@ -413,10 +419,12 @@ const CountsResourceContent = (
</>} </>}
</div> </div>
{waiting && <div role="status" aria-live="polite" {waiting && <div role="status" aria-live="polite"
className="pointer-events-none absolute right-2 top-2"> className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="rounded border border-slate-300 bg-white px-2 py-1 <span className="rounded border border-slate-300 bg-white px-2 py-1
text-sm text-slate-900 dark:border-slate-600 inline-flex items-center gap-2 text-sm text-slate-900 dark:border-slate-600
dark:bg-slate-950 dark:text-slate-100"> dark:bg-slate-950 dark:text-slate-100">
<LoaderCircle aria-hidden className={cn (
'h-5 w-5', !motion.reduced && 'animate-spin')}/>
{label ('shared:messages.loadingEllipsis')}</span> {label ('shared:messages.loadingEllipsis')}</span>
</div>} </div>}
</div> </div>
@@ -428,11 +436,12 @@ const CountsResourceContent = (
const CountsResourcePage = ({ resource }: { resource: CountsResource }) => { const CountsResourcePage = ({ resource }: { resource: CountsResource }) => {
const locale = useLocale () const locale = useLocale ()
const { data, isError: errorFlg } = useQuery ({ const { data, error, isError: errorFlg } = useQuery ({
queryKey: countsKeys.definition (resource, locale), queryKey: countsKeys.definition (resource, locale),
queryFn: () => fetchCountsDefinition (resource, locale), staleTime: Infinity }) queryFn: () => fetchCountsDefinition (resource, locale), staleTime: Infinity })
return <CountsDefinitionContext.Provider value={data ?? null}> return <CountsDefinitionContext.Provider value={data ?? null}>
<CountsResourceContent resource={resource} definition={data} definitionError={errorFlg}/> <CountsResourceContent resource={resource} definition={data}
definitionError={errorFlg} definitionFailure={error}/>
</CountsDefinitionContext.Provider> </CountsDefinitionContext.Provider>
} }