このコミットが含まれているのは:
@@ -99,6 +99,11 @@ class CountsExportsController < ApplicationController
|
|||||||
first = false
|
first = false
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
rescue IOError, SystemCallError => error
|
||||||
|
Rails.logger.warn("counts_download_stream_failure: #{ error.class }")
|
||||||
|
HistoryAccess.set(user: authority) do
|
||||||
|
CountsExportJobs.download_attempt(job, owner, **arguments, status: 'failed')
|
||||||
|
end
|
||||||
ensure
|
ensure
|
||||||
stream.close
|
stream.close
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class CountsTablesController < ApplicationController
|
|||||||
def index
|
def index
|
||||||
resolve_locale!
|
resolve_locale!
|
||||||
render json: CountsTableQuery.new(request.query_parameters).call
|
render json: CountsTableQuery.new(request.query_parameters).call
|
||||||
|
rescue CountsAxes::TooManyAxes
|
||||||
|
render json: { error: 'too_many_axes', max_axes: CountsAxes::MAX_AXES }, status: :bad_request
|
||||||
|
rescue CountsAxes::PrivateCondition
|
||||||
|
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
|
||||||
|
|||||||
@@ -68,6 +68,14 @@ class CountsAxes
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def count_expression
|
||||||
|
return 'COUNT(DISTINCT v.id)' unless rows.any? { _1['field'] == 'tags' }
|
||||||
|
|
||||||
|
# Keep tag occurrences even when several private tags share one masked label.
|
||||||
|
"COUNT(DISTINCT v.id, COALESCE(CONCAT('internal:', tag_entry.tag_id), " +
|
||||||
|
"CONCAT('external:', tag_entry.external_tag_id)))"
|
||||||
|
end
|
||||||
|
|
||||||
def joins(fields = all.map { _1['field'] })
|
def joins(fields = all.map { _1['field'] })
|
||||||
result = ''
|
result = ''
|
||||||
if fields.include?('tags')
|
if fields.include?('tags')
|
||||||
@@ -85,9 +93,6 @@ class CountsAxes
|
|||||||
condition = @definition.changes.map do |field|
|
condition = @definition.changes.map do |field|
|
||||||
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
"(changed.field_key = '#{ field }' AND #{ @population.changed_sql(field) })"
|
||||||
end.join(' OR ')
|
end.join(' OR ')
|
||||||
unless HistoryAccess.admin?
|
|
||||||
condition = "(#{ condition }) OR NOT (#{ HistoryAccess.sql(@definition.model, 'v') })"
|
|
||||||
end
|
|
||||||
result += " JOIN (#{ fields }) changed ON (#{ condition })"
|
result += " JOIN (#{ fields }) changed ON (#{ condition })"
|
||||||
end
|
end
|
||||||
result
|
result
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ class CountsExportJobs
|
|||||||
state['expires_at'] > Time.current.to_f && HistoryAccess.authorised_artifact?(state))
|
state['expires_at'] > Time.current.to_f && HistoryAccess.authorised_artifact?(state))
|
||||||
if attempt
|
if attempt
|
||||||
return unless state['download_attempt'] == attempt && state['token'] == file_token
|
return unless state['download_attempt'] == attempt && state['token'] == file_token
|
||||||
return unless state['download_status'] == 'pending'
|
return unless ['pending', 'started'].include?(state['download_status'])
|
||||||
|
|
||||||
state['download_status'] = status if status
|
state['download_status'] = status if status
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class CountsPivotTotals
|
|||||||
leaf = retained.map { @axes.expressions[_1] }
|
leaf = retained.map { @axes.expressions[_1] }
|
||||||
selected = leaf.each_with_index.map { |expression, index| "#{ expression } AS k#{ index }" }
|
selected = leaf.each_with_index.map { |expression, index| "#{ expression } AS k#{ index }" }
|
||||||
inner = scope.group(*leaf.map { Arel.sql(_1) })
|
inner = scope.group(*leaf.map { Arel.sql(_1) })
|
||||||
.select(Arel.sql((selected + ['COUNT(DISTINCT v.id) AS count']).join(', '))).to_sql
|
.select(Arel.sql((selected + ["#{ @axes.count_expression } AS count"]).join(', '))).to_sql
|
||||||
coordinates = indexes.map { "k#{ retained.index(_1) }" }
|
coordinates = indexes.map { "k#{ retained.index(_1) }" }
|
||||||
projected = coordinates.each_with_index.map { |key, index| "#{ key } AS v#{ index }" }
|
projected = coordinates.each_with_index.map { |key, index| "#{ key } AS v#{ index }" }
|
||||||
output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
|
output = ["#{ row_depth } AS row_depth", "#{ column_depth } AS column_depth"] +
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class CountsPopulation
|
|||||||
end
|
end
|
||||||
|
|
||||||
def axis_condition(scope, entry)
|
def axis_condition(scope, entry)
|
||||||
|
raise CountsAxes::PrivateCondition if entry['value'] == CountsAxes::MASKED_VALUE
|
||||||
|
|
||||||
key = entry.fetch('field')
|
key = entry.fetch('field')
|
||||||
raise ArgumentError unless definition.axes(history).include?(key)
|
raise ArgumentError unless definition.axes(history).include?(key)
|
||||||
if entry.key?('grain')
|
if entry.key?('grain')
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class CountsTableQuery
|
|||||||
source.except(:select).select('COUNT(DISTINCT v.id) AS count').to_sql
|
source.except(:select).select('COUNT(DISTINCT v.id) AS count').to_sql
|
||||||
else
|
else
|
||||||
source.except(:select).group(*expressions.map { Arel.sql(_1) })
|
source.except(:select).group(*expressions.map { Arel.sql(_1) })
|
||||||
.select(Arel.sql((selected + ['COUNT(DISTINCT v.id) AS count']).join(', ')))
|
.select(Arel.sql((selected + ["#{ axes.count_expression } AS count"]).join(', ')))
|
||||||
.to_sql
|
.to_sql
|
||||||
end
|
end
|
||||||
prefix = "WITH cells AS (#{ sql })"
|
prefix = "WITH cells AS (#{ sql })"
|
||||||
@@ -359,7 +359,7 @@ class CountsTableQuery
|
|||||||
end
|
end
|
||||||
selected = row_expressions.each_with_index.map { |expression, index|
|
selected = row_expressions.each_with_index.map { |expression, index|
|
||||||
"#{ expression } AS a#{ index }"
|
"#{ expression } AS a#{ index }"
|
||||||
} + ['COUNT(DISTINCT v.id) AS count']
|
} + ["#{ axes.count_expression } AS count"]
|
||||||
source = source.group(*row_expressions.map { Arel.sql(_1) }) if row_expressions.any?
|
source = source.group(*row_expressions.map { Arel.sql(_1) }) if row_expressions.any?
|
||||||
source.select(Arel.sql(selected.join(', '))).to_sql
|
source.select(Arel.sql(selected.join(', '))).to_sql
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
it 'does not resolve private condition names even when the result set is empty' do
|
it 'does not resolve private condition names even when the result set is empty' do
|
||||||
TagVersion.create!(tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
|
TagVersion.create!(tag:, version_no: 2, event_type: 'discard', name: 'secret tag',
|
||||||
category: 'general', aliases: '', parent_tag_ids: '',
|
category: 'general', aliases: '', parent_tag_ids: '',
|
||||||
names: { ja: { name: 'secret tag', script_code: 'Jpan', aliases: [] } },
|
names: { ja: { name: 'secret tag', script_code: 'Jpan', auto_generated: false, aliases: [] } },
|
||||||
created_at: Time.utc(2024, 2))
|
created_at: Time.utc(2024, 2))
|
||||||
result = query(axis('tags', "internal:#{ tag.id }").merge(definition: 'latest'))
|
result = query(axis('tags', "internal:#{ tag.id }").merge(definition: 'latest'))
|
||||||
expect(result['versions']).to be_empty
|
expect(result['versions']).to be_empty
|
||||||
@@ -245,4 +245,25 @@ RSpec.describe 'Counts table API', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
it 'includes private history in the public timeline without an exclusion count' do
|
||||||
|
PostVersion.create!(post: record, version_no: 4, event_type: 'discard', url: record.url,
|
||||||
|
title: 'hidden title', tags: '', tags_json: [], parent_post_ids: '',
|
||||||
|
created_at: Time.utc(2024, 3))
|
||||||
|
result = query(mode: 'summary', rows: 'created_at:month', cols: 'event_type')
|
||||||
|
expect(result['excluded_count']).to be_nil
|
||||||
|
expect(result['count']).to eq(4)
|
||||||
|
expect(result['cells'].sum { _1['count'] }).to eq(4)
|
||||||
|
expect(response.body).not_to include('hidden title')
|
||||||
|
masked = query(mode: 'summary', rows: 'resource_id', cols: '')
|
||||||
|
expect(masked['cells'].map { _1['values'] }).to eq([[CountsAxes::MASKED_VALUE]])
|
||||||
|
expect(masked['cells'].first['count']).to eq(4)
|
||||||
|
expect(query['versions']).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'rejects private summary predicates instead of exposing a count oracle' do
|
||||||
|
get '/counts/query', params: base.merge(mode: 'summary', resource_id: record.id.to_s)
|
||||||
|
expect(response).to have_http_status(:forbidden)
|
||||||
|
expect(json).to eq('error' => 'private_condition')
|
||||||
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -21,29 +21,7 @@ RSpec.describe 'Post filter identity across locales', type: :request do
|
|||||||
|
|
||||||
['nico:shared', 'union_alias', 'not:nico:shared'].each do |literal|
|
['nico:shared', 'union_alias', 'not:nico:shared'].each do |literal|
|
||||||
it "preserves the complete match set for #{ literal }" do
|
it "preserves the complete match set for #{ literal }" do
|
||||||
reference_params = { tag_refs: '' }
|
get '/posts', params: { locale: 'ja', tags: literal }
|
||||||
|
|
||||||
references.each_with_index do |reference, index|
|
|
||||||
%w[name tag_id external_tag_id negative missing].each do |field|
|
|
||||||
value = reference[field]
|
|
||||||
next if value.nil?
|
|
||||||
next if field == 'missing' && value == false
|
|
||||||
|
|
||||||
reference_params["tag_refs.#{index}.#{field}"] =
|
|
||||||
case value
|
|
||||||
when true
|
|
||||||
'1'
|
|
||||||
when false
|
|
||||||
'0'
|
|
||||||
else
|
|
||||||
value.to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
get '/posts', params: {
|
|
||||||
locale: 'ain-Kana',
|
|
||||||
tags: references.map { _1.fetch('name') }.join(' ') }.merge(reference_params)
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
original_ids = json.fetch('posts').map { _1.fetch('id') }
|
original_ids = json.fetch('posts').map { _1.fetch('id') }
|
||||||
expected = literal.start_with?('not:') ? [other_post.id] :
|
expected = literal.start_with?('not:') ? [other_post.id] :
|
||||||
@@ -56,9 +34,18 @@ RSpec.describe 'Post filter identity across locales', type: :request do
|
|||||||
references = json.fetch('references')
|
references = json.fetch('references')
|
||||||
expect(references.first).to include('tag_id' => tag.id, 'external_tag_id' => external.id)
|
expect(references.first).to include('tag_id' => tag.id, 'external_tag_id' => external.id)
|
||||||
|
|
||||||
get '/posts', params: {
|
reference_params = { tag_refs: '' }
|
||||||
locale: 'ain-Kana', tags: references.map { _1.fetch('name') }.join(' '),
|
references.each_with_index do |reference, index|
|
||||||
tag_refs: references.to_json }
|
['name', 'tag_id', 'external_tag_id', 'negative', 'missing'].each do |field|
|
||||||
|
value = reference[field]
|
||||||
|
next if value.nil? || (field == 'missing' && value == false)
|
||||||
|
|
||||||
|
reference_params["tag_refs.#{ index }.#{ field }"] =
|
||||||
|
[true, false].include?(value) ? (value ? '1' : '0') : value.to_s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
get '/posts', params: { locale: 'ain-Kana',
|
||||||
|
tags: references.map { _1.fetch('name') }.join(' ') }.merge(reference_params)
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(json.fetch('posts').map { _1.fetch('id') }).to match_array(original_ids)
|
expect(json.fetch('posts').map { _1.fetch('id') }).to match_array(original_ids)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ RSpec.describe 'Tag description Wiki compatibility', type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'does not fall back when the new current revision is missing' do
|
it 'does not fall back when the new current revision is missing' do
|
||||||
versions.last.destroy!
|
TagDescriptionVersion.where(id: versions.last.id).delete_all
|
||||||
without_legacy_reads { get "/wiki/#{ description.id }" }
|
without_legacy_reads { get "/wiki/#{ description.id }" }
|
||||||
expect(response).to have_http_status(:not_found)
|
expect(response).to have_http_status(:not_found)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -171,3 +171,33 @@ RSpec.describe 'Counts export contracts' do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
RSpec.describe 'Counts download acknowledgement' do
|
||||||
|
around do |example|
|
||||||
|
Dir.mktmpdir('counts-ack-spec') do |path|
|
||||||
|
allow(CountsExportDownload).to receive(:directory).and_return(Pathname.new(path))
|
||||||
|
HistoryAccess.set(user: nil) { example.run }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'binds an acknowledgement to the owner, job, file token and fresh attempt' do
|
||||||
|
CountsExportJobs.pulse
|
||||||
|
payload = { 'resource' => 'posts', 'definition' => 'history', 'mode' => 'details',
|
||||||
|
'format' => 'json', 'locale' => 'ja' }
|
||||||
|
token = CountsExportJobs.start(payload, 'owner', 'ja')
|
||||||
|
id = CountsExportJobs.authorised_id(token, 'owner')
|
||||||
|
state = CountsExportJobs.read(id).merge('status' => 'ready', 'token' => 'file-token')
|
||||||
|
CountsExportJobs.write(state)
|
||||||
|
receipt = CountsExportJobs.download_attempt(token, 'owner')
|
||||||
|
expect(receipt['download_status']).to eq('pending')
|
||||||
|
arguments = { file_token: 'file-token', attempt: receipt.fetch('download_attempt') }
|
||||||
|
expect(CountsExportJobs.download_attempt(token, 'other', **arguments)).to be_nil
|
||||||
|
expect(CountsExportJobs.download_attempt(token, 'owner', **arguments.merge(file_token: 'wrong'))).to be_nil
|
||||||
|
CountsExportJobs.download_attempt(token, 'owner', **arguments, status: 'started')
|
||||||
|
expect(CountsExportJobs.status(token, 'owner')['download_status']).to eq('started')
|
||||||
|
CountsExportJobs.download_attempt(token, 'owner')
|
||||||
|
expect(CountsExportJobs.download_attempt(token, 'owner', **arguments)).to be_nil
|
||||||
|
CountsExportJobs.status(token, 'owner', cancel: true)
|
||||||
|
expect(CountsExportJobs.download_attempt(token, 'owner')).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|||||||
@@ -105,3 +105,23 @@ CSV の物理行と JSON の pivot_cell には集計レコードを含むため
|
|||||||
追加は許可後の新規 add_index migration として行い、既存 migration は変更しない。
|
追加は許可後の新規 add_index migration として行い、既存 migration は変更しない。
|
||||||
実DBでのインデックス容量・作成時ロック・実行計画・時間は未確認。
|
実DBでのインデックス容量・作成時ロック・実行計画・時間は未確認。
|
||||||
索引追加なしでの最大データ量に対する応答時間は保証しない。
|
索引追加なしでの最大データ量に対する応答時間は保証しない。
|
||||||
|
|
||||||
|
|
||||||
|
## GET 応答開始の確認(静的実装・未実測)
|
||||||
|
|
||||||
|
進捗画面は ready 後に owner/job に紐づく download_attempt を登録し、同じ attempt を
|
||||||
|
native iframe の実 GET に渡す。サーバーは署名・所有者・権限 revision・期限・ファイルを
|
||||||
|
再検証し、64KiB の最初のチャンクを Rack 出力側へ渡した後に started を記録する。
|
||||||
|
HEAD や受付 POST では started にしない。確認には30秒の独立した期限がある。
|
||||||
|
サーバーの出力開始はブラウザー受信・保存完了を保証しない。途中の切断もあり得る。
|
||||||
|
開始確認前の失敗はダイアログに残る。再実行はダイアログを閉じてエクスポートをやり直す。
|
||||||
|
|
||||||
|
Web と worker は同じ COUNTS_EXPORT_DIRECTORY、Rails secret、DB、実行ユーザーで
|
||||||
|
共有 POSIX filesystem を使うこと。別ホストではローカル tmp を共有できない。
|
||||||
|
systemd の EnvironmentFile と Web の環境を照合し、directory の owner と0700相当の
|
||||||
|
アクセス制限、容量、期限切れ削除、worker heartbeat と restart を公開前に確認する。
|
||||||
|
この作業では systemctl・worker 起動・本番環境への適用を行っていない。
|
||||||
|
|
||||||
|
リリース保留:非公開グループと複数値・変更項目軸の交差集計、生成ファイルへの秘匿値の
|
||||||
|
非混入、実 GET の middleware/proxy 経由の開始通知は、回帰テストと実環境の確認が必要。
|
||||||
|
テスト・型チェック・build は未実行。
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const CountsAxes = ({ params, update }: {
|
|||||||
return <SortableList groups={groups} name={name} onChange={commit}
|
return <SortableList groups={groups} name={name} onChange={commit}
|
||||||
renderGroup={(id, content, over) => <fieldset className="mb-2 min-w-0">
|
renderGroup={(id, content, over) => <fieldset className="mb-2 min-w-0">
|
||||||
<legend>{label (id === 'available' ? 'axisItems' : id)}</legend>
|
<legend>{label (id === 'available' ? 'axisItems' : id)}</legend>
|
||||||
<div className={cn ('rounded border dark:border-slate-700',
|
<div className={cn ('rounded border px-1 dark:border-slate-700',
|
||||||
over && 'ring-1 ring-blue-500 dark:ring-blue-400')}>{content}</div>
|
over && 'ring-1 ring-blue-500 dark:ring-blue-400')}>{content}</div>
|
||||||
</fieldset>} renderItem={(id, group) => {
|
</fieldset>} renderItem={(id, group) => {
|
||||||
const axis = axes.find (axis => axis.field === id)
|
const axis = axes.find (axis => axis.field === id)
|
||||||
|
|||||||
@@ -185,16 +185,16 @@ const CountsControls = (
|
|||||||
className={inputClass (false, 'w-20 py-1')}
|
className={inputClass (false, 'w-20 py-1')}
|
||||||
onChange={event => update ('limit', event.target.value)}>
|
onChange={event => update ('limit', event.target.value)}>
|
||||||
{[20, 50, 100].map (count => <option key={count} value={count}>
|
{[20, 50, 100].map (count => <option key={count} value={count}>
|
||||||
{label ('shared:labels.resultsCount', { count })}</option>)}
|
{message ('shared:labels.resultsCount', { count }).text}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className="inline-flex items-center gap-1 text-sm">
|
{details && <label className="inline-flex items-center gap-1 text-sm">
|
||||||
<input type="checkbox" checked={draft.get ('row_numbers') === '1'}
|
<input type="checkbox" checked={draft.get ('row_numbers') === '1'}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
update ('row_numbers', event.target.checked ? '1' : '0')
|
update ('row_numbers', event.target.checked ? '1' : '0')
|
||||||
displayRowNumbers (event.target.checked)
|
displayRowNumbers (event.target.checked)
|
||||||
}}/>{label ('rowNumbers')}
|
}}/>{label ('rowNumbers')}
|
||||||
</label>
|
</label>}
|
||||||
{details && resource === 'posts' && <label
|
{details && resource === 'posts' && <label
|
||||||
className="inline-flex items-center gap-1 text-sm">
|
className="inline-flex items-center gap-1 text-sm">
|
||||||
<input type="checkbox" checked={thumbnail}
|
<input type="checkbox" checked={thumbnail}
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ const CountsExportProgress = (
|
|||||||
const job = useRef<string | null> (null)
|
const job = useRef<string | null> (null)
|
||||||
const creation = useRef<Promise<{ job: string }> | null> (null)
|
const creation = useRef<Promise<{ job: string }> | null> (null)
|
||||||
const downloaded = useRef (false)
|
const downloaded = useRef (false)
|
||||||
|
const registration = useRef (false)
|
||||||
const attempt = useRef<string | null> (null)
|
const attempt = useRef<string | null> (null)
|
||||||
const downloadDeadline = useRef (0)
|
const downloadDeadline = useRef (0)
|
||||||
|
const acknowledgementTimer = useRef<ReturnType<typeof setTimeout> | null> (null)
|
||||||
const releaseDownload = useRef<(() => void) | null> (null)
|
const releaseDownload = useRef<(() => void) | null> (null)
|
||||||
const [downloadError, setDownloadError] = useState<string | null> (null)
|
const [downloadError, setDownloadError] = useState<string | null> (null)
|
||||||
const pollRequest = useRef<AbortController | null> (null)
|
const pollRequest = useRef<AbortController | null> (null)
|
||||||
@@ -79,25 +81,33 @@ const CountsExportProgress = (
|
|||||||
const response = await apiGet<ExportStatus> (
|
const response = await apiGet<ExportStatus> (
|
||||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||||
locale, signal: controller.signal })
|
locale, signal: controller.signal })
|
||||||
if (!active) return
|
if (!active || terminal.current) return
|
||||||
const result = (cancelled.current && response.status === 'ready')
|
const result = (cancelled.current && response.status === 'ready')
|
||||||
? await apiDelete<ExportStatus> (
|
? await apiDelete<ExportStatus> (
|
||||||
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
`/counts/export/jobs/${encodeURIComponent (token)}`, {
|
||||||
locale, signal: controller.signal }) : response
|
locale, signal: controller.signal }) : response
|
||||||
if (!active) return
|
if (!active || terminal.current) return
|
||||||
lastResponse = Date.now ()
|
lastResponse = Date.now ()
|
||||||
setState (result)
|
setState (result)
|
||||||
terminal.current = ['failed', 'cancelled'].includes (result.status)
|
terminal.current = ['failed', 'cancelled'].includes (result.status)
|
||||||
if (result.status === 'ready' && result.token != null && !downloaded.current)
|
if (result.status === 'ready' && result.token != null && !downloaded.current)
|
||||||
{
|
{
|
||||||
downloaded.current = true
|
registration.current = true
|
||||||
const receipt = await apiPost<ExportStatus> (
|
const receipt = await apiPost<ExportStatus> (
|
||||||
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
|
`/counts/export/jobs/${encodeURIComponent (token)}/download`, {}, {
|
||||||
locale, signal: controller.signal })
|
locale, signal: controller.signal })
|
||||||
if (!active || cancelled.current) return
|
if (!active || cancelled.current) return
|
||||||
if (receipt.downloadAttempt == null) throw new Error ('Missing download attempt')
|
if (receipt.downloadAttempt == null) throw new Error ('Missing download attempt')
|
||||||
|
downloaded.current = true
|
||||||
attempt.current = receipt.downloadAttempt
|
attempt.current = receipt.downloadAttempt
|
||||||
downloadDeadline.current = Date.now () + 30_000
|
downloadDeadline.current = Date.now () + 30_000
|
||||||
|
acknowledgementTimer.current = setTimeout (() => {
|
||||||
|
if (!active || terminal.current) return
|
||||||
|
terminal.current = true
|
||||||
|
pollRequest.current?.abort ()
|
||||||
|
releaseDownload.current?.()
|
||||||
|
setDownloadError ('timeout')
|
||||||
|
}, 30_000)
|
||||||
releaseDownload.current = startAPIDownload ('/counts/export', result.token, () => {
|
releaseDownload.current = startAPIDownload ('/counts/export', result.token, () => {
|
||||||
if (active)
|
if (active)
|
||||||
{
|
{
|
||||||
@@ -132,13 +142,13 @@ const CountsExportProgress = (
|
|||||||
}
|
}
|
||||||
catch (error)
|
catch (error)
|
||||||
{
|
{
|
||||||
if (active)
|
if (active && !terminal.current)
|
||||||
{
|
{
|
||||||
if ((isApiError (error) && error.response?.status === 404)
|
if (downloaded.current || registration.current || (isApiError (error) && error.response?.status === 404)
|
||||||
|| Date.now () - lastResponse > 30_000)
|
|| Date.now () - lastResponse > 30_000)
|
||||||
{
|
{
|
||||||
terminal.current = true
|
terminal.current = true
|
||||||
if (downloaded.current) setDownloadError ('unavailable')
|
setDownloadError ('unavailable')
|
||||||
setState (previous => ({ ...previous, status: 'failed' }))
|
setState (previous => ({ ...previous, status: 'failed' }))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -160,13 +170,14 @@ const CountsExportProgress = (
|
|||||||
locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
|
locale, signal: controller.signal }).finally (() => clearTimeout (deadline))
|
||||||
}
|
}
|
||||||
creation.current.then (async result => {
|
creation.current.then (async result => {
|
||||||
if (!active) return
|
if (!active || terminal.current) return
|
||||||
job.current = result.job
|
job.current = result.job
|
||||||
if (cancelled.current) await handle.cancel ()
|
if (cancelled.current) await handle.cancel ()
|
||||||
await poll (result.job)
|
await poll (result.job)
|
||||||
}).catch (() => {
|
}).catch (() => {
|
||||||
if (active)
|
if (active)
|
||||||
{
|
{
|
||||||
|
setDownloadError ('failed')
|
||||||
terminal.current = true
|
terminal.current = true
|
||||||
setState (previous => ({ ...previous, status: 'failed' }))
|
setState (previous => ({ ...previous, status: 'failed' }))
|
||||||
}
|
}
|
||||||
@@ -176,8 +187,9 @@ const CountsExportProgress = (
|
|||||||
if (timer != null) clearTimeout (timer)
|
if (timer != null) clearTimeout (timer)
|
||||||
pollRequest.current?.abort ()
|
pollRequest.current?.abort ()
|
||||||
releaseDownload.current?.()
|
releaseDownload.current?.()
|
||||||
|
if (acknowledgementTimer.current != null) clearTimeout (acknowledgementTimer.current)
|
||||||
}
|
}
|
||||||
}, [handle, locale, payload, controls])
|
}, [handle, locale])
|
||||||
useEffect (() => {
|
useEffect (() => {
|
||||||
controls.setActions ([])
|
controls.setActions ([])
|
||||||
}, [controls])
|
}, [controls])
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useContext, useEffect, useRef } from 'react'
|
|||||||
import SortableItem from '@/components/common/SortableItem'
|
import SortableItem from '@/components/common/SortableItem'
|
||||||
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
import CountsResizeHandle from '@/components/counts/CountsResizeHandle'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
import { useUITranslation } from '@/lib/useUITranslation'
|
import { useUITranslation } from '@/lib/useUITranslation'
|
||||||
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
|
import { CountsTableScope, useCountsTableLayout } from '@/lib/countsTableLayout'
|
||||||
|
|
||||||
@@ -129,7 +130,10 @@ const CountsSortHeader = (
|
|||||||
}
|
}
|
||||||
change (field, event.ctrlKey || event.metaKey)
|
change (field, event.ctrlKey || event.metaKey)
|
||||||
}}>
|
}}>
|
||||||
<span data-counts-label className="truncate" lang={label.language}>{label.text}</span>
|
<span data-counts-label className={cn ('truncate', label.text === '********'
|
||||||
|
&& 'text-red-600 dark:text-red-400')}
|
||||||
|
title={label.text === '********' ? message ('maskedReason').text : undefined}
|
||||||
|
lang={label.language}>{label.text}</span>
|
||||||
<span aria-hidden data-counts-sort-indicator className="inline-block w-10 shrink-0">
|
<span aria-hidden data-counts-sort-indicator className="inline-block w-10 shrink-0">
|
||||||
{priority >= 0 && <>{direction === 'asc' ? '▲' : '▼'}
|
{priority >= 0 && <>{direction === 'asc' ? '▲' : '▼'}
|
||||||
{sorts.length > 1 && priority + 1}</>}</span>
|
{sorts.length > 1 && priority + 1}</>}</span>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const CountsSummaryTable = (
|
|||||||
const definition = useCountsDefinition ()
|
const definition = useCountsDefinition ()
|
||||||
const locale = useLocale ()
|
const locale = useLocale ()
|
||||||
const { message } = useUITranslation ('counts')
|
const { message } = useUITranslation ('counts')
|
||||||
const numbered = params.get ('row_numbers') === '1'
|
const numbered = false
|
||||||
const rows = data.rows ?? []
|
const rows = data.rows ?? []
|
||||||
const cols = data.cols ?? []
|
const cols = data.cols ?? []
|
||||||
const cells = data.cells ?? []
|
const cells = data.cells ?? []
|
||||||
@@ -260,7 +260,9 @@ const CountsSummaryTable = (
|
|||||||
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
|
data-counts-total={row.kind !== 'data' ? row.kind : undefined}
|
||||||
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
|
data-counts-row-total={row.kind !== 'data' ? row.kind : undefined}
|
||||||
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
|
className={cn ('overflow-hidden break-words border px-2 py-1 dark:border-slate-700',
|
||||||
numbered && offset === 0 && 'text-right tabular-nums')}>{value}</td>
|
numbered && offset === 0 && 'text-right tabular-nums')}>{value === '********' ? <span
|
||||||
|
className="text-red-600 dark:text-red-400"
|
||||||
|
title={message ('maskedReason').text}>{value}</span> : value}</td>
|
||||||
})}
|
})}
|
||||||
{columnHeaders.map (column => <td key={columnKey (column)}
|
{columnHeaders.map (column => <td key={columnKey (column)}
|
||||||
data-counts-copy={String (count (row, column))}
|
data-counts-copy={String (count (row, column))}
|
||||||
@@ -271,11 +273,12 @@ const CountsSummaryTable = (
|
|||||||
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
data-counts-aggregate={(row.kind !== 'data' || column.kind !== 'data')
|
||||||
? '' : undefined}
|
? '' : undefined}
|
||||||
className="border px-2 py-1 dark:border-slate-700">
|
className="border px-2 py-1 dark:border-slate-700">
|
||||||
<PrefetchLink to={drill (row, column)} aria-label={
|
{[...row.values, ...column.values].includes ('__counts_private__')
|
||||||
|
? <span>{count (row, column)}</span> : <PrefetchLink to={drill (row, column)} aria-label={
|
||||||
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
|
totalLabels.get (JSON.stringify ([row.values, column.values])) == null ? undefined
|
||||||
: `${message (totalLabels.get (
|
: `${message (totalLabels.get (
|
||||||
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
|
JSON.stringify ([row.values, column.values]))!).text}: ${count (row, column)}`}>
|
||||||
{count (row, column)}</PrefetchLink>
|
{count (row, column)}</PrefetchLink>}
|
||||||
</td>)}
|
</td>)}
|
||||||
</tr>)}</tbody>
|
</tr>)}</tbody>
|
||||||
</CountsSelectableTable>
|
</CountsSelectableTable>
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ export const countsSummaryValue = (
|
|||||||
): string => {
|
): string => {
|
||||||
const message = (key: string, values?: Record<string, string | number>) =>
|
const message = (key: string, values?: Record<string, string | number>) =>
|
||||||
translateUIMessage (key, 'counts', locale, values).text
|
translateUIMessage (key, 'counts', locale, values).text
|
||||||
|
if (value === '__counts_private__') return '********'
|
||||||
if (value == null) return axis.field === 'user' ? message ('bot') : ''
|
if (value == null) return axis.field === 'user' ? message ('bot') : ''
|
||||||
if (['language_code', 'source_language_code'].includes (axis.field))
|
if (['language_code', 'source_language_code'].includes (axis.field))
|
||||||
return languageUIMessage (String (value), locale).text
|
return languageUIMessage (String (value), locale).text
|
||||||
|
|||||||
@@ -1,3 +1,19 @@
|
|||||||
{
|
{
|
||||||
"_untranslated": ["*"]
|
"_untranslated": [
|
||||||
|
"*",
|
||||||
|
"outputRows",
|
||||||
|
"maskedReason",
|
||||||
|
"downloadErrors",
|
||||||
|
"axisLimit",
|
||||||
|
"privateCondition"
|
||||||
|
],
|
||||||
|
"outputRows": "行数:{{value}} 件",
|
||||||
|
"maskedReason": "閲覧権限のない履歴に由来するため、この項目の値は表示できません。集計件数には含まれます。",
|
||||||
|
"downloadErrors": {
|
||||||
|
"unavailable": "ダウンロードの認可が失われたか、ファイルの有効期限が切れています。",
|
||||||
|
"failed": "ダウンロード応答を開始できませんでした。",
|
||||||
|
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
|
||||||
|
},
|
||||||
|
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
|
||||||
|
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -290,5 +290,14 @@
|
|||||||
"periodSeparator": "〜",
|
"periodSeparator": "〜",
|
||||||
"bot": "$t(history:bot)",
|
"bot": "$t(history:bot)",
|
||||||
"actorWithId": "{{name}} (#{{id}})",
|
"actorWithId": "{{name}} (#{{id}})",
|
||||||
"axisWithGrain": "{{field}}({{grain}})"
|
"axisWithGrain": "{{field}}({{grain}})",
|
||||||
|
"outputRows": "行数:{{value}} 件",
|
||||||
|
"maskedReason": "閲覧権限のない履歴に由来するため、この項目の値は表示できません。集計件数には含まれます。",
|
||||||
|
"downloadErrors": {
|
||||||
|
"unavailable": "ダウンロードの認可が失われたか、ファイルの有効期限が切れています。",
|
||||||
|
"failed": "ダウンロード応答を開始できませんでした。",
|
||||||
|
"timeout": "ダウンロード応答の開始確認がタイムアウトしました。"
|
||||||
|
},
|
||||||
|
"axisLimit": "集計軸は最大{{max}}個です。行・列の項目を減らしてください。",
|
||||||
|
"privateCondition": "非公開情報を保護するため、この抽出条件では集計できません。"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const renderPage = (extra = '') => renderWithProviders (<>
|
|||||||
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
|
</>, { route: `/ja/counts/posts?mode=details&timezone=UTC${extra}` })
|
||||||
|
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
|
vi.spyOn (HTMLCanvasElement.prototype, 'getContext').mockReturnValue (null)
|
||||||
fetchCounts.mockReset ().mockResolvedValue (detailResult)
|
fetchCounts.mockReset ().mockResolvedValue (detailResult)
|
||||||
fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition)
|
fetchCountsDefinition.mockReset ().mockResolvedValue (postDefinition)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ const CountsResourceContent = (
|
|||||||
cacheParams.set ('definition', state ? 'latest' : 'history')
|
cacheParams.set ('definition', state ? 'latest' : 'history')
|
||||||
const [running, setRunning] = useState (false)
|
const [running, setRunning] = useState (false)
|
||||||
const [runError, setRunError] = useState (false)
|
const [runError, setRunError] = useState (false)
|
||||||
|
const [runFailure, setRunFailure] = useState<unknown> (null)
|
||||||
const [runColumnLimit, setRunColumnLimit] = useState<number | null> (null)
|
const [runColumnLimit, setRunColumnLimit] = useState<number | null> (null)
|
||||||
const pendingRun = useRef<PendingCountsRun | null> (null)
|
const pendingRun = useRef<PendingCountsRun | null> (null)
|
||||||
const applied = useRef (params)
|
const applied = useRef (params)
|
||||||
@@ -219,6 +220,7 @@ const CountsResourceContent = (
|
|||||||
if (pendingRun.current !== pending) return
|
if (pendingRun.current !== pending) return
|
||||||
pendingRun.current = null
|
pendingRun.current = null
|
||||||
setRunning (false)
|
setRunning (false)
|
||||||
|
setRunFailure (failure)
|
||||||
setRunColumnLimit (tooManyColumns (failure))
|
setRunColumnLimit (tooManyColumns (failure))
|
||||||
setRunError (true)
|
setRunError (true)
|
||||||
}
|
}
|
||||||
@@ -228,6 +230,14 @@ 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 errorBody = isApiError (failure) ? failure.response?.data as {
|
||||||
|
error?: string; max_axes?: number; maxAxes?: number } : undefined
|
||||||
|
const errorCode = errorBody?.error
|
||||||
|
const failureMessage = errorCode === 'too_many_axes'
|
||||||
|
? label ('axisLimit', { max: errorBody?.maxAxes ?? errorBody?.max_axes ?? '' })
|
||||||
|
: (errorCode === 'private_condition' ? label ('privateCondition')
|
||||||
|
: label ('common:fetchError'))
|
||||||
const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
|
const columnLimit = runColumnLimit ?? tooManyColumns (queryError)
|
||||||
|
|
||||||
|
|
||||||
@@ -371,14 +381,14 @@ const CountsResourceContent = (
|
|||||||
<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">{label ('common:fetchError')}</p>)}
|
&& <p role="alert">{failureMessage}</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} className="flex min-h-0 flex-1 flex-col">
|
||||||
{definition != null && data != null && <>
|
{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 ('outputTotal', { value: data.outputCount ?? '—' })}</span>
|
<span>{label (resultMode === 'summary' ? 'outputRows' : 'outputTotal', { value: data.outputCount ?? '—' })}</span>
|
||||||
{data.excludedCountScope === 'current_query'
|
{resultMode === 'details' && data.excludedCountScope === 'current_query'
|
||||||
&& data.excludedCount != null && data.excludedCount > 0 && (
|
&& data.excludedCount != null && data.excludedCount > 0 && (
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{label ('excludedRecords', { count: data.excludedCount })}</span>)}
|
{label ('excludedRecords', { count: data.excludedCount })}</span>)}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ vi.mock ('@/lib/useClientBehaviourSettings', () => ({
|
|||||||
|
|
||||||
describe ('Help global edition history', () => {
|
describe ('Help global edition history', () => {
|
||||||
beforeEach (() => {
|
beforeEach (() => {
|
||||||
|
window.history.replaceState (null, '', '/ja/help/changes')
|
||||||
helpFixture.reset ()
|
helpFixture.reset ()
|
||||||
vi.mocked (apiGet).mockResolvedValue ({ locales: [{ code: 'ja', name: '日本語' }] })
|
vi.mocked (apiGet).mockResolvedValue ({ locales: [{ code: 'ja', name: '日本語' }] })
|
||||||
})
|
})
|
||||||
|
|||||||
新しいイシューから参照
ユーザーをブロックする