Skip to content

Commit c40acd8

Browse files
committed
Add downloadLogs Page and optimize downloading process
1 parent 05624e0 commit c40acd8

File tree

10 files changed

+413
-143
lines changed

10 files changed

+413
-143
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dhis2-downloader",
3-
"version": "0.5.4",
3+
"version": "0.6.0",
44
"description": "DHIS2 Downloader",
55
"main": "./out/main/index.js",
66
"author": "globalfinancingfacility.org",

src/renderer/src/App.jsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import Map from './pages/Facility/Map'
1414
import FacilityTable from './pages/Facility/Table'
1515
import ModalManager from './pages/Modals/ModalManager'
1616
import PrivacyPolicy from './pages/Privacy'
17+
import DownloadResultsTable from './pages/DownloadResultsTable'
1718
import { servicesDb, dictionaryDb, queryDb } from './service/db'
1819
import { openModal } from './reducers/modalReducer'
1920

@@ -65,6 +66,14 @@ const App = () => {
6566
</PrivateRoute>
6667
}
6768
/>
69+
<Route
70+
path="/download-results"
71+
element={
72+
<PrivateRoute>
73+
<DownloadResultsTable />
74+
</PrivateRoute>
75+
}
76+
/>
6877
<Route
6978
path="/map"
7079
element={

src/renderer/src/i18n/en/history.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,9 @@
1111
"paramsPassed": "Parameters passed for row {{id}}.",
1212
"finishedDownloading": "Finished downloading from {{url}}",
1313
"noSelectedRedownload": "No worker or no URLs selected for redownload.",
14-
"errorProcessingDownloads": "Error processing downloads:"
14+
"errorProcessingDownloads": "Error processing downloads:",
15+
"ok": "OK",
16+
"failed": "Failed",
17+
"exportCsv": "Export CSV",
18+
"resultsTitle": "Download Results"
1519
}

src/renderer/src/i18n/en/navbar.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
"about": "About",
1111
"signOut": "Sign out",
1212
"terms": "Terms and Conditions",
13-
"privacy": "Privacy Policy"
13+
"privacy": "Privacy Policy",
14+
"downloadResults": "Download Logs"
1415
}

src/renderer/src/i18n/fr/history.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,9 @@
1111
"paramsPassed": "Paramètres transférés pour la ligne {{id}}.",
1212
"finishedDownloading": "Téléchargement terminé depuis {{url}}",
1313
"noSelectedRedownload": "Aucun worker ou URL sélectionné pour le re-téléchargement.",
14-
"errorProcessingDownloads": "Erreur lors du traitement des téléchargements :"
14+
"errorProcessingDownloads": "Erreur lors du traitement des téléchargements :",
15+
"ok": "OK",
16+
"failed": "Échec",
17+
"exportCsv": "Exporter CSV",
18+
"resultsTitle": "Résultats du téléchargement"
1519
}

