Skip to content

Commit aa4b043

Browse files
committed
refactor(imports): make imports a job
1 parent 6a942f5 commit aa4b043

File tree

27 files changed

+1592
-731
lines changed

27 files changed

+1592
-731
lines changed

frontend/src/lib/api/import.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import type { ImportResponse } from '$lib/types/settings';
1+
import type { ImportStartResponse, ImportStatusResponse } from '$lib/types/settings';
22
import type { Api } from './api';
33

4-
export async function importFromHackatime(api: Api, api_key: string) {
5-
return api.post<ImportResponse>('/data/import?api_key=' + encodeURIComponent(api_key), {});
4+
export async function startImport(api: Api, api_key: string): Promise<ImportStartResponse> {
5+
return api.post<ImportStartResponse>('/data/import?api_key=' + encodeURIComponent(api_key), {});
6+
}
7+
8+
export async function getImportStatus(api: Api): Promise<ImportStatusResponse> {
9+
return api.get<ImportStatusResponse>('/data/import/status');
610
}

frontend/src/lib/components/SideBar.svelte

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import LucideMenu from '~icons/lucide/menu';
1919
import LucideX from '~icons/lucide/x';
2020
import LucideTrophy from '~icons/lucide/trophy';
21+
import LucideImport from '~icons/lucide/import';
2122
import { onMount } from 'svelte';
2223
import UserTag from '$lib/components/ui/UserTag.svelte';
2324
import { impersonateUser } from '$lib/api/admin';
@@ -208,6 +209,19 @@
208209
>Admin</span
209210
>
210211
</a>
212+
<a
213+
href={resolve('/imports')}
214+
onclick={() => setTimeout(closeMobileSidebar, 100)}
215+
data-sveltekit-preload-data="hover"
216+
class="w-full text-left py-2 cursor-pointer rounded-md items-center outline-dashed bg-yellow/5 outline-1 outline-yellow inline-flex {page
217+
.url.pathname === '/imports'
218+
? 'bg-surface0/70 text-lavender'
219+
: 'hover:bg-surface1/50'} {collapsed ? 'justify-center' : 'px-3'}"
220+
>
221+
<LucideImport class="w-6 h-6 inline" /><span class={collapsed ? 'hidden' : 'ml-2'}
222+
>Imports</span
223+
>
224+
</a>
211225
{/if}
212226
{#if $auth.isAuthenticated && $auth.user}
213227
<a

frontend/src/lib/types/imports.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
export interface ImportJobWithUser {
2+
id: number;
3+
user_id: number;
4+
user_name: string | null;
5+
user_avatar_url: string | null;
6+
status: string;
7+
imported_count: number | null;
8+
processed_count: number | null;
9+
request_count: number | null;
10+
start_date: string | null;
11+
time_taken: number | null;
12+
error_message: string | null;
13+
created_at: string;
14+
updated_at: string;
15+
}
16+
17+
export interface AdminImportsResponse {
18+
imports: ImportJobWithUser[];
19+
total: number;
20+
limit: number;
21+
offset: number;
22+
}

frontend/src/lib/types/settings.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,25 @@ export interface SettingsResponse {
22
api_key?: string;
33
}
44

5+
export interface ImportStartResponse {
6+
job_id: number;
7+
status: string;
8+
message: string;
9+
}
10+
11+
export interface ImportStatusResponse {
12+
job_id: number;
13+
status: string;
14+
imported_count: number | null;
15+
processed_count: number | null;
16+
request_count: number | null;
17+
start_date: string | null;
18+
time_taken: number | null;
19+
error_message: string | null;
20+
created_at: string;
21+
updated_at: string;
22+
}
23+
524
export interface ImportResponse {
625
imported: number;
726
processed: number;

frontend/src/routes/admin/+page.svelte

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
import { auth } from '$lib/stores/auth';
1818
import { impersonateUser, changeAdminLevel } from '$lib/api/admin';
1919
import { createApi } from '$lib/api/api';
20-
2120
interface Props {
2221
data: PageData;
2322
}
@@ -182,7 +181,7 @@
182181
<table class="min-w-lg w-full">
183182
<thead class="border-b border-surface0 bg-surface0">
184183
<tr>
185-
<th class="px-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
184+
<th class="pl-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
186185
>Id</th
187186
>
188187
<th class="px-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
@@ -211,7 +210,7 @@
211210
return a.id - b.id;
212211
}) as user (user.id)}
213212
<tr class="border-b border-surface0 last:border-0 hover:bg-surface0/50">
214-
<td class="px-6 py-4 whitespace-nowrap text-sm text-ctp-subtext1">{user.id}</td>
213+
<td class="pl-6 py-4 whitespace-nowrap text-sm text-ctp-subtext1">{user.id}</td>
215214
<td class="px-6 py-4 whitespace-nowrap">
216215
<div class="flex items-center">
217216
{#if user.avatar_url}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<script lang="ts">
2+
import ErrorPage from '$lib/components/ErrorPage.svelte';
3+
import { page } from '$app/state';
4+
</script>
5+
6+
<ErrorPage status={page.status} error={page.error} />
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import type { PageServerLoad } from './$types';
2+
import type { AdminImportsResponse } from '$lib/types/imports';
3+
import { createApi, ApiError } from '$lib/api/api';
4+
import { redirect, error } from '@sveltejs/kit';
5+
6+
export const load: PageServerLoad = async ({ fetch, depends, request, url }) => {
7+
depends('app:admin-imports');
8+
9+
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
10+
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
11+
12+
try {
13+
const cookieHeader = request.headers.get('cookie') || undefined;
14+
const api = createApi(fetch, cookieHeader);
15+
return await api.get<AdminImportsResponse>(`/page/imports?limit=${limit}&offset=${offset}`);
16+
} catch (e) {
17+
console.error('Error loading admin imports page data:', e);
18+
const err = e as ApiError;
19+
if (err.status === 401 || err.status === 403) {
20+
throw redirect(302, '/?auth_error=unauthorized');
21+
}
22+
throw error(err.status || 500, err.message);
23+
}
24+
};
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
<script lang="ts">
2+
import { invalidate, goto } from '$app/navigation';
3+
import type { PageData } from './$types';
4+
import { Container, PageScaffold, SectionTitle, Button } from '$lib';
5+
import { setupVisibilityRefresh } from '$lib/utils/refresh';
6+
import { formatDuration } from '$lib/utils/time';
7+
import { auth } from '$lib/stores/auth';
8+
import LucideChevronLeft from '~icons/lucide/chevron-left';
9+
import LucideChevronRight from '~icons/lucide/chevron-right';
10+
import LucideLoader2 from '~icons/lucide/loader-2';
11+
import LucideCheck from '~icons/lucide/check';
12+
import LucideX from '~icons/lucide/x';
13+
14+
interface Props {
15+
data: PageData;
16+
}
17+
18+
let { data }: Props = $props();
19+
20+
let importsData = $derived(data);
21+
let lastUpdatedAt = $state(new Date());
22+
23+
const refreshImportsData = async () => {
24+
await invalidate('app:admin-imports');
25+
};
26+
27+
setupVisibilityRefresh({
28+
refresh: refreshImportsData,
29+
onError: (error) => {
30+
console.error('Failed to refresh imports data:', error);
31+
}
32+
});
33+
34+
$effect(() => {
35+
if (data) {
36+
lastUpdatedAt = new Date();
37+
}
38+
});
39+
40+
function formatDate(value: string) {
41+
const date = new Date(value);
42+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
43+
}
44+
45+
function formatStartDate(value: string | null) {
46+
if (!value) return 'N/A';
47+
const date = new Date(value);
48+
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
49+
}
50+
51+
function getStatusIcon(status: string) {
52+
switch (status) {
53+
case 'completed':
54+
return LucideCheck;
55+
case 'failed':
56+
return LucideX;
57+
default:
58+
return LucideLoader2;
59+
}
60+
}
61+
62+
function getStatusColor(status: string) {
63+
switch (status) {
64+
case 'completed':
65+
return 'text-green';
66+
case 'failed':
67+
return 'text-red';
68+
default:
69+
return 'text-yellow';
70+
}
71+
}
72+
73+
const currentOffset = $derived(importsData.offset);
74+
const limit = $derived(importsData.limit);
75+
const total = $derived(importsData.total);
76+
const hasPrevious = $derived(currentOffset > 0);
77+
const hasNext = $derived(currentOffset + limit < total);
78+
const currentPage = $derived(Math.floor(currentOffset / limit) + 1);
79+
const totalPages = $derived(Math.ceil(total / limit));
80+
81+
function goToPage(offset: number) {
82+
// eslint-disable-next-line svelte/no-navigation-without-resolve
83+
goto(`/admin/imports?offset=${offset}&limit=${limit}`);
84+
}
85+
86+
function previousPage() {
87+
goToPage(Math.max(0, currentOffset - limit));
88+
}
89+
90+
function nextPage() {
91+
goToPage(currentOffset + limit);
92+
}
93+
</script>
94+
95+
<svelte:head>
96+
<title>Imports - Admin - rustytime</title>
97+
</svelte:head>
98+
99+
{#if importsData}
100+
<PageScaffold title="Import Jobs" {lastUpdatedAt}>
101+
<Container>
102+
<div class="flex items-center justify-between mb-4">
103+
<SectionTitle>All Import Jobs ({total})</SectionTitle>
104+
</div>
105+
106+
{#if importsData.imports.length > 0}
107+
<div class="rounded-lg border border-surface0 bg-mantle">
108+
<div class="overflow-x-auto">
109+
<table class="min-w-lg w-full">
110+
<thead class="border-b border-surface0 bg-surface0">
111+
<tr>
112+
<th class="pl-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
113+
>ID</th
114+
>
115+
<th class="px-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
116+
>User</th
117+
>
118+
<th class="px-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
119+
>Status</th
120+
>
121+
<th class="px-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
122+
>Imported</th
123+
>
124+
<th class="px-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
125+
>Processed</th
126+
>
127+
<th class="px-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
128+
>Requests</th
129+
>
130+
<th class="px-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
131+
>Duration</th
132+
>
133+
<th class="px-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
134+
>Start (UTC)</th
135+
>
136+
<th class="px-6 py-3 text-left text-xs font-medium text-ctp-subtext0 uppercase"
137+
>Created (UTC)</th
138+
>
139+
</tr>
140+
</thead>
141+
<tbody>
142+
{#each importsData.imports as job (job.id)}
143+
{@const StatusIcon = getStatusIcon(job.status)}
144+
<tr class="border-b border-surface0 last:border-0 hover:bg-surface0/50">
145+
<td class="pl-6 py-4 whitespace-nowrap text-sm text-ctp-subtext1">{job.id}</td>
146+
<td class="px-6 py-4 whitespace-nowrap">
147+
<div class="flex items-center">
148+
{#if job.user_avatar_url}
149+
<img
150+
src={job.user_avatar_url}
151+
alt="Avatar"
152+
class="h-8 w-8 rounded-full mr-3"
153+
/>
154+
{/if}
155+
<a
156+
class="text-sm font-medium {job.user_id === $auth.user?.id
157+
? 'text-blue'
158+
: 'text-text'}"
159+
href={job.user_name ? `https://github.com/${job.user_name}` : undefined}
160+
target="_blank"
161+
data-umami-event="github-profile-link"
162+
data-umami-event-name={job.user_name}
163+
rel="noopener noreferrer external">{job.user_name || 'Unknown'}</a
164+
>
165+
</div>
166+
</td>
167+
<td class="px-6 py-4 whitespace-nowrap">
168+
<div class="flex items-center gap-2">
169+
<StatusIcon
170+
class={`w-4 h-4 ${getStatusColor(job.status)} ${job.status === 'running' ? 'animate-spin' : ''}`}
171+
/>
172+
<span class={`text-sm font-medium capitalize ${getStatusColor(job.status)}`}
173+
>{job.status}</span
174+
>
175+
</div>
176+
</td>
177+
<td class="px-6 py-4 whitespace-nowrap text-sm text-ctp-subtext1">
178+
{job.imported_count?.toLocaleString() ?? '-'}
179+
</td>
180+
<td class="px-6 py-4 whitespace-nowrap text-sm text-ctp-subtext1">
181+
{job.processed_count?.toLocaleString() ?? '-'}
182+
</td>
183+
<td class="px-6 py-4 whitespace-nowrap text-sm text-ctp-subtext1">
184+
{job.request_count?.toLocaleString() ?? '-'}
185+
</td>
186+
<td class="px-6 py-4 whitespace-nowrap text-sm text-ctp-subtext1">
187+
{job.time_taken ? formatDuration(job.time_taken) : '-'}
188+
</td>
189+
<td class="px-6 py-4 whitespace-nowrap text-sm text-ctp-subtext1">
190+
{formatStartDate(job.start_date)}
191+
</td>
192+
<td class="px-6 py-4 whitespace-nowrap text-sm text-ctp-subtext1"
193+
>{formatDate(job.created_at)}</td
194+
>
195+
</tr>
196+
{#if job.error_message}
197+
<tr class="bg-red/5">
198+
<td colspan="9" class="px-6 py-2">
199+
<div class="text-sm text-red">
200+
<span class="font-medium">Error:</span>
201+
{job.error_message}
202+
</div>
203+
</td>
204+
</tr>
205+
{/if}
206+
{/each}
207+
</tbody>
208+
</table>
209+
</div>
210+
</div>
211+
212+
<!-- Pagination -->
213+
{#if totalPages > 1}
214+
<div class="flex items-center justify-between mt-4">
215+
<p class="text-sm text-subtext0">
216+
Showing {currentOffset + 1} - {Math.min(currentOffset + limit, total)} of {total} imports
217+
</p>
218+
<div class="flex items-center gap-2">
219+
<Button onClick={previousPage} disabled={!hasPrevious} className="p-2">
220+
<LucideChevronLeft class="w-4 h-4" />
221+
</Button>
222+
<span class="text-sm text-subtext0">
223+
Page {currentPage} of {totalPages}
224+
</span>
225+
<Button onClick={nextPage} disabled={!hasNext} className="p-2">
226+
<LucideChevronRight class="w-4 h-4" />
227+
</Button>
228+
</div>
229+
</div>
230+
{/if}
231+
{:else}
232+
<div
233+
class="flex flex-col items-center gap-4 border border-dashed border-ctp-surface0/80 py-12 text-center"
234+
>
235+
<p class="text-lg font-semibold text-ctp-text">No import jobs found</p>
236+
<p class="text-ctp-subtext0">No users have started any import jobs yet.</p>
237+
</div>
238+
{/if}
239+
</Container>
240+
</PageScaffold>
241+
{/if}

0 commit comments

Comments
 (0)