diff --git a/frontend/src/assets/icons/collaborator-count.svg b/frontend/src/assets/icons/collaborator-count.svg
new file mode 100644
index 0000000000..b13d72044c
--- /dev/null
+++ b/frontend/src/assets/icons/collaborator-count.svg
@@ -0,0 +1,14 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/file-count.svg b/frontend/src/assets/icons/file-count.svg
new file mode 100644
index 0000000000..9b8b96b9d2
--- /dev/null
+++ b/frontend/src/assets/icons/file-count.svg
@@ -0,0 +1,17 @@
+
+
\ No newline at end of file
diff --git a/frontend/src/assets/icons/statistics.svg b/frontend/src/assets/icons/statistics.svg
new file mode 100644
index 0000000000..1d1c151eb4
--- /dev/null
+++ b/frontend/src/assets/icons/statistics.svg
@@ -0,0 +1,20 @@
+
+
+
diff --git a/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js b/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js
index d3be66e802..dcc68aa489 100644
--- a/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js
+++ b/frontend/src/components/dir-view-mode/dir-views/new-view-menu.js
@@ -10,6 +10,7 @@ export const KEY_ADD_VIEW_MAP = {
ADD_GALLERY: 'ADD_GALLERY',
ADD_KANBAN: 'ADD_KANBAN',
ADD_MAP: 'ADD_MAP',
+ ADD_STATISTICS: 'ADD_STATISTICS',
};
const ADD_VIEW_OPTIONS = [
@@ -25,6 +26,10 @@ const ADD_VIEW_OPTIONS = [
key: KEY_ADD_VIEW_MAP.ADD_KANBAN,
type: VIEW_TYPE.KANBAN,
},
+ {
+ key: KEY_ADD_VIEW_MAP.ADD_STATISTICS,
+ type: VIEW_TYPE.STATISTICS,
+ }
];
const translateLabel = (type) => {
@@ -37,6 +42,8 @@ const translateLabel = (type) => {
return gettext('Kanban');
case VIEW_TYPE.MAP:
return gettext('Map');
+ case VIEW_TYPE.STATISTICS:
+ return gettext('Statistics');
default:
return type;
}
diff --git a/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js b/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js
index 0d0a3c8409..ae9da4f00f 100644
--- a/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js
+++ b/frontend/src/components/dir-view-mode/dir-views/views-more-operations.js
@@ -36,6 +36,10 @@ const ViewsMoreOperations = ({ menuProps }) => {
addView(VIEW_TYPE.MAP);
return;
}
+ case KEY_ADD_VIEW_MAP.ADD_STATISTICS: {
+ addView(VIEW_TYPE.STATISTICS);
+ return;
+ }
default: {
return;
}
diff --git a/frontend/src/metadata/api.js b/frontend/src/metadata/api.js
index ed3a54ec82..38a820d8bd 100644
--- a/frontend/src/metadata/api.js
+++ b/frontend/src/metadata/api.js
@@ -408,6 +408,12 @@ class MetadataManagerAPI {
return this.req.post(url, params);
};
+ // statistics
+ getStatistics = (repoID) => {
+ const url = this.server + '/api/v2.1/repos/' + repoID + '/metadata/statistics/';
+ return this.req.get(url);
+ };
+
}
const metadataAPI = new MetadataManagerAPI();
diff --git a/frontend/src/metadata/constants/view/index.js b/frontend/src/metadata/constants/view/index.js
index ea8771ec7d..f1ca373700 100644
--- a/frontend/src/metadata/constants/view/index.js
+++ b/frontend/src/metadata/constants/view/index.js
@@ -7,6 +7,7 @@ import { SORT_COLUMN_OPTIONS, GALLERY_SORT_COLUMN_OPTIONS, GALLERY_FIRST_SORT_CO
export * from './gallery';
export * from './kanban';
export * from './map';
+export * from './statistics';
export * from './table';
export const METADATA_VIEWS_KEY = 'sf-metadata-views';
@@ -25,6 +26,7 @@ export const VIEW_TYPE = {
FACE_RECOGNITION: 'face_recognition',
KANBAN: 'kanban',
MAP: 'map',
+ STATISTICS: 'statistics',
};
export const FACE_RECOGNITION_VIEW_ID = '_face_recognition';
@@ -35,6 +37,7 @@ export const VIEW_TYPE_ICON = {
[VIEW_TYPE.FACE_RECOGNITION]: 'face-recognition-view',
[VIEW_TYPE.KANBAN]: 'kanban',
[VIEW_TYPE.MAP]: 'map',
+ [VIEW_TYPE.STATISTICS]: 'statistics',
'image': 'image'
};
@@ -92,6 +95,13 @@ export const VIEW_TYPE_DEFAULT_BASIC_FILTER = {
filter_term: []
},
],
+ [VIEW_TYPE.STATISTICS]: [
+ {
+ column_key: PRIVATE_COLUMN_KEY.IS_DIR,
+ filter_predicate: FILTER_PREDICATE_TYPE.IS,
+ filter_term: 'file'
+ },
+ ],
};
export const VIEW_TYPE_DEFAULT_SORTS = {
@@ -100,6 +110,7 @@ export const VIEW_TYPE_DEFAULT_SORTS = {
[VIEW_TYPE.FACE_RECOGNITION]: [{ column_key: PRIVATE_COLUMN_KEY.FILE_CTIME, sort_type: SORT_TYPE.DOWN }],
[VIEW_TYPE.KANBAN]: [],
[VIEW_TYPE.MAP]: [{ column_key: PRIVATE_COLUMN_KEY.FILE_CTIME, sort_type: SORT_TYPE.DOWN }],
+ [VIEW_TYPE.STATISTICS]: [],
};
export const VIEW_SORT_COLUMN_RULES = {
diff --git a/frontend/src/metadata/constants/view/statistics.js b/frontend/src/metadata/constants/view/statistics.js
new file mode 100644
index 0000000000..0756191bc0
--- /dev/null
+++ b/frontend/src/metadata/constants/view/statistics.js
@@ -0,0 +1,36 @@
+import { gettext } from '../../../utils/constants';
+import { PREDEFINED_FILE_TYPE_OPTION_KEY } from '../column/predefined';
+
+export const FILE_TYPE_NAMES = {
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.PICTURE]: gettext('Pictures'),
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.DOCUMENT]: gettext('Documents'),
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.VIDEO]: gettext('Videos'),
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.AUDIO]: gettext('Audios'),
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.CODE]: gettext('Codes'),
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.COMPRESSED]: gettext('Compresseds'),
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.DIAGRAM]: gettext('Diagrams'),
+ other: gettext('Others'),
+};
+
+export const FILE_TYPE_COLORS = {
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.PICTURE]: '#9867ba',
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.DOCUMENT]: '#4ecdc4',
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.VIDEO]: '#93c4fd',
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.AUDIO]: '#79ddcb',
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.CODE]: '#f6c038',
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.COMPRESSED]: '#9397fd',
+ [PREDEFINED_FILE_TYPE_OPTION_KEY.DIAGRAM]: '#74b9ff',
+ other: '#636e72',
+};
+
+export const TIME_GROUPING_OPTIONS = [
+ { value: 'created', label: gettext('Created time') },
+ { value: 'modified', label: gettext('Modified time') },
+];
+
+export const CREATOR_SORT_OPTIONS = [
+ { value: 'count-asc', text: gettext('Ascending by count') },
+ { value: 'count-desc', text: gettext('Descending by count') },
+ { value: 'name-asc', text: gettext('Ascending by name') },
+ { value: 'name-desc', text: gettext('Descending by name') }
+];
diff --git a/frontend/src/metadata/views/statistics/charts/BarChart.js b/frontend/src/metadata/views/statistics/charts/BarChart.js
new file mode 100644
index 0000000000..e9bd74d23a
--- /dev/null
+++ b/frontend/src/metadata/views/statistics/charts/BarChart.js
@@ -0,0 +1,454 @@
+import React, { useEffect, useRef } from 'react';
+import * as d3 from 'd3';
+
+export const BarChart = ({ data, unit }) => {
+ const svgRef = useRef();
+ const containerRef = useRef();
+
+ const isDark = document.body.getAttribute('data-bs-theme') === 'dark';
+
+ useEffect(() => {
+ if (!data || data.length === 0) return;
+
+ const container = containerRef.current;
+ const svg = d3.select(svgRef.current);
+ svg.selectAll('*').remove();
+
+ const containerWidth = container.offsetWidth;
+ const barWidth = 24;
+ const minBarSpacing = 24;
+ const totalBarWidth = barWidth + minBarSpacing;
+
+ const requiredWidth = data.length * totalBarWidth;
+ const marginReserve = 40;
+ const shouldScroll = requiredWidth > containerWidth - marginReserve;
+ const chartWidth = shouldScroll ? requiredWidth : Math.max(containerWidth, requiredWidth);
+
+ const margin = {
+ top: 15,
+ right: 30,
+ bottom: 60,
+ left: 20
+ };
+
+ const width = chartWidth - margin.left - margin.right;
+ const height = 250 - margin.top - margin.bottom;
+
+ const g = svg
+ .attr('width', chartWidth)
+ .attr('height', 250)
+ .append('g')
+ .attr('transform', `translate(${margin.left},${margin.top})`);
+
+ const xScale = d3.scaleBand()
+ .domain(data.map(d => d.name))
+ .range([0, width])
+ .paddingInner(minBarSpacing / totalBarWidth)
+ .paddingOuter(0.1);
+
+ const actualBarWidth = barWidth;
+ const maxValue = d3.max(data, d => d.value);
+ const yScale = d3.scaleLinear().domain([0, maxValue]).range([height, 0]).nice();
+
+ const yAxisTickFormat = maxValue > 1000 ? d3.format('.1s') : d3.format('d');
+ const yAxis = g.append('g')
+ .call(d3.axisLeft(yScale)
+ .tickSize(-width)
+ .tickFormat(yAxisTickFormat)
+ .ticks(4)
+ );
+
+ yAxis.selectAll('text')
+ .style('font-size', '12px')
+ .style('fill', 'var(--bs-body-secondary-color)')
+ .style('color', 'var(--bs-body-secondary-color)');
+
+ yAxis.selectAll('.tick line')
+ .style('stroke', isDark ? 'var(--bs-border-color)' : '#f5f5f5')
+ .style('stroke-width', 1)
+ .style('opacity', 0.7);
+
+ yAxis.select('.domain').remove();
+
+ g.selectAll('.bar')
+ .data(data)
+ .enter()
+ .append('path')
+ .attr('class', 'bar')
+ .attr('fill', '#ff9800')
+ .attr('d', d => {
+ const x = xScale(d.name) + (xScale.bandwidth() - actualBarWidth) / 2;
+ const width = actualBarWidth;
+ const height = 0;
+ const y = height;
+ const radius = Math.min(4, actualBarWidth / 6);
+
+ return `M${x},${y}
+ L${x},${y - height + radius}
+ Q${x},${y - height} ${x + radius},${y - height}
+ L${x + width - radius},${y - height}
+ Q${x + width},${y - height} ${x + width},${y - height + radius}
+ L${x + width},${y}
+ Z`;
+ })
+ .transition()
+ .duration(400)
+ .delay((d, i) => i * 100)
+ .attr('d', d => {
+ const x = xScale(d.name) + (xScale.bandwidth() - actualBarWidth) / 2;
+ const width = actualBarWidth;
+ const barHeight = height - yScale(d.value);
+ const y = height;
+ const radius = Math.min(4, barHeight / 2, actualBarWidth / 6);
+
+ if (barHeight <= 0) return '';
+
+ return `M${x},${y}
+ L${x},${y - barHeight + radius}
+ Q${x},${y - barHeight} ${x + radius},${y - barHeight}
+ L${x + width - radius},${y - barHeight}
+ Q${x + width},${y - barHeight} ${x + width},${y - barHeight + radius}
+ L${x + width},${y}
+ Z`;
+ });
+
+ const xAxis = g.append('g')
+ .attr('transform', `translate(0,${height})`)
+ .call(d3.axisBottom(xScale).tickSize(0));
+
+ xAxis.selectAll('text').remove();
+
+ xAxis.selectAll('.custom-tick-text')
+ .data(data)
+ .enter()
+ .append('g')
+ .attr('class', 'custom-tick-text')
+ .attr('transform', d => `translate(${xScale(d.name) + xScale.bandwidth() / 2}, 0)`)
+ .each(function (d) {
+ const tickGroup = d3.select(this);
+ const dateText = d.name;
+
+ const monthYearMatch = dateText.match(/^([A-Za-z]{3,})\s+(\d{4})$/);
+ if (monthYearMatch) {
+ const month = monthYearMatch[1];
+ const year = monthYearMatch[2];
+
+ tickGroup.append('text')
+ .attr('x', 0)
+ .attr('y', 12)
+ .attr('text-anchor', 'middle')
+ .style('font-size', '11px')
+ .style('fill', 'var(--bs-body-secondary-color)')
+ .style('color', 'var(--bs-body-secondary-color)')
+ .text(month);
+
+ tickGroup.append('text')
+ .attr('x', 0)
+ .attr('y', 26)
+ .attr('text-anchor', 'middle')
+ .style('font-size', '10px')
+ .style('fill', 'var(--bs-body-secondary-color)')
+ .style('color', 'var(--bs-body-secondary-color)')
+ .text(year);
+ } else {
+ tickGroup.append('text')
+ .attr('x', 0)
+ .attr('y', 16)
+ .attr('text-anchor', 'middle')
+ .style('font-size', '11px')
+ .style('fill', 'var(--bs-body-secondary-color)')
+ .style('color', 'var(--bs-body-secondary-color)')
+ .text(dateText.length > 8 ? dateText.substring(0, 6) + '...' : dateText);
+ }
+ });
+
+ xAxis.select('.domain').remove();
+
+ g.selectAll('.value-label')
+ .data(data)
+ .enter()
+ .append('text')
+ .attr('class', 'value-label')
+ .attr('x', d => xScale(d.name) + xScale.bandwidth() / 2)
+ .attr('y', d => yScale(d.value) - 4)
+ .attr('text-anchor', 'middle')
+ .style('font-size', '11px')
+ .style('fill', 'var(--bs-body-secondary-color)')
+ .style('color', 'var(--bs-body-secondary-color)')
+ .style('opacity', 0)
+ .text(d => yAxisTickFormat(d.value))
+ .transition()
+ .duration(400)
+ .delay((d, i) => i * 100 + 400)
+ .style('opacity', 1);
+
+ g.selectAll('.bar')
+ .on('mouseover', function (event, d) {
+ d3.select(this).transition().duration(200).attr('fill', '#e65100');
+ })
+ .on('mouseout', function (event, d) {
+ d3.select(this).transition().duration(200).attr('fill', '#ff9800');
+ });
+
+ }, [data, unit, isDark]);
+
+ const barWidth = 24;
+ const minBarSpacing = 24;
+ const totalBarWidth = barWidth + minBarSpacing;
+ const requiredWidth = (data?.length || 0) * totalBarWidth;
+ const needsScrolling = requiredWidth > 400;
+
+ return (
+
+
+
+ );
+};
+
+export const HorizontalBarChart = ({ data }) => {
+ const svgRef = useRef();
+ const containerRef = useRef();
+
+ const isDark = document.body.getAttribute('data-bs-theme') === 'dark';
+
+ useEffect(() => {
+ if (!data || data.length === 0) return;
+
+ const container = containerRef.current;
+ const svg = d3.select(svgRef.current);
+ svg.selectAll('*').remove();
+
+ const containerWidth = container.offsetWidth;
+ const isMobile = containerWidth < 480;
+ const margin = {
+ top: 10,
+ right: isMobile ? 60 : 80,
+ bottom: 30,
+ left: isMobile ? 100 : 120
+ };
+
+ const width = Math.max(350, containerWidth - margin.left - margin.right);
+
+ const minBarSpacing = 26;
+ const barHeight = 18;
+ const totalItemHeight = barHeight + minBarSpacing;
+ const requiredContentHeight = data.length * totalItemHeight;
+
+ const containerHeight = container.offsetHeight || 340;
+ const availableHeight = containerHeight - margin.top - margin.bottom;
+
+ const shouldScroll = requiredContentHeight > availableHeight;
+ const svgHeight = shouldScroll ? requiredContentHeight + margin.top + margin.bottom : containerHeight;
+
+ const actualContentHeight = shouldScroll ? requiredContentHeight : availableHeight;
+ const centerOffset = shouldScroll ? 0 : Math.max(0, (availableHeight - requiredContentHeight) / 2);
+
+ const g = svg
+ .attr('width', '100%')
+ .attr('height', svgHeight)
+ .append('g')
+ .attr('transform', `translate(${margin.left},${margin.top})`);
+
+ const maxValue = d3.max(data, d => d.value);
+
+ const getNiceNumber = (value, round = true) => {
+ const exponent = Math.floor(Math.log10(value));
+ const fraction = value / Math.pow(10, exponent);
+ let niceFraction;
+
+ if (round) {
+ if (fraction < 1.5) niceFraction = 1;
+ else if (fraction < 3) niceFraction = 2;
+ else if (fraction < 7) niceFraction = 5;
+ else niceFraction = 10;
+ } else {
+ if (fraction <= 1) niceFraction = 1;
+ else if (fraction <= 2) niceFraction = 2;
+ else if (fraction <= 5) niceFraction = 5;
+ else niceFraction = 10;
+ }
+
+ return niceFraction * Math.pow(10, exponent);
+ };
+
+ const range = getNiceNumber(maxValue, false);
+ const stepSize = getNiceNumber(range / 4, true);
+ const niceMax = Math.ceil(maxValue / stepSize) * stepSize;
+ const adjustedMax = Math.max(niceMax, stepSize * 4);
+ const finalStepSize = adjustedMax / 4;
+
+ const xScale = d3.scaleLinear().domain([0, adjustedMax]).range([0, width - 60]);
+
+ const yScale = d3.scaleBand()
+ .domain(data.map(d => d.name))
+ .range([centerOffset, centerOffset + (shouldScroll ? requiredContentHeight : Math.min(requiredContentHeight, availableHeight))])
+ .paddingInner(minBarSpacing / totalItemHeight)
+ .paddingOuter(0.1);
+
+ const xAxis = g.append('g')
+ .attr('transform', `translate(0,${actualContentHeight})`)
+ .call(d3.axisBottom(xScale)
+ .tickValues([0, finalStepSize, finalStepSize * 2, finalStepSize * 3, adjustedMax])
+ .tickFormat(d3.format('d'))
+ .tickSize(-actualContentHeight)
+ );
+
+ xAxis.selectAll('text')
+ .style('font-size', isMobile ? '10px' : '11px')
+ .style('color', '#666')
+ .attr('dy', '20px');
+
+ xAxis.selectAll('.tick line')
+ .style('stroke', isDark ? 'var(--bs-border-color)' : '#f5f5f5')
+ .style('stroke-width', 1);
+
+ xAxis.select('.domain').remove();
+
+ const barY = d => yScale(d.name) + (yScale.bandwidth() - barHeight) / 2;
+
+ g.selectAll('.bar')
+ .data(data)
+ .enter()
+ .append('path')
+ .attr('class', 'bar')
+ .attr('fill', '#6395fa')
+ .attr('d', d => {
+ const width = 0;
+ const height = barHeight;
+ const y = barY(d);
+ return `M0,${y} L${width},${y} L${width},${y + height} L0,${y + height} Z`;
+ })
+ .transition()
+ .duration(400)
+ .delay((d, i) => i * 100)
+ .attr('d', d => {
+ const width = xScale(d.value);
+ const height = barHeight;
+ const y = barY(d);
+ const radius = Math.min(3, width / 2);
+
+ if (width <= 0) return '';
+
+ return `M0,${y}
+ L${width - radius},${y}
+ Q${width},${y} ${width},${y + radius}
+ L${width},${y + height - radius}
+ Q${width},${y + height} ${width - radius},${y + height}
+ L0,${y + height}
+ Z`;
+ });
+
+ const yAxisGroup = g.append('g').attr('class', 'y-axis');
+
+ data.forEach((d, i) => {
+ const yPos = yScale(d.name) + yScale.bandwidth() / 2;
+ const itemGroup = yAxisGroup.append('g').attr('transform', `translate(0, ${yPos})`);
+ const avatarCenterX = -margin.left + 4 + 14;
+
+ itemGroup.append('circle')
+ .attr('cx', avatarCenterX)
+ .attr('cy', 0)
+ .attr('r', 14)
+ .attr('fill', '#f0f0f0')
+ .attr('stroke', '#ddd')
+ .attr('stroke-width', 1);
+
+ itemGroup.append('clipPath')
+ .attr('id', `avatar-clip-${i}`)
+ .append('circle')
+ .attr('cx', avatarCenterX)
+ .attr('cy', 0)
+ .attr('r', 14);
+
+ itemGroup.append('image')
+ .attr('x', avatarCenterX - 14)
+ .attr('y', -14)
+ .attr('width', 28)
+ .attr('height', 28)
+ .attr('href', d.avatarUrl)
+ .attr('clip-path', `url(#avatar-clip-${i})`)
+ .style('opacity', 0)
+ .transition()
+ .duration(400)
+ .delay(i * 100)
+ .style('opacity', 1);
+
+ const nameX = avatarCenterX + 14 + 12;
+ const maxNameWidth = 70;
+
+ const nameText = itemGroup.append('text')
+ .attr('x', nameX)
+ .attr('y', 0)
+ .attr('dy', '0.35em')
+ .attr('text-anchor', 'start')
+ .style('font-size', '13px')
+ .style('fill', isDark ? 'var(--bs-body-secondary-color)' : '#333')
+ .style('color', isDark ? 'var(--bs-body-secondary-color)' : '#333');
+
+ itemGroup.select('image').append('title').text(d.displayName);
+ itemGroup.select('text').append('title').text(d.displayName);
+
+ nameText.text(d.displayName);
+ let textWidth = nameText.node().getBBox().width;
+
+ if (textWidth > maxNameWidth) {
+ let truncatedName = d.displayName;
+ while (truncatedName.length > 3) {
+ truncatedName = truncatedName.slice(0, -1);
+ nameText.text(truncatedName + '...');
+ textWidth = nameText.node().getBBox().width;
+ if (textWidth <= maxNameWidth) break;
+ }
+ }
+ });
+
+ g.selectAll('.value-label')
+ .data(data)
+ .enter()
+ .append('text')
+ .attr('class', 'value-label')
+ .attr('x', d => xScale(d.value) + 4)
+ .attr('y', d => barY(d) + barHeight / 2)
+ .attr('dy', '0.35em')
+ .style('font-size', '12px')
+ .style('fill', '#666')
+ .style('color', '#666')
+ .style('opacity', 0)
+ .text(d => d.value)
+ .transition()
+ .duration(400)
+ .delay((d, i) => i * 100 + 400)
+ .style('opacity', 1);
+
+ }, [data, isDark]);
+
+ const minBarSpacing = 26;
+ const barHeight = 18;
+ const totalItemHeight = barHeight + minBarSpacing;
+ const requiredHeight = (data?.length || 0) * totalItemHeight;
+ const needsScrolling = requiredHeight > 300;
+
+ return (
+
+
+
+ );
+};
diff --git a/frontend/src/metadata/views/statistics/charts/PieChart.js b/frontend/src/metadata/views/statistics/charts/PieChart.js
new file mode 100644
index 0000000000..dfc449b7a5
--- /dev/null
+++ b/frontend/src/metadata/views/statistics/charts/PieChart.js
@@ -0,0 +1,192 @@
+import React, { useEffect, useRef } from 'react';
+import * as d3 from 'd3';
+
+const PieChart = ({ data }) => {
+ const svgRef = useRef();
+ const containerRef = useRef();
+
+ const isDark = document.body.getAttribute('data-bs-theme') === 'dark';
+
+ useEffect(() => {
+ if (!data || data.length === 0) return;
+
+ const container = containerRef.current;
+ const svg = d3.select(svgRef.current);
+ svg.selectAll('*').remove();
+
+ const tooltip = d3.select(container)
+ .append('div')
+ .attr('class', 'pie-chart-tooltip')
+ .style('position', 'absolute')
+ .style('background', 'rgba(0, 0, 0, 0.8)')
+ .style('color', 'white')
+ .style('padding', '8px 12px')
+ .style('border-radius', '4px')
+ .style('font-size', '12px')
+ .style('pointer-events', 'none')
+ .style('opacity', 0)
+ .style('z-index', '1000');
+
+ const containerWidth = container.offsetWidth;
+ const isMobile = containerWidth < 480;
+ const radius = 150;
+ const pieSize = (radius + 20) * 2;
+
+ svg.attr('width', '100%').attr('height', pieSize);
+
+ const g = svg.append('g')
+ .attr('transform', `translate(${pieSize / 2}, ${pieSize / 2})`);
+
+ const pie = d3.pie().value(d => d.value).sort(null);
+ const arc = d3.arc().innerRadius(0).outerRadius(radius);
+
+ const arcs = g.selectAll('.arc')
+ .data(pie(data))
+ .enter()
+ .append('g')
+ .attr('class', 'arc');
+
+ arcs.append('path')
+ .attr('d', arc)
+ .attr('fill', d => d.data.color || '#95a5a6')
+ .attr('stroke', '#fff')
+ .attr('stroke-width', 2)
+ .style('opacity', 0.9)
+ .on('mouseover', function (event, d) {
+ const total = d3.sum(data, d => d.value);
+ const percentage = Math.round((d.data.value / total) * 100);
+
+ d3.select(this)
+ .style('opacity', 1)
+ .transition()
+ .duration(200)
+ .attr('transform', function (d) {
+ const centroid = arc.centroid(d);
+ return `translate(${centroid[0] * 0.05}, ${centroid[1] * 0.05})`;
+ });
+
+ const containerRect = container.getBoundingClientRect();
+ tooltip
+ .style('opacity', 1)
+ .html(`${d.data.label}
Count: ${d.data.value}
Percentage: ${percentage}%`)
+ .style('left', (event.clientX - containerRect.left + 50) + 'px')
+ .style('top', (event.clientY - containerRect.top + 100) + 'px');
+ })
+ .on('mousemove', function (event, d) {
+ const containerRect = container.getBoundingClientRect();
+ tooltip
+ .style('left', (event.clientX - containerRect.left + 50) + 'px')
+ .style('top', (event.clientY - containerRect.top + 100) + 'px');
+ })
+ .on('mouseout', function (event, d) {
+ d3.select(this)
+ .style('opacity', 0.9)
+ .transition()
+ .duration(200)
+ .attr('transform', 'translate(0,0)');
+
+ tooltip.style('opacity', 0);
+ });
+
+ const total = d3.sum(data, d => d.value);
+ const minSlicePercentage = 0.04;
+
+ arcs.filter(d => (d.data.value / total) >= minSlicePercentage)
+ .each(function (d) {
+ const textGroup = d3.select(this);
+ const sliceCenterAngle = (d.startAngle + d.endAngle) / 2;
+ const percentage = Math.round((d.data.value / total) * 100);
+ const labelText = `${d.data.value}(${percentage}%)`;
+
+ const isRightSide = sliceCenterAngle < Math.PI;
+ const baselineRotation = (sliceCenterAngle * 180 / Math.PI) - 90;
+ const textRotation = isRightSide ? baselineRotation : baselineRotation + 180;
+
+ const measurementText = textGroup.append('text')
+ .style('font-size', '13px')
+ .text(labelText)
+ .style('opacity', 0);
+
+ const labelWidth = measurementText.node().getBBox().width;
+ measurementText.remove();
+
+ const edgeMargin = 10;
+ const maxLabelDistance = radius - edgeMargin;
+ const safeLabelDistance = Math.min(maxLabelDistance, radius - labelWidth - edgeMargin);
+
+ const labelX = Math.cos(sliceCenterAngle - Math.PI / 2) * safeLabelDistance;
+ const labelY = Math.sin(sliceCenterAngle - Math.PI / 2) * safeLabelDistance;
+
+ const textAnchor = isRightSide ? 'start' : 'end';
+
+ textGroup.append('text')
+ .attr('transform', `translate(${labelX}, ${labelY}) rotate(${textRotation})`)
+ .attr('text-anchor', textAnchor)
+ .attr('dominant-baseline', 'central')
+ .style('font-size', '13px')
+ .style('font-weight', '400')
+ .style('fill', 'none')
+ .style('stroke', '#fff')
+ .style('stroke-width', '2px')
+ .style('stroke-linejoin', 'round')
+ .text(labelText);
+
+ // Then draw the main text (colored fill, no stroke)
+ textGroup.append('text')
+ .attr('transform', `translate(${labelX}, ${labelY}) rotate(${textRotation})`)
+ .attr('text-anchor', textAnchor)
+ .attr('dominant-baseline', 'central')
+ .style('font-size', '13px')
+ .style('font-weight', '400')
+ .style('fill', '#666')
+ .style('color', '#666')
+ .text(labelText);
+ });
+
+ const legendContainer = svg.append('g')
+ .attr('class', 'legend')
+ .attr('transform', `translate(${pieSize + 15}, ${pieSize / 2 - (data.length * 11)})`);
+
+ const legend = legendContainer.selectAll('.legend-item')
+ .data(data)
+ .enter()
+ .append('g')
+ .attr('class', 'legend-item')
+ .attr('transform', (d, i) => `translate(0, ${i * 22})`);
+
+ legend.append('rect')
+ .attr('width', 20)
+ .attr('height', 6)
+ .attr('rx', 3)
+ .attr('fill', d => d.color || '#95a5a6');
+
+ legend.append('text')
+ .attr('x', 26)
+ .attr('y', 3)
+ .attr('dy', '0.35em')
+ .style('font-size', '13px')
+ .style('color', isDark ? 'var(--bs-body-secondary-color)' : '#333')
+ .style('fill', isDark ? 'var(--bs-body-secondary-color)' : '#333')
+ .text(d => {
+ const maxLength = isMobile ? 10 : 15;
+ const label = d.label.length > maxLength ? d.label.substring(0, maxLength) + '...' : d.label;
+ return label;
+ });
+
+ }, [data, isDark]);
+
+ useEffect(() => {
+ const currentContainer = containerRef.current;
+ return () => {
+ d3.select(currentContainer).selectAll('.pie-chart-tooltip').remove();
+ };
+ }, []);
+
+ return (
+
+
+
+ );
+};
+
+export default PieChart;
diff --git a/frontend/src/metadata/views/statistics/charts/SummaryCards.js b/frontend/src/metadata/views/statistics/charts/SummaryCards.js
new file mode 100644
index 0000000000..bae6c05d4a
--- /dev/null
+++ b/frontend/src/metadata/views/statistics/charts/SummaryCards.js
@@ -0,0 +1,25 @@
+import { gettext } from '../../../../utils/constants';
+import Icon from '../../../../components/icon';
+
+const SummaryCards = ({ totalFiles, totalCollaborators }) => {
+ return (
+
+
+
+
+
{totalFiles.toLocaleString()}
+
{gettext('File count')}
+
+
+
+
+
+
{totalCollaborators.toLocaleString()}
+
{gettext('Collaborator count')}
+
+
+
+ );
+};
+
+export default SummaryCards;
diff --git a/frontend/src/metadata/views/statistics/charts/index.js b/frontend/src/metadata/views/statistics/charts/index.js
new file mode 100644
index 0000000000..c4ce4f8bae
--- /dev/null
+++ b/frontend/src/metadata/views/statistics/charts/index.js
@@ -0,0 +1,3 @@
+export { default as PieChart } from './PieChart';
+export { BarChart, HorizontalBarChart } from './BarChart';
+export { default as SummaryCards } from './SummaryCards';
diff --git a/frontend/src/metadata/views/statistics/index.css b/frontend/src/metadata/views/statistics/index.css
new file mode 100644
index 0000000000..6ed6971458
--- /dev/null
+++ b/frontend/src/metadata/views/statistics/index.css
@@ -0,0 +1,413 @@
+.statistics-view {
+ height: 100%;
+ overflow: auto;
+ padding: 20px;
+ box-sizing: border-box;
+}
+
+.statistics-container {
+ width: 100%;
+ min-width: 1200px;
+ min-height: 100%;
+ height: max-content;
+ gap: 20px;
+ display: grid;
+ grid-template-columns: 550px 1fr auto;
+ grid-template-rows: 420px 340px;
+ grid-template-areas:
+ "file-type creator creator"
+ "time time summary";
+}
+
+.chart-container {
+ background: var(--bs-body-bg);
+ border-radius: 12px;
+ padding: 20px;
+ box-shadow: 0 -2px 4px #f0f0f0;
+ border: 1px solid #f0f0f0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ transition: box-shadow 0.2s ease;
+ min-height: 300px;
+}
+
+[data-bs-theme=dark] .chart-container {
+ border-color: var(--bs-border-color);
+ box-shadow: none;
+}
+
+.chart-container:hover {
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
+}
+
+.chart-container:focus-within {
+ outline: 2px solid #3498db;
+ outline-offset: 2px;
+}
+
+.chart-container.file-type-chart-container {
+ grid-area: file-type;
+}
+
+.chart-container.creator-chart-container {
+ grid-area: creator;
+}
+
+.chart-container.time-chart-container {
+ grid-area: time;
+ gap: 15px;
+}
+
+.chart-container.summary-chart-container {
+ grid-area: summary;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ gap: 24px;
+ width: fit-content;
+}
+
+.chart-header {
+ width: 100%;
+ height: 36px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.chart-header h4 {
+ color: var(--bs-body-color);
+ font-size: 14px;
+ margin: 0;
+ border-bottom: none;
+ padding-bottom: 0;
+}
+
+.chart-header .dropdown {
+ color: var(--bs-icon-color);
+}
+
+.chart-header .dropdown:hover {
+ background-color: var(--bs-hover-bg);
+ border-radius: 3px;
+}
+
+.pie-chart-container,
+.horizontal-bar-chart-container,
+.bar-chart-container {
+ flex: 1;
+ display: flex;
+ min-height: 0;
+}
+
+.pie-chart-container {
+ justify-content: center;
+ align-items: center;
+ min-height: 320px;
+}
+
+.bar-chart-container {
+ height: 240px;
+ overflow-x: auto;
+}
+
+.horizontal-bar-chart-container {
+ width: 100%;
+ height: 340px;
+ overflow-x: auto;
+ justify-content: center;
+ align-items: center;
+}
+
+.bar-chart-responsive {
+ width: 100%;
+ height: 240px;
+ display: flex;
+ flex-direction: column;
+ min-width: 400px;
+}
+
+.bar-chart-responsive svg {
+ flex: 1;
+ height: 240px;
+}
+
+.card-container {
+ display: flex;
+ gap: 20px;
+}
+
+.summary-card {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 50px 20px;
+ background: #fef7ea;
+ border-radius: 10px;
+ width: 240px;
+ height: 240px;
+ flex-shrink: 0;
+}
+
+.summary-card:nth-child(2) {
+ background: #f8f9fd;
+}
+
+.summary-icon {
+ width: 50px;
+ height: 50px;
+ font-size: 32px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-bottom: 12px;
+}
+
+.summary-content {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ flex: 1;
+ justify-content: space-between;
+}
+
+.summary-number {
+ font-size: 42px;
+ font-weight: 700;
+ color: #212529;
+ line-height: 1;
+ margin: 0;
+}
+
+.summary-label {
+ font-size: 16px;
+ color: #666;
+ font-weight: 500;
+ letter-spacing: 0.5px;
+ margin: 0;
+}
+
+.chart-container.time-chart-container .sf-metadata-radio-group {
+ height: 28px;
+ border: none;
+ padding: 2px;
+ background-color: #f3f3f3;
+}
+
+.chart-container.time-chart-container .sf-metadata-radio-group::before {
+ content: none;
+}
+
+[data-bs-theme=dark] .chart-container.time-chart-container .sf-metadata-radio-group {
+ background-color: var(--bs-nav-active-bg);
+}
+
+.chart-container.time-chart-container .sf-metadata-radio-group .sf-metadata-radio-group-option.active {
+ background-color: var(--bs-body-bg);
+}
+
+.chart-container.time-chart-container .sf-metadata-time-grouping-setter .sf-metadata-radio-group-option {
+ height: 24px;
+ font-size: 13px;
+ padding: 0 6px;
+ background-color: transparent;
+ border-radius: 3px;
+}
+
+.sf-metadata-time-grouping-setter[data-active="created"]::before {
+ transform: translateX(0);
+}
+
+.sf-metadata-time-grouping-setter[data-active="modified"]::before {
+ transform: translateX(98px);
+}
+
+.statistics-loading {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ min-height: 400px;
+}
+
+.statistics-error {
+ text-align: center;
+ padding: 48px 24px;
+ color: #666;
+ font-size: 16px;
+ background: white;
+ border-radius: 12px;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
+ border: 1px solid var(--bs-border-color);
+}
+
+.no-data-message {
+ text-align: center;
+ padding: 40px 20px;
+ color: #666;
+ font-size: 14px;
+ font-style: italic;
+ background: #f8f9fa;
+ border-radius: 8px;
+}
+
+.arc path,
+.bar {
+ transition: all 0.2s ease;
+}
+
+.arc path:hover {
+ opacity: 0.8;
+ stroke-width: 3px;
+}
+
+.bar:hover {
+ opacity: 0.8;
+}
+
+.chart-wrapper {
+ animation: fadeInUp 0.6s ease-out;
+}
+
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.arc path,
+.bar {
+ animation: fadeIn 0.5s ease-in-out;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ transform: translateY(10px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.statistics-view .domain {
+ stroke: #999;
+}
+
+.statistics-view .tick line {
+ stroke: #999;
+}
+
+.statistics-view .tick text {
+ fill: #666;
+ font-size: 12px;
+}
+
+@media (max-width: 1023.98px) {
+ .statistics-view {
+ height: 100%;
+ overflow-y: auto;
+ overflow-x: hidden;
+ min-height: 100%;
+ padding: 20px;
+ }
+
+ .statistics-container {
+ grid-template-columns: 1fr;
+ grid-template-rows: 420px 420px minmax(420px, auto) 420px;
+ grid-template-areas:
+ "file-type"
+ "creator"
+ "time"
+ "summary";
+ max-width: 550px;
+ min-width: auto;
+ margin: 0 auto;
+ height: auto;
+ }
+
+ .chart-container.summary-chart-container {
+ justify-content: space-between;
+ align-items: center;
+ padding: 40px 24px;
+ }
+
+ .summary-card {
+ width: 240px;
+ height: 240px;
+ padding: 24px;
+ }
+
+ .summary-number {
+ font-size: 24px;
+ }
+
+ .summary-icon {
+ font-size: 28px;
+ width: 40px;
+ height: 40px;
+ }
+}
+
+@media (max-width: 768px) {
+ .statistics-view {
+ padding: 0;
+ }
+
+ .statistics-container {
+ padding: 16px;
+ gap: 16px;
+ max-width: 100%;
+ grid-template-rows: repeat(4, 380px);
+ }
+
+ .chart-container {
+ padding: 20px;
+ }
+
+ .chart-container h4 {
+ font-size: 14px;
+ margin-bottom: 16px;
+ padding-bottom: 8px;
+ }
+
+ .chart-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+
+ .chart-container.summary-chart-container {
+ flex-direction: column;
+ justify-content: center;
+ gap: 16px;
+ padding: 24px 20px;
+ }
+
+ .summary-card {
+ width: 100%;
+ max-width: 300px;
+ height: 100px;
+ padding: 20px;
+ align-self: center;
+ }
+
+ .summary-number {
+ font-size: 20px;
+ }
+
+ .summary-icon {
+ font-size: 24px;
+ width: 36px;
+ height: 36px;
+ }
+}
diff --git a/frontend/src/metadata/views/statistics/index.js b/frontend/src/metadata/views/statistics/index.js
new file mode 100644
index 0000000000..611acb1c24
--- /dev/null
+++ b/frontend/src/metadata/views/statistics/index.js
@@ -0,0 +1,143 @@
+import React, { useState, useMemo } from 'react';
+import { gettext } from '../../../utils/constants';
+import Loading from '../../../components/loading';
+import SortMenu from '../../../components/sort-menu';
+import RadioGroup from '../../components/radio-group';
+import EmptyTip from '../../../components/empty-tip';
+import { useMetadataView } from '../../hooks/metadata-view';
+import { useCollaborators } from '../../hooks/collaborators';
+import { PieChart, BarChart, HorizontalBarChart, SummaryCards } from './charts';
+import { useStatisticsData } from './useStatisticsData';
+import { processFileTypeData, processTimeData, processCreatorData } from './utils';
+import { TIME_GROUPING_OPTIONS, CREATOR_SORT_OPTIONS } from '../../constants/view/statistics';
+
+import './index.css';
+
+const Statistics = () => {
+ const { repoID } = useMetadataView();
+ const { collaborators, getCollaborator: getCollaboratorFromHook } = useCollaborators();
+ const { isLoading, statisticsData } = useStatisticsData(repoID);
+
+ const [timeGrouping, setTimeGrouping] = useState('created');
+ const [creatorSortBy, setCreatorSortBy] = useState('count');
+ const [creatorSortOrder, setCreatorSortOrder] = useState('desc');
+
+ const pieChartData = useMemo(() =>
+ processFileTypeData(statisticsData?.fileTypeStats),
+ [statisticsData]
+ );
+
+ const timeChartData = useMemo(() =>
+ processTimeData(statisticsData?.timeStats, timeGrouping),
+ [statisticsData, timeGrouping]
+ );
+
+ const creatorChartData = useMemo(() =>
+ processCreatorData(
+ statisticsData?.creatorStats,
+ collaborators,
+ getCollaboratorFromHook,
+ creatorSortBy,
+ creatorSortOrder
+ ),
+ [statisticsData, collaborators, getCollaboratorFromHook, creatorSortBy, creatorSortOrder]
+ );
+
+ const handleTimeGroupingChange = (newGrouping) => {
+ setTimeGrouping(newGrouping);
+ };
+
+ const handleCreatorSortChange = (item) => {
+ const [sortBy, sortOrder] = item.value.split('-');
+ setCreatorSortBy(sortBy);
+ setCreatorSortOrder(sortOrder);
+ };
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (!statisticsData) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
{gettext('Files by type')}
+
+
+
+
+
+
+
{gettext('Files by creator')}
+
+
+
+ {creatorChartData.length > 0 ? (
+
+ ) : (
+
+ {gettext('No creator data available')}
+
+ )}
+
+
+
+
+
+
{gettext('Distributed by time')}
+
+
+
+ {timeChartData.length > 0 ? (
+
+ ) : (
+
+ {gettext('No time-based data available')}
+
+ )}
+
+
+
+
+
+
{gettext('Library')}
+
+
+
+
+
+ );
+};
+
+export default Statistics;
diff --git a/frontend/src/metadata/views/statistics/useStatisticsData.js b/frontend/src/metadata/views/statistics/useStatisticsData.js
new file mode 100644
index 0000000000..f770bc8a52
--- /dev/null
+++ b/frontend/src/metadata/views/statistics/useStatisticsData.js
@@ -0,0 +1,69 @@
+import { useState, useEffect, useCallback } from 'react';
+import { Utils } from '../../../utils/utils';
+import toaster from '../../../components/toast';
+import metadataAPI from '../../api';
+
+export const useStatisticsData = (repoID) => {
+ const [isLoading, setIsLoading] = useState(true);
+ const [statisticsData, setStatisticsData] = useState(null);
+
+ const fetchStatisticsData = useCallback(async () => {
+ if (!repoID) {
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ setIsLoading(true);
+ const response = await metadataAPI.getStatistics(repoID);
+
+ if (response.data && response.data.summary_stats.total_files !== 0) {
+ const transformedData = {
+ fileTypeStats: response.data.file_type_stats.map(item => ({
+ type: item.type,
+ count: item.count
+ })),
+ timeStats: {
+ created: {
+ unit: response.data.time_stats.created.unit || 'year',
+ data: (response.data.time_stats.created.data || []).map(item => ({
+ period: item.period || item.year || item.month || item.day,
+ label: item.label || item.year?.toString() || item.period,
+ count: item.count
+ }))
+ },
+ modified: {
+ unit: response.data.time_stats.modified.unit || 'year',
+ data: (response.data.time_stats.modified.data || []).map(item => ({
+ period: item.period || item.year || item.month || item.day,
+ label: item.label || item.year?.toString() || item.period,
+ count: item.count
+ }))
+ }
+ },
+ creatorStats: response.data.creator_stats.map(item => ({
+ creator: item.creator,
+ count: item.count
+ })),
+ totalFiles: response.data.summary_stats.total_files,
+ totalCollaborators: response.data.summary_stats.total_collaborators
+ };
+
+ setStatisticsData(transformedData);
+ } else {
+ setStatisticsData(null);
+ }
+ } catch (error) {
+ toaster.danger(Utils.getErrorMsg(error));
+ setStatisticsData(null);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [repoID]);
+
+ useEffect(() => {
+ fetchStatisticsData();
+ }, [fetchStatisticsData]);
+
+ return { isLoading, statisticsData, refetch: fetchStatisticsData };
+};
diff --git a/frontend/src/metadata/views/statistics/utils.js b/frontend/src/metadata/views/statistics/utils.js
new file mode 100644
index 0000000000..25eadacfcc
--- /dev/null
+++ b/frontend/src/metadata/views/statistics/utils.js
@@ -0,0 +1,54 @@
+import { mediaUrl } from '../../../utils/constants';
+import { getCollaborator } from '../../utils/cell/column/collaborator';
+import { FILE_TYPE_NAMES, FILE_TYPE_COLORS } from '../../constants/view/statistics';
+
+export const processFileTypeData = (fileTypeStats) => {
+ if (!fileTypeStats) return [];
+
+ return fileTypeStats.map(item => ({
+ label: FILE_TYPE_NAMES[item.type] || item.type,
+ value: item.count,
+ color: FILE_TYPE_COLORS[item.type] || '#636e72',
+ type: item.type
+ }));
+};
+
+export const processTimeData = (timeStats, timeGrouping) => {
+ if (!timeStats?.[timeGrouping]?.data) return [];
+
+ return timeStats[timeGrouping].data.map(item => ({
+ name: item.label,
+ value: item.count,
+ period: item.period
+ }));
+};
+
+export const processCreatorData = (creatorStats, collaborators, getCollaboratorFromHook, sortBy, sortOrder) => {
+ if (!creatorStats) return [];
+
+ const processed = creatorStats.map(item => {
+ const collaborator = getCollaboratorFromHook
+ ? getCollaboratorFromHook(item.creator)
+ : getCollaborator(collaborators, item.creator);
+
+ return {
+ name: item.creator,
+ displayName: collaborator?.name || item.creator,
+ avatarUrl: collaborator?.avatar_url || `${mediaUrl}/avatars/default.png`,
+ value: item.count,
+ collaborator: collaborator
+ };
+ });
+
+ return processed.sort((a, b) => {
+ let compareValue = 0;
+
+ if (sortBy === 'count') {
+ compareValue = a.value - b.value;
+ } else if (sortBy === 'name') {
+ compareValue = a.displayName.localeCompare(b.displayName);
+ }
+
+ return sortOrder === 'asc' ? compareValue : -compareValue;
+ });
+};
diff --git a/frontend/src/metadata/views/view.js b/frontend/src/metadata/views/view.js
index 3a17243841..57adb76c8e 100644
--- a/frontend/src/metadata/views/view.js
+++ b/frontend/src/metadata/views/view.js
@@ -6,6 +6,7 @@ import Gallery from './gallery';
import FaceRecognition from './face-recognition';
import Kanban from './kanban';
import Map from './map';
+import Statistics from './statistics';
import { useMetadataView } from '../hooks/metadata-view';
import { VIEW_TYPE } from '../constants';
import { gettext } from '../../utils/constants';
@@ -32,6 +33,9 @@ const View = () => {
case VIEW_TYPE.MAP: {
return ;
}
+ case VIEW_TYPE.STATISTICS: {
+ return ;
+ }
default:
return null;
}
diff --git a/seahub/repo_metadata/apis.py b/seahub/repo_metadata/apis.py
index edae20515e..ea52a3832b 100644
--- a/seahub/repo_metadata/apis.py
+++ b/seahub/repo_metadata/apis.py
@@ -3270,3 +3270,349 @@ class MetadataImportTags(APIView):
logger.exception(e)
return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, 'Internal Server Error')
return Response({'success': True})
+
+
+class MetadataStatistics(APIView):
+ """
+ API for retrieving library metadata statistics for the statistics view.
+ """
+ authentication_classes = (TokenAuthentication, SessionAuthentication)
+ permission_classes = (IsAuthenticated,)
+ throttle_classes = (UserRateThrottle,)
+
+ def get(self, request, repo_id):
+ """
+ Get statistics data for metadata view including:
+ - File type distribution
+ - Files by creation/modification time
+ - Files by creator
+ - Summary statistics
+ """
+ repo = seafile_api.get_repo(repo_id)
+ if not repo:
+ error_msg = 'Library %s not found.' % repo_id
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ if not can_read_metadata(request, repo_id):
+ error_msg = 'Permission denied.'
+ return api_error(status.HTTP_403_FORBIDDEN, error_msg)
+
+ record = RepoMetadata.objects.filter(repo_id=repo_id).first()
+ if not record or not record.enabled:
+ error_msg = f'The metadata module is disabled for repo {repo_id}.'
+ return api_error(status.HTTP_404_NOT_FOUND, error_msg)
+
+ try:
+ metadata_server_api = MetadataServerAPI(repo_id, request.user.username)
+ from seafevents.repo_metadata.constants import METADATA_TABLE
+
+ statistics = self._get_sql_statistics(metadata_server_api, METADATA_TABLE)
+
+ return Response(statistics)
+
+ except Exception as e:
+ logger.error(f'Error generating metadata statistics for repo {repo_id}: {e}')
+ error_msg = 'Internal Server Error'
+ return api_error(status.HTTP_500_INTERNAL_SERVER_ERROR, error_msg)
+
+ def _get_sql_statistics(self, metadata_server_api, METADATA_TABLE):
+ default_response = {
+ 'file_type_stats': [],
+ 'time_stats': {
+ 'created': {'unit': 'year', 'data': [], 'grouping': 'created'},
+ 'modified': {'unit': 'year', 'data': [], 'grouping': 'modified'}
+ },
+ 'creator_stats': [],
+ 'summary_stats': {
+ 'total_files': 0,
+ 'total_collaborators': 0
+ }
+ }
+
+ try:
+ file_type_stats = self._get_file_type_stats_sql(metadata_server_api, METADATA_TABLE)
+ creator_stats = self._get_creator_stats_sql(metadata_server_api, METADATA_TABLE)
+ summary_stats = self._get_summary_stats_sql(metadata_server_api, METADATA_TABLE)
+ time_stats = self._get_time_stats_sql(metadata_server_api, METADATA_TABLE)
+
+ return {
+ 'file_type_stats': file_type_stats,
+ 'time_stats': time_stats,
+ 'creator_stats': creator_stats,
+ 'summary_stats': summary_stats
+ }
+
+ except Exception as e:
+ logger.error(f'Error in SQL statistics processing: {e}')
+ return default_response
+
+ def _get_file_type_stats_sql(self, metadata_server_api, METADATA_TABLE):
+ sql = f'''
+ SELECT
+ `{METADATA_TABLE.columns.file_type.name}` as file_type,
+ COUNT(*) as count
+ FROM `{METADATA_TABLE.name}`
+ WHERE `{METADATA_TABLE.columns.is_dir.name}` = false
+ GROUP BY `{METADATA_TABLE.columns.file_type.name}`
+ ORDER BY count DESC
+ '''
+
+ results = metadata_server_api.query_rows(sql, [])
+ if not results or 'results' not in results:
+ return []
+
+ processed_results = []
+ for row in results['results']:
+ file_type = row.get('file_type', '') or 'other'
+ if not file_type.strip():
+ file_type = 'other'
+ processed_results.append({'type': file_type, 'count': row['count']})
+
+ return processed_results
+
+ def _get_creator_stats_sql(self, metadata_server_api, METADATA_TABLE):
+ sql = f'''
+ SELECT
+ `{METADATA_TABLE.columns.file_creator.name}` as creator,
+ COUNT(*) as count
+ FROM `{METADATA_TABLE.name}`
+ WHERE `{METADATA_TABLE.columns.is_dir.name}` = false
+ AND `{METADATA_TABLE.columns.file_creator.name}` IS NOT NULL
+ AND `{METADATA_TABLE.columns.file_creator.name}` != ''
+ GROUP BY `{METADATA_TABLE.columns.file_creator.name}`
+ ORDER BY count DESC
+ '''
+
+ results = metadata_server_api.query_rows(sql, [])
+ if not results or 'results' not in results:
+ return []
+
+ return [
+ {'creator': row['creator'], 'count': row['count']}
+ for row in results['results']
+ ]
+
+ def _get_summary_stats_sql(self, metadata_server_api, METADATA_TABLE):
+ sql = f'''
+ SELECT
+ COUNT(*) as total_files
+ FROM `{METADATA_TABLE.name}`
+ WHERE `{METADATA_TABLE.columns.is_dir.name}` = false
+ '''
+
+ results = metadata_server_api.query_rows(sql, [])
+ if not results or 'results' not in results or not results['results']:
+ return {'total_files': 0, 'total_collaborators': 0}
+
+ row = results['results'][0]
+
+ collaborators_set = set()
+
+ all_creators_sql = f'''
+ SELECT DISTINCT `{METADATA_TABLE.columns.file_creator.name}` as creator
+ FROM `{METADATA_TABLE.name}`
+ WHERE `{METADATA_TABLE.columns.is_dir.name}` = false
+ AND `{METADATA_TABLE.columns.file_creator.name}` IS NOT NULL
+ AND `{METADATA_TABLE.columns.file_creator.name}` != ''
+ '''
+
+ creator_list_results = metadata_server_api.query_rows(all_creators_sql, [])
+ if creator_list_results and 'results' in creator_list_results:
+ for creator_row in creator_list_results['results']:
+ collaborators_set.add(creator_row['creator'])
+
+ all_modifiers_sql = f'''
+ SELECT DISTINCT `{METADATA_TABLE.columns.file_modifier.name}` as modifier
+ FROM `{METADATA_TABLE.name}`
+ WHERE `{METADATA_TABLE.columns.is_dir.name}` = false
+ AND `{METADATA_TABLE.columns.file_modifier.name}` IS NOT NULL
+ AND `{METADATA_TABLE.columns.file_modifier.name}` != ''
+ '''
+
+ modifier_list_results = metadata_server_api.query_rows(all_modifiers_sql, [])
+ if modifier_list_results and 'results' in modifier_list_results:
+ for modifier_row in modifier_list_results['results']:
+ collaborators_set.add(modifier_row['modifier'])
+
+ return {
+ 'total_files': row['total_files'],
+ 'total_collaborators': len(collaborators_set)
+ }
+
+ def _get_time_stats_sql(self, metadata_server_api, METADATA_TABLE):
+ date_range_sql = f'''
+ SELECT
+ MIN(`{METADATA_TABLE.columns.file_ctime.name}`) as min_ctime,
+ MAX(`{METADATA_TABLE.columns.file_ctime.name}`) as max_ctime,
+ MIN(`{METADATA_TABLE.columns.file_mtime.name}`) as min_mtime,
+ MAX(`{METADATA_TABLE.columns.file_mtime.name}`) as max_mtime,
+ COUNT(*) as total_files
+ FROM `{METADATA_TABLE.name}`
+ WHERE `{METADATA_TABLE.columns.is_dir.name}` = false
+ AND (`{METADATA_TABLE.columns.file_ctime.name}` IS NOT NULL
+ OR `{METADATA_TABLE.columns.file_mtime.name}` IS NOT NULL)
+ '''
+
+ range_results = metadata_server_api.query_rows(date_range_sql, [])
+ if not range_results or 'results' not in range_results or not range_results['results']:
+ return {
+ 'created': {'unit': 'year', 'data': [], 'grouping': 'created'},
+ 'modified': {'unit': 'year', 'data': [], 'grouping': 'modified'}
+ }
+
+ range_data = range_results['results'][0]
+
+ created_stats = self._get_time_period_stats_sql(
+ metadata_server_api, METADATA_TABLE, 'created',
+ range_data['min_ctime'], range_data['max_ctime']
+ )
+
+ modified_stats = self._get_time_period_stats_sql(
+ metadata_server_api, METADATA_TABLE, 'modified',
+ range_data['min_mtime'], range_data['max_mtime']
+ )
+
+ return {
+ 'created': created_stats,
+ 'modified': modified_stats
+ }
+
+ def _get_time_period_stats_sql(self, metadata_server_api, METADATA_TABLE, time_type, min_date, max_date):
+ from datetime import datetime
+
+ if not min_date or not max_date:
+ return {'unit': 'year', 'data': [], 'grouping': time_type}
+ try:
+ min_dt = self._format_datetime(min_date)
+ max_dt = self._format_datetime(max_date)
+ time_span = max_dt - min_dt
+ years_span = time_span.days / 365.25
+ months_span = time_span.days / 30.44
+
+ except (ValueError, AttributeError):
+ return {'unit': 'year', 'data': [], 'grouping': time_type}
+
+ date_column = METADATA_TABLE.columns.file_ctime.name if time_type == 'created' else METADATA_TABLE.columns.file_mtime.name
+
+ if years_span >= 3:
+ sql = f'''
+ SELECT
+ `{date_column}` as date_value,
+ COUNT(*) as count
+ FROM `{METADATA_TABLE.name}`
+ WHERE `{METADATA_TABLE.columns.is_dir.name}` = false
+ AND `{date_column}` IS NOT NULL
+ GROUP BY `{date_column}`
+ ORDER BY `{date_column}`
+ '''
+
+ results = metadata_server_api.query_rows(sql, [])
+ if not results or 'results' not in results:
+ return {'unit': 'year', 'data': [], 'grouping': time_type}
+
+ year_counts = {}
+ for row in results['results']:
+ try:
+ dt = self._format_datetime(row['date_value'])
+ year = dt.year
+ year_counts[year] = year_counts.get(year, 0) + row['count']
+ except (ValueError, TypeError, AttributeError):
+ continue
+
+ data = [
+ {
+ 'period': str(year),
+ 'label': str(year),
+ 'count': count
+ }
+ for year, count in sorted(year_counts.items())
+ ]
+
+ return {'unit': 'year', 'data': data, 'grouping': time_type}
+
+ elif months_span >= 6:
+ sql = f'''
+ SELECT
+ `{date_column}` as date_value,
+ COUNT(*) as count
+ FROM `{METADATA_TABLE.name}`
+ WHERE `{METADATA_TABLE.columns.is_dir.name}` = false
+ AND `{date_column}` IS NOT NULL
+ GROUP BY `{date_column}`
+ ORDER BY `{date_column}`
+ '''
+
+ results = metadata_server_api.query_rows(sql, [])
+ if not results or 'results' not in results:
+ return {'unit': 'month', 'data': [], 'grouping': time_type}
+
+ import calendar
+ month_counts = {}
+ for row in results['results']:
+ try:
+ dt = self._format_datetime(row['date_value'])
+ month_key = f"{dt.year}-{dt.month:02d}"
+ month_counts[month_key] = month_counts.get(month_key, 0) + row['count']
+ except (ValueError, TypeError, AttributeError):
+ continue
+
+ data = []
+ for month_key, count in sorted(month_counts.items()):
+ try:
+ year, month = month_key.split('-')
+ month_name = calendar.month_abbr[int(month)]
+ label = f"{month_name} {year}"
+ data.append({
+ 'period': month_key,
+ 'label': label,
+ 'count': count
+ })
+ except (ValueError, IndexError):
+ continue
+
+ return {'unit': 'month', 'data': data, 'grouping': time_type}
+
+ else:
+ sql = f'''
+ SELECT
+ `{date_column}` as date_value,
+ COUNT(*) as count
+ FROM `{METADATA_TABLE.name}`
+ WHERE `{METADATA_TABLE.columns.is_dir.name}` = false
+ AND `{date_column}` IS NOT NULL
+ GROUP BY `{date_column}`
+ ORDER BY `{date_column}`
+ '''
+
+ results = metadata_server_api.query_rows(sql, [])
+ if not results or 'results' not in results:
+ return {'unit': 'day', 'data': [], 'grouping': time_type}
+
+ day_counts = {}
+ for row in results['results']:
+ try:
+ dt = self._format_datetime(row['date_value'])
+ day_key = dt.strftime('%Y-%m-%d')
+ day_counts[day_key] = day_counts.get(day_key, 0) + row['count']
+ except (ValueError, TypeError, AttributeError):
+ continue
+
+ data = []
+ for day_key, count in sorted(day_counts.items()):
+ try:
+ date_obj = datetime.strptime(day_key, '%Y-%m-%d')
+ label = date_obj.strftime('%b %d')
+ data.append({
+ 'period': day_key,
+ 'label': label,
+ 'count': count
+ })
+ except (ValueError, TypeError):
+ continue
+
+ return {'unit': 'day', 'data': data, 'grouping': time_type}
+
+ def _format_datetime(self, dt):
+ if isinstance(dt, str):
+ return datetime.fromisoformat(dt)
+ return dt
diff --git a/seahub/repo_metadata/urls.py b/seahub/repo_metadata/urls.py
index 1c5a88ddf7..99281adb00 100644
--- a/seahub/repo_metadata/urls.py
+++ b/seahub/repo_metadata/urls.py
@@ -4,7 +4,7 @@ from .apis import MetadataRecognizeFaces, MetadataRecords, MetadataManage, Metad
FaceRecognitionManage, FacesRecord, MetadataExtractFileDetails, PeoplePhotos, MetadataTagsStatusManage, MetadataTags, \
MetadataTagsLinks, MetadataFileTags, MetadataTagFiles, MetadataMergeTags, MetadataTagsFiles, MetadataDetailsSettingsView, \
PeopleCoverPhoto, MetadataMigrateTags, MetadataExportTags, MetadataImportTags, MetadataGlobalHiddenColumnsView, \
- MetadataBatchRecords
+ MetadataBatchRecords, MetadataStatistics
urlpatterns = [
re_path(r'^$', MetadataManage.as_view(), name='api-v2.1-metadata'),
@@ -48,4 +48,7 @@ urlpatterns = [
re_path(r'^migrate-tags/$', MetadataMigrateTags.as_view(), name='api-v2.1-metadata-migrate-tags'),
re_path(r'^export-tags/$', MetadataExportTags.as_view(), name='api-v2.1-metadata-export-tags'),
re_path(r'^import-tags/$', MetadataImportTags.as_view(), name='api-v2.1-metadata-import-tags'),
+
+ # statistics api
+ re_path(r'^statistics/$', MetadataStatistics.as_view(), name='api-v2.1-metadata-statistics'),
]