Benjamin Harris 1 месяц назад
Родитель
Сommit
a07f032249
4 измененных файлов с 145 добавлено и 34 удалено
  1. 65 27
      services/gateway/server.js
  2. 3 0
      ui/src/locales/en.ts
  3. 3 0
      ui/src/locales/tr.ts
  4. 74 7
      ui/src/views/Analytics.vue

+ 65 - 27
services/gateway/server.js

@@ -978,22 +978,29 @@ app.post('/analytics/crawl', async () => {
   return { success: true, total, byPlatform: results };
   return { success: true, total, byPlatform: results };
 });
 });
 
 
-app.get('/analytics/insights', async () => {
+app.get('/analytics/insights', async (request) => {
+  const filter = parseAccountFilter(request.query.account);
+  const metricsMatch = filter
+    ? { platform: filter.platform, ...(filter.accountId && { accountId: filter.accountId }) }
+    : {};
   const db = await getDb();
   const db = await getDb();
-  const total = await db.collection('post_metrics').countDocuments({});
+  const total = await db.collection('post_metrics').countDocuments(metricsMatch);
   if (total === 0) return { empty: true };
   if (total === 0) return { empty: true };
 
 
   const [byHourRaw, byDayRaw, topPosts, platformComparison, heatmapRaw] = await Promise.all([
   const [byHourRaw, byDayRaw, topPosts, platformComparison, heatmapRaw] = await Promise.all([
     db.collection('post_metrics').aggregate([
     db.collection('post_metrics').aggregate([
+      { $match: metricsMatch },
       { $group: { _id: '$hourOfDay', avgEngagement: { $avg: '$metrics.engagementTotal' }, count: { $sum: 1 } } },
       { $group: { _id: '$hourOfDay', avgEngagement: { $avg: '$metrics.engagementTotal' }, count: { $sum: 1 } } },
       { $sort: { _id: 1 } },
       { $sort: { _id: 1 } },
     ]).toArray(),
     ]).toArray(),
     db.collection('post_metrics').aggregate([
     db.collection('post_metrics').aggregate([
+      { $match: metricsMatch },
       { $group: { _id: '$dayOfWeek', avgEngagement: { $avg: '$metrics.engagementTotal' }, count: { $sum: 1 } } },
       { $group: { _id: '$dayOfWeek', avgEngagement: { $avg: '$metrics.engagementTotal' }, count: { $sum: 1 } } },
       { $sort: { _id: 1 } },
       { $sort: { _id: 1 } },
     ]).toArray(),
     ]).toArray(),
-    db.collection('post_metrics').find({}).sort({ 'metrics.engagementTotal': -1 }).limit(5).toArray(),
+    db.collection('post_metrics').find(metricsMatch).sort({ 'metrics.engagementTotal': -1 }).limit(5).toArray(),
     db.collection('post_metrics').aggregate([
     db.collection('post_metrics').aggregate([
+      { $match: metricsMatch },
       { $group: {
       { $group: {
         _id: '$platform',
         _id: '$platform',
         avgEngagement: { $avg: '$metrics.engagementTotal' },
         avgEngagement: { $avg: '$metrics.engagementTotal' },
@@ -1005,6 +1012,7 @@ app.get('/analytics/insights', async () => {
       { $sort: { avgEngagement: -1 } },
       { $sort: { avgEngagement: -1 } },
     ]).toArray(),
     ]).toArray(),
     db.collection('post_metrics').aggregate([
     db.collection('post_metrics').aggregate([
+      { $match: metricsMatch },
       { $group: {
       { $group: {
         _id: { day: '$dayOfWeek', hour: '$hourOfDay' },
         _id: { day: '$dayOfWeek', hour: '$hourOfDay' },
         avgEngagement: { $avg: '$metrics.engagementTotal' },
         avgEngagement: { $avg: '$metrics.engagementTotal' },
@@ -1051,13 +1059,43 @@ app.get('/analytics/insights', async () => {
 
 
 // ─── Analytics ────────────────────────────────────────────────────────────────
 // ─── Analytics ────────────────────────────────────────────────────────────────
 
 
-app.get('/analytics/summary', async () => {
+// Parse "platform" or "platform:accountId" filter strings from the account query param.
+function parseAccountFilter(account) {
+  if (!account) return null;
+  const idx = account.indexOf(':');
+  if (idx === -1) return { platform: account };
+  return { platform: account.slice(0, idx), accountId: account.slice(idx + 1) };
+}
+
+// Build a MongoDB match fragment for scheduled_jobs given an account filter.
+function sjFilter(filter) {
+  if (!filter) return {};
+  return {
+    'destinations.platform': filter.platform,
+    ...(filter.accountId && { 'destinations.accountId': filter.accountId }),
+  };
+}
+
+// Build a MongoDB match fragment for posts (type:immediate) given an account filter.
+function ipFilter(filter) {
+  if (!filter) return {};
+  return {
+    'destinations.platform': filter.platform,
+    ...(filter.accountId && { 'destinations.accountId': filter.accountId }),
+  };
+}
+
+app.get('/analytics/summary', async (request) => {
+  const filter = parseAccountFilter(request.query.account);
   const db = await getDb();
   const db = await getDb();
   const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
   const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
   const sevenDaysAgo  = new Date(Date.now() -  7 * 24 * 60 * 60 * 1000);
   const sevenDaysAgo  = new Date(Date.now() -  7 * 24 * 60 * 60 * 1000);
 
 
-  // scheduled_jobs is the primary source — it holds the full history of all
-  // scheduled posts. posts (type: immediate) supplements it for direct dispatches.
+  // Post-unwind filter for scheduled_jobs platform breakdown — re-applies the
+  // account filter after $unwind so a job targeting multiple platforms only
+  // counts the platform(s) that match the filter.
+  const unwindFilter = filter ? [{ $match: sjFilter(filter) }] : [];
+
   const [
   const [
     schedCompleted, schedFailed,
     schedCompleted, schedFailed,
     immPublished, immFailed,
     immPublished, immFailed,
@@ -1065,22 +1103,23 @@ app.get('/analytics/summary', async () => {
     schedPlatformRaw, immPlatformRaw,
     schedPlatformRaw, immPlatformRaw,
     schedDayRaw, immDayRaw,
     schedDayRaw, immDayRaw,
   ] = await Promise.all([
   ] = await Promise.all([
-    db.collection('scheduled_jobs').countDocuments({ status: 'completed' }),
-    db.collection('scheduled_jobs').countDocuments({ status: 'failed' }),
-    db.collection('posts').countDocuments({ type: 'immediate', status: { $in: ['published', 'partial'] } }),
-    db.collection('posts').countDocuments({ type: 'immediate', status: 'failed' }),
-    db.collection('scheduled_jobs').countDocuments({ status: 'completed', completedAt: { $gte: sevenDaysAgo } }),
-    db.collection('posts').countDocuments({ type: 'immediate', publishedAt: { $gte: sevenDaysAgo } }),
+    db.collection('scheduled_jobs').countDocuments({ status: 'completed', ...sjFilter(filter) }),
+    db.collection('scheduled_jobs').countDocuments({ status: 'failed', ...sjFilter(filter) }),
+    db.collection('posts').countDocuments({ type: 'immediate', status: { $in: ['published', 'partial'] }, ...ipFilter(filter) }),
+    db.collection('posts').countDocuments({ type: 'immediate', status: 'failed', ...ipFilter(filter) }),
+    db.collection('scheduled_jobs').countDocuments({ status: 'completed', completedAt: { $gte: sevenDaysAgo }, ...sjFilter(filter) }),
+    db.collection('posts').countDocuments({ type: 'immediate', publishedAt: { $gte: sevenDaysAgo }, ...ipFilter(filter) }),
     // Platform breakdown from scheduled_jobs destinations
     // Platform breakdown from scheduled_jobs destinations
     db.collection('scheduled_jobs').aggregate([
     db.collection('scheduled_jobs').aggregate([
-      { $match: { status: 'completed' } },
+      { $match: { status: 'completed', ...sjFilter(filter) } },
       { $unwind: '$destinations' },
       { $unwind: '$destinations' },
+      ...unwindFilter,
       { $group: { _id: '$destinations.platform', count: { $sum: 1 } } },
       { $group: { _id: '$destinations.platform', count: { $sum: 1 } } },
       { $sort: { count: -1 } },
       { $sort: { count: -1 } },
     ]).toArray(),
     ]).toArray(),
     // Platform breakdown from immediate posts platformResults
     // Platform breakdown from immediate posts platformResults
     db.collection('posts').aggregate([
     db.collection('posts').aggregate([
-      { $match: { type: 'immediate' } },
+      { $match: { type: 'immediate', ...ipFilter(filter) } },
       { $project: { results: { $objectToArray: { $ifNull: ['$platformResults', {}] } } } },
       { $project: { results: { $objectToArray: { $ifNull: ['$platformResults', {}] } } } },
       { $unwind: '$results' },
       { $unwind: '$results' },
       { $match: { 'results.v.success': true } },
       { $match: { 'results.v.success': true } },
@@ -1089,26 +1128,24 @@ app.get('/analytics/summary', async () => {
     ]).toArray(),
     ]).toArray(),
     // Activity by day from scheduled_jobs (using completedAt)
     // Activity by day from scheduled_jobs (using completedAt)
     db.collection('scheduled_jobs').aggregate([
     db.collection('scheduled_jobs').aggregate([
-      { $match: { status: 'completed', completedAt: { $gte: thirtyDaysAgo } } },
+      { $match: { status: 'completed', completedAt: { $gte: thirtyDaysAgo }, ...sjFilter(filter) } },
       { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$completedAt' } }, count: { $sum: 1 } } },
       { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$completedAt' } }, count: { $sum: 1 } } },
       { $sort: { _id: 1 } },
       { $sort: { _id: 1 } },
     ]).toArray(),
     ]).toArray(),
     // Activity by day from immediate posts
     // Activity by day from immediate posts
     db.collection('posts').aggregate([
     db.collection('posts').aggregate([
-      { $match: { type: 'immediate', publishedAt: { $gte: thirtyDaysAgo } } },
+      { $match: { type: 'immediate', publishedAt: { $gte: thirtyDaysAgo }, ...ipFilter(filter) } },
       { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$publishedAt' } }, count: { $sum: 1 } } },
       { $group: { _id: { $dateToString: { format: '%Y-%m-%d', date: '$publishedAt' } }, count: { $sum: 1 } } },
       { $sort: { _id: 1 } },
       { $sort: { _id: 1 } },
     ]).toArray(),
     ]).toArray(),
   ]);
   ]);
 
 
-  // Merge byDay from both sources
   const dayMap = {};
   const dayMap = {};
   for (const { _id, count } of [...schedDayRaw, ...immDayRaw]) {
   for (const { _id, count } of [...schedDayRaw, ...immDayRaw]) {
     dayMap[_id] = (dayMap[_id] || 0) + count;
     dayMap[_id] = (dayMap[_id] || 0) + count;
   }
   }
   const byDay = Object.entries(dayMap).map(([date, count]) => ({ date, count })).sort((a, b) => a.date.localeCompare(b.date));
   const byDay = Object.entries(dayMap).map(([date, count]) => ({ date, count })).sort((a, b) => a.date.localeCompare(b.date));
 
 
-  // Merge byPlatform from both sources
   const platformMap = {};
   const platformMap = {};
   for (const { _id, count } of [...schedPlatformRaw, ...immPlatformRaw]) {
   for (const { _id, count } of [...schedPlatformRaw, ...immPlatformRaw]) {
     if (_id) platformMap[_id] = (platformMap[_id] || 0) + count;
     if (_id) platformMap[_id] = (platformMap[_id] || 0) + count;
@@ -1124,30 +1161,31 @@ app.get('/analytics/summary', async () => {
 });
 });
 
 
 app.get('/analytics/posts', async (request) => {
 app.get('/analytics/posts', async (request) => {
-  const limit = Math.min(parseInt(request.query.limit || '20', 10), 100);
-  const skip  = parseInt(request.query.skip || '0', 10);
+  const limit  = Math.min(parseInt(request.query.limit || '20', 10), 100);
+  const skip   = parseInt(request.query.skip || '0', 10);
+  const filter = parseAccountFilter(request.query.account);
   const db = await getDb();
   const db = await getDb();
 
 
-  // scheduled_jobs holds all scheduled-post history (content stored from now on;
-  // older records have content: undefined). Immediate posts come from the posts collection.
+  const sjMatch = { status: { $in: ['completed', 'failed'] }, ...sjFilter(filter) };
+  const ipMatch = { type: 'immediate', ...ipFilter(filter) };
+
   const [scheduledJobs, immediatePosts, schedTotal, immTotal] = await Promise.all([
   const [scheduledJobs, immediatePosts, schedTotal, immTotal] = await Promise.all([
     db.collection('scheduled_jobs')
     db.collection('scheduled_jobs')
-      .find({ status: { $in: ['completed', 'failed'] } })
+      .find(sjMatch)
       .sort({ completedAt: -1, scheduledAt: -1 })
       .sort({ completedAt: -1, scheduledAt: -1 })
       .skip(skip)
       .skip(skip)
       .limit(limit)
       .limit(limit)
       .project({ content: 1, destinations: 1, status: 1, completedAt: 1, scheduledAt: 1 })
       .project({ content: 1, destinations: 1, status: 1, completedAt: 1, scheduledAt: 1 })
       .toArray(),
       .toArray(),
     db.collection('posts')
     db.collection('posts')
-      .find({ type: 'immediate' })
+      .find(ipMatch)
       .sort({ publishedAt: -1 })
       .sort({ publishedAt: -1 })
       .project({ content: 1, destinations: 1, platformResults: 1, status: 1, publishedAt: 1 })
       .project({ content: 1, destinations: 1, platformResults: 1, status: 1, publishedAt: 1 })
       .toArray(),
       .toArray(),
-    db.collection('scheduled_jobs').countDocuments({ status: { $in: ['completed', 'failed'] } }),
-    db.collection('posts').countDocuments({ type: 'immediate' }),
+    db.collection('scheduled_jobs').countDocuments(sjMatch),
+    db.collection('posts').countDocuments(ipMatch),
   ]);
   ]);
 
 
-  // Normalise to a single shape expected by the frontend
   const normalised = [
   const normalised = [
     ...scheduledJobs.map((j) => ({
     ...scheduledJobs.map((j) => ({
       _id: String(j._id),
       _id: String(j._id),

+ 3 - 0
ui/src/locales/en.ts

@@ -47,6 +47,9 @@ export default {
     loadMore: 'Load more',
     loadMore: 'Load more',
     noContent: 'Content not available for older posts',
     noContent: 'Content not available for older posts',
 
 
+    filterBy: 'Filter:',
+    filterAll: 'All',
+
     crawlMetrics: 'Crawl Metrics',
     crawlMetrics: 'Crawl Metrics',
     crawling: 'Crawling…',
     crawling: 'Crawling…',
     crawlDone: 'Crawled {count} posts',
     crawlDone: 'Crawled {count} posts',

+ 3 - 0
ui/src/locales/tr.ts

@@ -47,6 +47,9 @@ export default {
     loadMore: 'Daha fazla yükle',
     loadMore: 'Daha fazla yükle',
     noContent: 'Eski gönderiler için içerik mevcut değil',
     noContent: 'Eski gönderiler için içerik mevcut değil',
 
 
+    filterBy: 'Filtrele:',
+    filterAll: 'Tümü',
+
     crawlMetrics: 'Metrikleri Getir',
     crawlMetrics: 'Metrikleri Getir',
     crawling: 'Getiriliyor…',
     crawling: 'Getiriliyor…',
     crawlDone: '{count} gönderi getirildi',
     crawlDone: '{count} gönderi getirildi',

+ 74 - 7
ui/src/views/Analytics.vue

@@ -28,6 +28,31 @@
         </div>
         </div>
       </div>
       </div>
 
 
+      <!-- Account filter chips -->
+      <div v-if="filterAccounts.length > 1" class="flex items-center gap-2 flex-wrap mb-6">
+        <span class="text-xs text-gray-500 shrink-0">{{ $t('analytics.filterBy') }}</span>
+        <button
+          @click="selectedAccount = null"
+          class="px-3 py-1 rounded-full text-xs border transition-colors"
+          :class="selectedAccount === null
+            ? 'border-white/40 bg-white/10 text-white'
+            : 'border-gray-700 text-gray-400 hover:border-gray-500 hover:text-gray-300'"
+        >{{ $t('analytics.filterAll') }}</button>
+        <button
+          v-for="acc in filterAccounts"
+          :key="acc.key"
+          @click="selectedAccount = selectedAccount === acc.key ? null : acc.key"
+          class="px-3 py-1 rounded-full text-xs border transition-colors flex items-center gap-1.5"
+          :style="selectedAccount === acc.key
+            ? { borderColor: platformColor(acc.platform), background: platformColor(acc.platform) + '33', color: '#fff' }
+            : {}"
+          :class="selectedAccount === acc.key ? '' : 'border-gray-700 text-gray-400 hover:border-gray-500 hover:text-gray-300'"
+        >
+          <span class="w-1.5 h-1.5 rounded-full shrink-0" :style="{ background: platformColor(acc.platform) }"></span>
+          {{ acc.label }}
+        </button>
+      </div>
+
       <!-- Loading -->
       <!-- Loading -->
       <div v-if="loading && !summary" class="flex items-center justify-center h-64 text-gray-500">
       <div v-if="loading && !summary" class="flex items-center justify-center h-64 text-gray-500">
         {{ $t('analytics.loading') }}
         {{ $t('analytics.loading') }}
@@ -405,9 +430,9 @@
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, watch, onMounted } from 'vue'
 import axios from 'axios'
 import axios from 'axios'
-import { PLATFORM_META } from '../stores/platforms'
+import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
 
 
 // ── Types ─────────────────────────────────────────────────────────────────────
 // ── Types ─────────────────────────────────────────────────────────────────────
 
 
@@ -460,12 +485,16 @@ const DAY_COUNT = 30
 
 
 // ── State ─────────────────────────────────────────────────────────────────────
 // ── State ─────────────────────────────────────────────────────────────────────
 
 
+const platformsStore = usePlatformsStore()
+
 const loading     = ref(false)
 const loading     = ref(false)
 const loadingMore = ref(false)
 const loadingMore = ref(false)
 const summary     = ref<Summary | null>(null)
 const summary     = ref<Summary | null>(null)
 const posts       = ref<Post[]>([])
 const posts       = ref<Post[]>([])
 const postsTotal  = ref(0)
 const postsTotal  = ref(0)
 
 
+const selectedAccount = ref<string | null>(null)
+
 const insightsLoading = ref(false)
 const insightsLoading = ref(false)
 const insights        = ref<Insights | null>(null)
 const insights        = ref<Insights | null>(null)
 const crawling        = ref(false)
 const crawling        = ref(false)
@@ -473,12 +502,16 @@ const crawlResult     = ref<number | null>(null)
 
 
 // ── Data loading ──────────────────────────────────────────────────────────────
 // ── Data loading ──────────────────────────────────────────────────────────────
 
 
+function accountParams(extra: Record<string, unknown> = {}) {
+  return selectedAccount.value ? { account: selectedAccount.value, ...extra } : extra
+}
+
 async function load() {
 async function load() {
   loading.value = true
   loading.value = true
   try {
   try {
     const [sRes, pRes] = await Promise.all([
     const [sRes, pRes] = await Promise.all([
-      axios.get('/api/analytics/summary'),
-      axios.get('/api/analytics/posts', { params: { limit: 20 } }),
+      axios.get('/api/analytics/summary', { params: accountParams() }),
+      axios.get('/api/analytics/posts', { params: accountParams({ limit: 20 }) }),
     ])
     ])
     summary.value = sRes.data
     summary.value = sRes.data
     posts.value = pRes.data.posts
     posts.value = pRes.data.posts
@@ -491,7 +524,7 @@ async function load() {
 async function loadMorePosts() {
 async function loadMorePosts() {
   loadingMore.value = true
   loadingMore.value = true
   try {
   try {
-    const res = await axios.get('/api/analytics/posts', { params: { limit: 20, skip: posts.value.length } })
+    const res = await axios.get('/api/analytics/posts', { params: accountParams({ limit: 20, skip: posts.value.length }) })
     posts.value.push(...res.data.posts)
     posts.value.push(...res.data.posts)
   } finally {
   } finally {
     loadingMore.value = false
     loadingMore.value = false
@@ -501,7 +534,7 @@ async function loadMorePosts() {
 async function loadInsights() {
 async function loadInsights() {
   insightsLoading.value = true
   insightsLoading.value = true
   try {
   try {
-    const res = await axios.get('/api/analytics/insights')
+    const res = await axios.get('/api/analytics/insights', { params: accountParams() })
     insights.value = res.data
     insights.value = res.data
   } finally {
   } finally {
     insightsLoading.value = false
     insightsLoading.value = false
@@ -520,7 +553,41 @@ async function crawlMetrics() {
   }
   }
 }
 }
 
 
-onMounted(() => { load(); loadInsights() })
+onMounted(() => {
+  platformsStore.fetchMetaConnections()
+  load()
+  loadInsights()
+})
+
+// Re-fetch everything when the account filter changes
+watch(selectedAccount, () => { load(); loadInsights() })
+
+// ── Account filter ────────────────────────────────────────────────────────────
+
+interface FilterAccount { key: string; platform: string; label: string }
+
+const filterAccounts = computed<FilterAccount[]>(() => {
+  const list: FilterAccount[] = []
+
+  for (const page of platformsStore.connectedPages) {
+    list.push({ key: `facebook:${page.id}`, platform: 'facebook', label: page.name })
+  }
+  for (const acc of platformsStore.connectedIgAccounts) {
+    list.push({ key: `instagram:${acc.id}`, platform: 'instagram', label: `@${acc.username}` })
+  }
+
+  // Non-Meta platforms that appear in the summary data
+  if (summary.value?.byPlatform) {
+    const metaPlats = new Set(['facebook', 'instagram'])
+    for (const platform of Object.keys(summary.value.byPlatform)) {
+      if (!metaPlats.has(platform)) {
+        list.push({ key: platform, platform, label: platformLabel(platform) })
+      }
+    }
+  }
+
+  return list
+})
 
 
 // ── Chart helpers ─────────────────────────────────────────────────────────────
 // ── Chart helpers ─────────────────────────────────────────────────────────────