src/renderer/src/i18n/fr/navbar.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,6 @@
1010
"about": "À propos",
1111
"signOut": "Se déconnecter",
1212
"terms": "Conditions générales",
13-
"privacy": "Politique de confidentialité"
13+
"privacy": "Politique de confidentialité",
14+
"downloadResults": "Journaux de téléchargement"
1415
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
// DownloadResultsTable.jsx
2+
import React, { useEffect, useMemo, useState } from 'react'
3+
import { useLiveQuery } from 'dexie-react-hooks'
4+
import { queryDb } from '../service/db'
5+
import { useTranslation } from 'react-i18next'
6+
import { MicroArrowTopRight } from '../components/Icons'
7+
8+
const headers = [
9+
'runId',
10+
'queryId',
11+
'ok',
12+
'chunkIndex',
13+
'ou',
14+
'startPeriod',
15+
'endPeriod',
16+
'dxCount',
17+
'errorMessage',
18+
'createdAt'
19+
]
20+
21+
const esc = (v) => (v == null ? '' : /[,"\n]/.test(v) ? `"${String(v).replace(/"/g, '""')}"` : v)
22+
23+
const toCsv = (rows) =>
24+
[headers.join(','), ...rows.map((r) => headers.map((h) => esc(r[h])).join(','))].join('\n')
25+
26+
const DownloadResultsTable = () => {
27+
const { t } = useTranslation()
28+
const [activeRunId, setActiveRunId] = useState(null)
29+
30+
// Fetch all runs
31+
const runs = useLiveQuery(async () => {
32+
return await queryDb.runs.orderBy('id').reverse().toArray()
33+
}, [])
34+
35+
// Set default active run (latest)
36+
useEffect(() => {
37+
if (runs?.length && !activeRunId) setActiveRunId(runs[0].id)
38+
}, [runs, activeRunId])
39+
40+
// Load results for current run
41+
const results = useLiveQuery(
42+
async () => {
43+
if (!activeRunId) return []
44+
const rows = await queryDb.results.where('runId').equals(activeRunId).toArray()
45+
const queryIds = [...new Set(rows.map((r) => r.queryId).filter(Boolean))]
46+
const qs = queryIds.length ? await queryDb.query.bulkGet(queryIds) : []
47+
const qMap = new Map(qs.filter(Boolean).map((q) => [q.id, q]))
48+
return rows.map((r) => ({
49+
...r,
50+
_dimensionName: qMap.get(r.queryId)?.dimensionName
51+
}))
52+
},
53+
[activeRunId],
54+
[]
55+
)
56+
57+
const summary = useMemo(() => {
58+
let success = 0,
59+
fail = 0
60+
results.forEach((r) => (r.ok ? success++ : fail++))
61+
return { success, fail }
62+
}, [results])
63+
64+
const handleExport = async () => {
65+
if (!results.length) return
66+
const csv = toCsv(results)
67+
const path = await window.electronAPI.selectSaveLocation?.()
68+
if (!path) return
69+
const stream = window.fileSystem.createWriteStream(path, { flags: 'w', encoding: 'utf8' })
70+
stream.write('\uFEFF')
71+
stream.write(csv)
72+
stream.end()
73+
}
74+
75+
const formatDate = (dateStr) => {
76+
const date = new Date(dateStr)
77+
return date.toLocaleString(undefined, {
78+
year: 'numeric',
79+
month: '2-digit',
80+
day: '2-digit',
81+
hour: '2-digit',
82+
minute: '2-digit'
83+
})
84+
}
85+
86+
if (!runs || runs.length === 0) {
87+
return (
88+
<p className="text-center text-gray-500">{t('history.resultsTitle', 'No runs available')}</p>
89+
)
90+
}
91+
92+
return (
93+
<div className="mb-8 w-full flex flex-col space-y-4">
94+
{/* Toolbar Component */}
95+
<div className="flex justify-between items-center px-4 py-2 bg-gray-100 rounded-t-lg">
96+
<div className="flex items-center space-x-4">
97+
<label className="text-sm font-semibold text-gray-700">{t('history.resultsTitle')}</label>
98+
<div className="flex items-center space-x-2">
99+
<label className="text-sm text-gray-600">Run:</label>
100+
<select
101+
className="text-sm border border-gray-300 rounded px-2 py-1 bg-white"
102+
value={activeRunId || ''}
103+
onChange={(e) => setActiveRunId(Number(e.target.value))}
104+
>
105+
{(runs || []).map((r) => (
106+
<option key={r.id} value={r.id}>
107+
#{r.id}{r.ok ? t('history.ok') : '✗ ' + t('history.failed')}
108+
</option>
109+
))}
110+
</select>
111+
</div>
112+
<div className="text-sm text-gray-600">
113+
<span className="text-green-600 font-medium">{summary.success}</span>
114+
{' · '}
115+
<span className="text-red-600 font-medium">{summary.fail}</span>
116+
</div>
117+
</div>
118+
<div className="flex space-x-2">
119+
<button
120+
onClick={handleExport}
121+
disabled={!results.length}
122+
className="text-blue-600 hover:text-blue-800 font-semibold px-4 text-sm transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed"
123+
>
124+
<MicroArrowTopRight /> {t('history.exportCsv')}
125+
</button>
126+
</div>
127+
</div>
128+
129+
{/* Table Component */}
130+
<div className="overflow-x-auto px-4">
131+
<div className="w-full bg-white rounded-lg shadow-lg">
132+
<table className="w-full table-auto">
133+
<thead>
134+
<tr className="bg-gray-100 text-gray-800 uppercase text-sm leading-normal">
135+
<th className="py-2 px-2 whitespace-nowrap">Run</th>
136+
<th className="py-2 px-2 whitespace-nowrap">Query</th>
137+
<th className="py-2 px-2 whitespace-nowrap">Status</th>
138+
<th className="py-2 px-2 whitespace-nowrap">Idx</th>
139+
<th className="py-2 px-2 whitespace-nowrap">OU</th>
140+
<th className="py-2 px-2 whitespace-nowrap">Start</th>
141+
<th className="py-2 px-2 whitespace-nowrap">End</th>
142+
<th className="py-2 px-2 whitespace-nowrap">DX</th>
143+
<th className="py-2 px-2 whitespace-nowrap">Query Name</th>
144+
<th className="py-2 px-2 whitespace-nowrap">Timestamp</th>
145+
<th className="py-2 px-2 whitespace-nowrap">Error</th>
146+
</tr>
147+
</thead>
148+
<tbody className="text-gray-800 text-xs font-light">
149+
{results.map((r) => (
150+
<tr key={r.id} className="hover:bg-gray-50">
151+
<td className="py-2 px-2 [word-break:break-word]">{r.runId}</td>
152+
<td className="py-2 px-2 [word-break:break-word]">{r.queryId}</td>
153+
<td
154+
className={`py-2 px-2 font-medium ${r.ok ? 'text-green-600' : 'text-red-600'}`}
155+
>
156+
{r.ok ? '✓ OK' : '✗ Failed'}
157+
</td>
158+
<td className="py-2 px-2 [word-break:break-word]">{r.chunkIndex ?? '-'}</td>
159+
<td className="py-2 px-2 [word-break:break-word] max-w-[280px]" title={r.ou}>
160+
{r.ou || '-'}
161+
</td>
162+
<td className="py-2 px-2 [word-break:break-word]">{r.startPeriod || '-'}</td>
163+
<td className="py-2 px-2 [word-break:break-word]">{r.endPeriod || '-'}</td>
164+
<td className="py-2 px-2 [word-break:break-word]">{r.dxCount ?? '-'}</td>
165+
<td
166+
className="py-2 px-2 [word-break:break-word] max-w-[260px]"
167+
title={r._dimensionName || ''}
168+
>
169+
{r._dimensionName || '-'}
170+
</td>
171+
<td className="py-2 px-2 [word-break:break-word]">{formatDate(r.createdAt)}</td>
172+
<td
173+
className="py-2 px-2 [word-break:break-word] text-red-600 max-w-[280px]"
174+
title={r.errorMessage || ''}
175+
>
176+
{r.errorMessage || '-'}
177+
</td>
178+
</tr>
179+
))}
180+
{!results.length && (
181+
<tr>
182+
<td colSpan={11} className="text-center text-gray-500 py-4">
183+
{t('history.noResults', 'No results available')}
184+
</td>
185+
</tr>
186+
)}
187+
</tbody>
188+
</table>
189+
</div>
190+
</div>
191+
</div>
192+
)
193+
}
194+
195+
export default DownloadResultsTable

0 commit comments

Comments
 (0)