Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,25 @@ extend type Query {
courseRepoOverview(course: String!): [AssignmentRepos!]!
}

"""
One streamed item of a course repo overview: a completed assignment as it is
checked, then a final event with `done: true`. `error` is set (with done) when the
overview cannot start — e.g. no stored GitLab token.
"""
type RepoOverviewEvent {
"The assignment just checked (null on the final done event)."
assignment: AssignmentRepos
"Total number of assignments to check (for a progress count)."
total: Int!
done: Boolean!
error: String
}

extend type Subscription {
"Stream the repo overview of one of the caller's courses assignment by assignment, so the GUI shows progress. Needs a stored GitLab token."
courseRepoOverviewProgress(course: String!): RepoOverviewEvent!
}

# ----- scheduling.graphqls -----
"The lifecycle state of a scheduled job. It starts PENDING, is claimed to RUNNING, and ends in exactly one terminal state."
enum JobStatus {
Expand Down
12 changes: 6 additions & 6 deletions src/lib/gql/gql.ts

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions src/lib/gql/graphql.ts

Large diffs are not rendered by default.

50 changes: 50 additions & 0 deletions src/lib/reposSubscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { graphql } from '$lib/gql';
import { subscribe } from '$lib/wsClient';
import type { CourseRepoOverviewProgressSubscription } from '$lib/gql/graphql';

// One progress event of the repo-overview stream (the subscription payload).
export type RepoOverviewEvent =
CourseRepoOverviewProgressSubscription['courseRepoOverviewProgress'];

const DOC = graphql(`
subscription CourseRepoOverviewProgress($course: String!) {
courseRepoOverviewProgress(course: $course) {
total
done
error
assignment {
name
per
targets
existing
note
repos {
for
url
exists
}
}
}
}
`);

/**
* Subscribe to the live repo-overview stream. Calls `next` for every event
* (one finished assignment, then a final done) and `error` on a transport failure.
* Returns a cleanup function.
*/
export function subscribeRepoOverview(
course: string,
handlers: { next: (e: RepoOverviewEvent) => void; error: (message: string) => void }
): () => void {
return subscribe<CourseRepoOverviewProgressSubscription>(
DOC,
{ course },
{
next: (d) => {
if (d.courseRepoOverviewProgress) handlers.next(d.courseRepoOverviewProgress);
},
error: handlers.error
}
);
}
36 changes: 4 additions & 32 deletions src/routes/courses/[name]/repos/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,10 @@
import { graphql } from '$lib/gql';
import { backendRequest } from '$lib/server/backend';
import { gqlErrorMessage } from '$lib/gqlError';
import type { PageServerLoad } from './$types';

/**
* Übersicht der tatsächlich generierten Repos pro Assignment. Fragt GitLab
* serverseitig ab (ein Gruppen-Listing pro Assignment) — kann ein paar Sekunden
* dauern und braucht einen hinterlegten GitLab-Token; ein Fehler (z. B. kein Token)
* wird als `error` durchgereicht statt die Seite scheitern zu lassen.
* Die Repo-Übersicht wird live im Browser über eine WebSocket-Subscription geladen
* (assignmentweise, mit Fortschritt), nicht im SSR — so blockiert die Seite nicht,
* während GitLab abgefragt wird. Der Load reicht nur die Route-Parameter durch.
*/
export const load: PageServerLoad = async ({ params }) => {
const course = params.name;
try {
const d = await backendRequest(
graphql(`
query CourseRepoOverview($course: String!) {
courseRepoOverview(course: $course) {
name
per
targets
existing
note
repos {
for
url
exists
}
}
}
`),
{ course }
);
return { course, overview: d?.courseRepoOverview ?? [], error: null as string | null };
} catch (e) {
return { course, overview: null, error: gqlErrorMessage(e) };
}
return { course: params.name };
};
68 changes: 59 additions & 9 deletions src/routes/courses/[name]/repos/+page.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,50 @@
<script lang="ts">
import { subscribeRepoOverview, type RepoOverviewEvent } from '$lib/reposSubscription';
import type { PageData } from './$types';

let { data }: { data: PageData } = $props();

let overview = $derived(data.overview ?? []);
let error = $derived(data.error);
type Assignment = NonNullable<RepoOverviewEvent['assignment']>;

let assignments = $state<Assignment[]>([]);
let total = $state(0);
let checked = $state(0);
let done = $state(false);
let error = $state<string | null>(null);

let needsToken = $derived(!!error && /token/i.test(error));
let sorted = $derived([...assignments].sort((a, b) => a.name.localeCompare(b.name)));

$effect(() => {
const course = data.course;
// Reset for this course, then stream assignment by assignment.
assignments = [];
total = 0;
checked = 0;
done = false;
error = null;
return subscribeRepoOverview(course, {
next: (e) => {
if (e.total) total = e.total;
if (e.error) {
error = e.error;
done = true;
return;
}
if (e.assignment) {
assignments = [...assignments, e.assignment];
checked += 1;
}
if (e.done) done = true;
},
error: (msg) => {
error = msg;
done = true;
}
});
});

function missing(a: { repos: { for: string; url: string; exists: boolean }[] }) {
function missing(a: Assignment) {
return a.repos.filter((r) => !r.exists);
}
</script>
Expand All @@ -25,20 +62,33 @@
(GitLab-Token nötig).
</p>

<div class="mt-4 flex items-center gap-3 text-sm">
{#if !done}
<span class="loading loading-spinner loading-sm"></span>
<span>prüfe GitLab … {checked}{total ? `/${total}` : ''} Assignments</span>
{:else if !error}
<span class="text-success">✓ fertig ({checked} Assignments)</span>
{/if}
</div>

{#if total > 0 && !done}
<progress class="progress progress-primary mt-2 w-full" value={checked} max={total}></progress>
{/if}

{#if error}
<div class="mt-6 alert alert-error">
<div class="mt-4 alert alert-error">
<span class="font-mono text-sm break-words whitespace-pre-wrap">{error}</span>
</div>
{#if needsToken}
<p class="mt-2 text-sm">
<a class="link link-primary" href="/token">GitLab-Token hinterlegen →</a>
</p>
{/if}
{:else if overview.length === 0}
<p class="mt-6 text-sm text-base-content/50">Keine Assignments in diesem Kurs.</p>
{:else}
{/if}

{#if sorted.length > 0}
<div class="mt-6 flex flex-col gap-2">
{#each overview as a (a.name)}
{#each sorted as a (a.name)}
{@const miss = missing(a)}
<div class="rounded-2xl border border-base-200 p-4">
<div class="flex flex-wrap items-center gap-2">
Expand All @@ -49,7 +99,7 @@
{a.name}
</a>
{#if a.note}
<span class="badge badge-ghost badge-sm" title={a.note}>—</span>
<span class="badge badge-ghost badge-sm">—</span>
<span class="text-xs text-base-content/50">{a.note}</span>
{:else}
<span
Expand Down