DEV Community

Cover image for Authentication system using Golang and Sveltekit - Updating the frontend
John Owolabi Idogun
John Owolabi Idogun

Posted on • Updated on

Authentication system using Golang and Sveltekit - Updating the frontend

Introduction

We have made a lot of changes in the backend system without a corresponding change in the front end. We'll do that in this article.

Source code

The source code for this series is hosted on GitHub via:

GitHub logo Sirneij / go-auth

A fullstack session-based authentication system using golang and sveltekit

go-auth

This repository accompanies a series of tutorials on session-based authentication using Go at the backend and JavaScript (SvelteKit) on the front-end.

It is currently live here (the backend may be brought down soon).

To run locally, kindly follow the instructions in each subdirectory.




Implementation

Step 1: Regenerate the token page

To regenerate tokens, users need to submit their unverified email addresses. Let's create the route:

<!-- frontend/src/routes/auth/regenerate-token/+page.svelte -->
<script>
    import { applyAction, enhance } from '$app/forms';
    import { receive, send } from '$lib/utils/helpers';
    import { scale } from 'svelte/transition';

    /** @type {import('./$types').ActionData} */
    export let form;

    /** @type {import('./$types').SubmitFunction} */
    const handleGenerate = async () => {
        return async ({ result }) => {
            await applyAction(result);
        };
    };
</script>

<div class="container">
    <form class="content" method="POST" use:enhance={handleGenerate}>
        <h1 class="step-title">Regenerate token</h1>
        {#if form?.errors}
            {#each form?.errors as error (error.id)}
                <h4
                    class="step-subtitle warning"
                    in:receive={{ key: error.id }}
                    out:send={{ key: error.id }}
                >
                    {error.error}
                </h4>
            {/each}
        {/if}

        <div class="input-box">
            <span class="label">Email:</span>
            <input
                class="input"
                type="email"
                name="email"
                id="email"
                placeholder="Registered e-mail address"
                required
            />
        </div>
        {#if form?.fieldsError && form?.fieldsError.email}
            <p class="warning" transition:scale|local={{ start: 0.7 }}>
                {form?.fieldsError.email}
            </p>
        {/if}

        <button class="button-dark">Regenerate</button>
    </form>
</div>
Enter fullscreen mode Exit fullscreen mode

The route will be /auth/regenerate-token. It only has one input and the page looks like this:

Application's regenerate token page

Its corresponding +page.server.js is:

// frontend/src/routes/auth/regenerate-token/+page.server.js
import { BASE_API_URI } from '$lib/utils/constants';
import { formatError, isEmpty, isValidEmail } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';

/** @type {import('./$types').Actions} */
export const actions = {
    default: async ({ fetch, request }) => {
        const formData = await request.formData();
        const email = String(formData.get('email'));

        // Some validations
        /** @type {Record<string, string>} */
        const fieldsError = {};
        if (!isValidEmail(email)) {
            fieldsError.email = 'That email address is invalid.';
        }

        if (!isEmpty(fieldsError)) {
            return fail(400, { fieldsError: fieldsError });
        }

        /** @type {RequestInit} */
        const requestInitOptions = {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify({ email: email })
        };

        const res = await fetch(`${BASE_API_URI}/users/regenerate-token/`, requestInitOptions);

        if (!res.ok) {
            const response = await res.json();
            const errors = formatError(response.error);
            return fail(400, { errors: errors });
        }

        const response = await res.json();

        // redirect the user
        throw redirect(302, `/auth/confirming?message=${response.message}`);
    }
};
Enter fullscreen mode Exit fullscreen mode

Here, we are using the default form action hence the reason we omitted the action attribute on the form tag.

Password reset request is almost exactly like this route. Same with the password change route. As a result, I won't discuss them in this article to avoid repetition. However, the pages' images are shown below:

Application's password reset request page

Application's change password page

Their source codes are in this folder in the repository.

Step 2: Profile Update, Image upload and deletion

Now to the user profile update. The route is in frontend/src/routes/auth/about/[id]/+page.svelte whose content looks like this:

<!-- frontend/src/routes/auth/about/[id]/+page.svelte -->
<script>
    import { applyAction, enhance } from '$app/forms';
    import { page } from '$app/stores';
    import ImageInput from '$lib/components/ImageInput.svelte';
    import Modal from '$lib/components/Modal.svelte';
    import SmallLoader from '$lib/components/SmallLoader.svelte';
    import Avatar from '$lib/img/teamavatar.png';
    import { receive, send } from '$lib/utils/helpers';

    $: ({ user } = $page.data);

    let showModal = false,
        isUploading = false,
        isUpdating = false;
    const open = () => (showModal = true);
    const close = () => (showModal = false);

    /** @type {import('./$types').ActionData} */
    export let form;

    /** @type {import('./$types').SubmitFunction} */
    const handleUpdate = async () => {
        isUpdating = true;
        return async ({ result }) => {
            isUpdating = false;
            if (result.type === 'success' || result.type === 'redirect') {
                close();
            }
            await applyAction(result);
        };
    };

    /** @type {import('./$types').SubmitFunction} */
    const handleUpload = async () => {
        isUploading = true;
        return async ({ result }) => {
            isUploading = false;
            /** @type {any} */
            const res = result;
            if (result.type === 'success' || result.type === 'redirect') {
                user.thumbnail = res.data.thumbnail;
            }
            await applyAction(result);
        };
    };
</script>

<div class="hero-container">
    <div class="hero-logo">
        <img
            src={user.thumbnail ? user.thumbnail : Avatar}
            alt={`${user.first_name} ${user.last_name}`}
        />
    </div>
    <h3 class="hero-subtitle subtitle">
        Name (First and Last): {`${user.first_name} ${user.last_name}`}
    </h3>
    {#if user.profile.phone_number}
        <h3 class="hero-subtitle">
            Phone: {user.profile.phone_number}
        </h3>
    {/if}

    {#if user.profile.github_link}
        <h3 class="hero-subtitle">
            GitHub: {user.profile.github_link}
        </h3>
    {/if}

    {#if user.profile.birth_date}
        <h3 class="hero-subtitle">
            Date of birth: {user.profile.birth_date}
        </h3>
    {/if}
    <div class="hero-buttons-container">
        <button class="button-dark" on:click={open}>Edit profile</button>
    </div>
</div>

{#if showModal}
    <Modal on:close={close}>
        <form
            class="content image"
            action="?/uploadImage"
            method="post"
            enctype="multipart/form-data"
            use:enhance={handleUpload}
        >
            <ImageInput avatar={user.thumbnail} fieldName="thumbnail" title="Select user image" />

            {#if !user.thumbnail}
                <div class="btn-wrapper">
                    {#if isUploading}
                        <SmallLoader width={30} message={'Uploading...'} />
                    {:else}
                        <button class="button-dark" type="submit">Upload image</button>
                    {/if}
                </div>
            {:else}
                <input type="hidden" hidden name="thumbnail_url" value={user.thumbnail} required />
                <div class="btn-wrapper">
                    {#if isUploading}
                        <SmallLoader width={30} message={'Removing...'} />
                    {:else}
                        <button class="button-dark" formaction="?/deleteImage" type="submit">
                            Remove image
                        </button>
                    {/if}
                </div>
            {/if}
        </form>
        <form class="content" action="?/updateUser" method="POST" use:enhance={handleUpdate}>
            <h1 class="step-title" style="text-align: center;">Update User</h1>
            {#if form?.success}
                <h4
                    class="step-subtitle warning"
                    in:receive={{ key: Math.floor(Math.random() * 100) }}
                    out:send={{ key: Math.floor(Math.random() * 100) }}
                >
                    To avoid corrupt data and inconsistencies in your thumbnail, ensure you click on the
                    "Update" button below.
                </h4>
            {/if}
            {#if form?.errors}
                {#each form?.errors as error (error.id)}
                    <h4
                        class="step-subtitle warning"
                        in:receive={{ key: error.id }}
                        out:send={{ key: error.id }}
                    >
                        {error.error}
                    </h4>
                {/each}
            {/if}

            <input type="hidden" hidden name="thumbnail" value={user.thumbnail} />

            <div class="input-box">
                <span class="label">First name:</span>
                <input
                    class="input"
                    type="text"
                    name="first_name"
                    value={user.first_name}
                    placeholder="Your first name..."
                />
            </div>
            <div class="input-box">
                <span class="label">Last name:</span>
                <input
                    class="input"
                    type="text"
                    name="last_name"
                    value={user.last_name}
                    placeholder="Your last name..."
                />
            </div>
            <div class="input-box">
                <span class="label">Phone number:</span>
                <input
                    class="input"
                    type="tel"
                    name="phone_number"
                    value={user.profile.phone_number ? user.profile.phone_number : ''}
                    placeholder="Your phone number e.g +2348135703593..."
                />
            </div>
            <div class="input-box">
                <span class="label">Birth date:</span>
                <input
                    class="input"
                    type="date"
                    name="birth_date"
                    value={user.profile.birth_date ? user.profile.birth_date : ''}
                    placeholder="Your date of birth..."
                />
            </div>
            <div class="input-box">
                <span class="label">GitHub Link:</span>
                <input
                    class="input"
                    type="url"
                    name="github_link"
                    value={user.profile.github_link ? user.profile.github_link : ''}
                    placeholder="Your github link e.g https://github.com/Sirneij/..."
                />
            </div>
            {#if isUpdating}
                <SmallLoader width={30} message={'Updating...'} />
            {:else}
                <button type="submit" class="button-dark">Update</button>
            {/if}
        </form>
    </Modal>
{/if}

<style>
    .hero-container .hero-subtitle:not(:last-of-type) {
        margin: 0 0 0 0;
    }

    .content.image {
        display: flex;
        align-items: center;
        justify-content: center;
    }
    @media (max-width: 680px) {
        .content.image {
            margin: 0 0 0;
        }
    }
    .content.image .btn-wrapper {
        margin-top: 2.5rem;
        margin-left: 1rem;
    }
    .content.image .btn-wrapper button {
        padding: 15px 18px;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

The page ordinarily displays the user's data based on the fields filled. It looks like this:

Application's user's profile page

Since the user in the screenshot is brand new, only the user's first and last names appeared. A default profile picture was also supplied. These data will change depending on the fields you have updated.

On this same page, a modal transitions in as soon as you click the EDIT PROFILE button. The modal is a different component:

<!-- frontend/src/lib/components/Modal.svelte -->
<script>
    import { quintOut } from 'svelte/easing';

    import { createEventDispatcher } from 'svelte';

    const modal = (/** @type {Element} */ node, { duration = 300 } = {}) => {
        const transform = getComputedStyle(node).transform;

        return {
            duration,
            easing: quintOut,
            css: (/** @type {any} */ t, /** @type {number} */ u) => {
                return `transform:
            ${transform}
            scale(${t})
            translateY(${u * -100}%)
            `;
            }
        };
    };

    const dispatch = createEventDispatcher();
    function closeModal() {
        dispatch('close', {});
    }
</script>

<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-background">
    <div transition:modal={{ duration: 1000 }} class="modal" role="dialog" aria-modal="true">
        <!-- svelte-ignore a11y-missing-attribute -->
        <a title="Close" class="modal-close" on:click={closeModal}>
            <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 384 512">
                <path
                    d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"
                />
            </svg>
        </a>
        <div class="container">
            <slot />
        </div>
    </div>
</div>

<style>
    .modal-background {
        width: 100%;
        height: 100%;
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background: rgba(0, 0, 0, 0.9);
        z-index: 9999;
    }

    .modal {
        position: absolute;
        left: 50%;
        top: 50%;
        width: 70%;
        box-shadow: 0 0 10px hsl(0 0% 0% / 10%);
        transform: translate(-50%, -50%);
    }
    @media (max-width: 990px) {
        .modal {
            width: 90%;
        }
    }
    .modal-close {
        border: none;
    }

    .modal-close svg {
        display: block;
        margin-left: auto;
        margin-right: auto;
        fill: rgb(14 165 233 /1);
        transition: all 0.5s;
    }
    .modal-close:hover svg {
        fill: rgb(225 29 72);
        transform: scale(1.5);
    }
    .modal .container {
        max-height: 90vh;
        overflow-y: auto;
    }
    @media (min-width: 680px) {
        .modal .container {
            flex-direction: column;
            left: 0;
            width: 100%;
        }
    }
</style>
Enter fullscreen mode Exit fullscreen mode

On the user profile page, clicking the EDIT PROFILE button shows something like the image below (the screenshot isn't exact):

Application's user's profile modal page

The modal has two forms in it: Image upload and User data update. The image upload form can also be used to delete an image. If a user already has an image, the "UPLOAD IMAGE" button will turn to the "REMOVE IMAGE" button and there will be an image instead of the "Select user image" input. The custom input for user image upload is a component on its own as well:

<!-- frontend/src/lib/components/ImageInput.svelte -->
<script>
    // @ts-nocheck
    export let avatar;

    export let fieldName;
    export let title;

    let newAvatar;
    const onFileSelected = (e) => {
        const target = e.target;
        if (target && target.files) {
            let reader = new FileReader();
            reader.readAsDataURL(target.files[0]);
            reader.onload = (e) => {
                newAvatar = e.target?.result;
            };
        }
    };
</script>

<div id="app">
    {#if avatar}
        <img class="avatar" src={avatar} alt="d" />
    {:else}
        <img
            class="avatar"
            src={newAvatar
                ? newAvatar
                : 'https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png'}
            alt=""
        />
        <input type="file" id="file" name={fieldName} required on:change={(e) => onFileSelected(e)} />
        <label for="file" class="btn-3">
            {#if newAvatar}
                <span>Image selected! Click upload.</span>
            {:else}
                <span>{title}</span>
            {/if}
        </label>
    {/if}
</div>

<style>
    #app {
        margin-top: 1rem;
        display: flex;
        align-items: center;
        justify-content: center;
        flex-flow: column;
        color: rgb(148 163 184);
    }

    .avatar {
        display: flex;
        height: 6.5rem;
        width: 8rem;
    }
    [type='file'] {
        height: 0;
        overflow: hidden;
        width: 0;
    }
    [type='file'] + label {
        background: #9b9b9b;
        border: none;
        border-radius: 5px;
        color: #fff;
        cursor: pointer;
        display: inline-block;
        font-weight: 500;
        margin-bottom: 1rem;
        outline: none;
        padding: 1rem 50px;
        position: relative;
        transition: all 0.3s;
        vertical-align: middle;
    }
    [type='file'] + label:hover {
        background-color: #9b9b9b;
    }
    [type='file'] + label.btn-3 {
        background-color: #d43aff;
        border-radius: 0;
        overflow: hidden;
    }
    [type='file'] + label.btn-3 span {
        display: inline-block;
        height: 100%;
        transition: all 0.3s;
        width: 100%;
    }
    [type='file'] + label.btn-3::before {
        color: #fff;
        content: '\01F4F7';
        font-size: 200%;
        height: 100%;
        left: 45%;
        position: absolute;
        top: -180%;
        transition: all 0.3s;
        width: 100%;
    }
    [type='file'] + label.btn-3:hover {
        background-color: rgba(14, 166, 236, 0.5);
    }
    [type='file'] + label.btn-3:hover span {
        transform: translateY(300%);
    }
    [type='file'] + label.btn-3:hover::before {
        top: 0;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

We built a custom file upload component with pure CSS. When a user clicks the "Select user image" button — inwardly, it's just an input label — and picks an image, the default image icon will be replaced by the newly selected image and a message, Image selected! Click upload., will appear. Clicking UPLOAD IMAGE will send the file to our backend's file upload endpoint which, in turn, sends it to AWS S3 for storage. A successful image upload or deletion will prompt the user to ensure the entire profile is updated for the image to be saved in the database.

The form actions responsible for all of these are in frontend/src/routes/auth/about/[id]/+page.server.js:

// frontend/src/routes/auth/about/[id]/+page.server.js
import { BASE_API_URI } from '$lib/utils/constants';
import { formatError } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';

/** @type {import('./$types').PageServerLoad} */
export async function load({ locals, params }) {
    // redirect user if not logged in
    if (!locals.user) {
        throw redirect(302, `/auth/login?next=/auth/about/${params.id}`);
    }
}

/** @type {import('./$types').Actions} */
export const actions = {
    /**
     *
     * @param request - The request object
     * @param fetch - Fetch object from sveltekit
     * @param cookies - SvelteKit's cookie object
     * @param locals - The local object, housing current user
     * @returns Error data or redirects user to the home page or the previous page
     */
    updateUser: async ({ request, fetch, cookies, locals }) => {
        const formData = await request.formData();
        const firstName = String(formData.get('first_name'));
        const lastName = String(formData.get('last_name'));
        const thumbnail = String(formData.get('thumbnail'));
        const phoneNumber = String(formData.get('phone_number'));
        const birthDate = String(formData.get('birth_date'));
        const githubLink = String(formData.get('github_link'));

        const apiURL = `${BASE_API_URI}/users/update-user/`;

        const res = await fetch(apiURL, {
            method: 'PATCH',
            credentials: 'include',
            headers: {
                'Content-Type': 'application/json',
                Cookie: `sessionid=${cookies.get('go-auth-sessionid')}`
            },
            body: JSON.stringify({
                first_name: firstName,
                last_name: lastName,
                thumbnail: thumbnail,
                phone_number: phoneNumber,
                birth_date: birthDate,
                github_link: githubLink
            })
        });

        if (!res.ok) {
            const response = await res.json();
            const errors = formatError(response.error);
            return fail(400, { errors: errors });
        }

        const response = await res.json();

        locals.user = response;

        if (locals.user.profile.birth_date) {
            locals.user.profile.birth_date = response['profile']['birth_date'].split('T')[0];
        }

        throw redirect(303, `/auth/about/${response.id}`);
    },
    /**
     *
     * @param request - The request object
     * @param fetch - Fetch object from sveltekit
     * @param cookies - SvelteKit's cookie object
     * @param locals - The local object, housing current user
     * @returns Error data or redirects user to the home page or the previous page
     */
    uploadImage: async ({ request, fetch, cookies }) => {
        const formData = await request.formData();

        /** @type {RequestInit} */
        const requestInitOptions = {
            method: 'POST',
            headers: {
                Cookie: `sessionid=${cookies.get('go-auth-sessionid')}`
            },
            body: formData
        };

        const res = await fetch(`${BASE_API_URI}/file/upload/`, requestInitOptions);

        if (!res.ok) {
            const response = await res.json();
            const errors = formatError(response.error);
            return fail(400, { errors: errors });
        }

        const response = await res.json();

        return {
            success: true,
            thumbnail: response['s3_url']
        };
    },
    /**
     *
     * @param request - The request object
     * @param fetch - Fetch object from sveltekit
     * @param cookies - SvelteKit's cookie object
     * @param locals - The local object, housing current user
     * @returns Error data or redirects user to the home page or the previous page
     */
    deleteImage: async ({ request, fetch, cookies }) => {
        const formData = await request.formData();

        /** @type {RequestInit} */
        const requestInitOptions = {
            method: 'DELETE',
            headers: {
                Cookie: `sessionid=${cookies.get('go-auth-sessionid')}`
            },
            body: formData
        };

        const res = await fetch(`${BASE_API_URI}/file/delete/`, requestInitOptions);

        if (!res.ok) {
            const response = await res.json();
            const errors = formatError(response.error);
            return fail(400, { errors: errors });
        }

        return {
            success: true,
            thumbnail: ''
        };
    }
};
Enter fullscreen mode Exit fullscreen mode

Three named form actions are there. They do exactly what their names imply using different API endpoints to achieve their aims.

Because uploading to and deleting a file from AWS S3 takes some seconds, I included a small loader to inform the user that something is still ongoing. The loader is a basic component:

<!-- frontend/src/lib/components/SmallLoader.svelte -->
<script>
    /** @type {number | null} */
    export let width;
    /** @type {string | null} */
    export let message;
</script>

<div class="loading">
    <p class="simple-loader" style={width ? `width: ${width}px` : ''} />
    {#if message}
        <p>{message}</p>
    {/if}
</div>

<style>
    .loading {
        display: flex;
        align-items: center;
        justify-content: center;
    }
    .loading p {
        margin-left: 0.5rem;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

The CSS for the real loader is in styles.css.

With that, you can test out the feature. Ensure you update Header.svelte.

Step 3: The admin interface

Though this article is becoming quite long, I feel I should include this here nevertheless. In the last article, we made an endpoint that exposes our application's metrics. The endpoint returns a JSON which isn't fancy enough for everyone to look at. This prompted me to build out a dashboard where the data therein are elegantly visualized. Therefore, I created an admin route, which can only be accessed by users with is_superuser set to true. The route has the following files' contents:

<!-- frontend/src/routes/auth/admin/+page.svelte -->

<script>
    import '$lib/css/dash.min.css';
    import { page } from '$app/stores';
    import List from '$lib/components/Admin/List.svelte';

    /** @type {import('./$types').PageData} */
    export let data;

    $: ({ metrics } = data);

    const calculateAvgProTime = (/** @type {any} */ metric) => {
        const div = metric.total_processing_time_μs / metric.total_requests_received;
        const inSecs = div * 0.000001;
        return `${inSecs.toFixed(2)}s/req`;
    };
    const turnMemstatsObjToArray = (/** @type {any} */ metric) => {
        const exclude = new Set(['PauseNs', 'PauseEnd', 'BySize']);
        const data = Object.fromEntries(Object.entries(metric).filter((e) => !exclude.has(e[0])));
        return Object.keys(data).map((key) => {
            return {
                id: crypto.randomUUID(),
                name: key,
                value: data[key]
            };
        });
    };
    const returnDate = (/** @type {number} */ timestamp) => {
        const date = new Date(timestamp);
        return date.toUTCString();
    };
</script>

<div class="app">
    <div class="app-body">
        <nav class="navigation">
            <a href="/auth/admin" class:active={$page.url.pathname === '/auth/admin'}>
                <svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512">
                    <path
                        d="M0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm320 96c0-26.9-16.5-49.9-40-59.3V88c0-13.3-10.7-24-24-24s-24 10.7-24 24V292.7c-23.5 9.5-40 32.5-40 59.3c0 35.3 28.7 64 64 64s64-28.7 64-64zM144 176a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm-16 80a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zm288 32a32 32 0 1 0 0-64 32 32 0 1 0 0 64zM400 144a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
                    />
                </svg>
                <span>Metrics</span>
            </a>
            <a href="/auth/admin#" class:active={$page.url.pathname === '/auth/admin#'}>
                <svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 640 512">
                    <path
                        d="M144 0a80 80 0 1 1 0 160A80 80 0 1 1 144 0zM512 0a80 80 0 1 1 0 160A80 80 0 1 1 512 0zM0 298.7C0 239.8 47.8 192 106.7 192h42.7c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96c-.2 0-.4 0-.7 0H21.3C9.6 320 0 310.4 0 298.7zM405.3 320c-.2 0-.4 0-.7 0c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7h42.7C592.2 192 640 239.8 640 298.7c0 11.8-9.6 21.3-21.3 21.3H405.3zM224 224a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zM128 485.3C128 411.7 187.7 352 261.3 352H378.7C452.3 352 512 411.7 512 485.3c0 14.7-11.9 26.7-26.7 26.7H154.7c-14.7 0-26.7-11.9-26.7-26.7z"
                    />
                </svg>
                <span>Users</span>
            </a>
        </nav>

        <div class="app-body-main-content">
            <div class="service-header">
                <h2>Metrics</h2>
                <span>App's version: {metrics.version}; Timestamp: {returnDate(metrics.timestamp)}</span>
            </div>
            <div class="tiles">
                <article class="tile">
                    <div class="tile-header">
                        <svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 448 512">
                            <path
                                d="M349.4 44.6c5.9-13.7 1.5-29.7-10.6-38.5s-28.6-8-39.9 1.8l-256 224c-10 8.8-13.6 22.9-8.9 35.3S50.7 288 64 288H175.5L98.6 467.4c-5.9 13.7-1.5 29.7 10.6 38.5s28.6 8 39.9-1.8l256-224c10-8.8 13.6-22.9 8.9-35.3s-16.6-20.7-30-20.7H272.5L349.4 44.6z"
                            />
                        </svg>
                        <h3>
                            <span>Avg Pro. Time</span>
                            <span>total pro. time(&mu;s) / total reqs</span>
                        </h3>
                    </div>
                    <p>{calculateAvgProTime(metrics)}</p>
                    <div>
                        {`(${metrics.total_processing_time_μs} / ${metrics.total_requests_received}) x 0.000001`}
                    </div>
                </article>
                <article class="tile">
                    <div class="tile-header">
                        <svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 640 512">
                            <path
                                d="M256 0c-35 0-64 59.5-64 93.7v84.6L8.1 283.4c-5 2.8-8.1 8.2-8.1 13.9v65.5c0 10.6 10.2 18.3 20.4 15.4l171.6-49 0 70.9-57.6 43.2c-4 3-6.4 7.8-6.4 12.8v42c0 7.8 6.3 14 14 14c1.3 0 2.6-.2 3.9-.5L256 480l110.1 31.5c1.3 .4 2.6 .5 3.9 .5c6 0 11.1-3.7 13.1-9C344.5 470.7 320 422.2 320 368c0-60.6 30.6-114 77.1-145.6L320 178.3V93.7C320 59.5 292 0 256 0zM640 368a144 144 0 1 0 -288 0 144 144 0 1 0 288 0zm-76.7-43.3c6.2 6.2 6.2 16.4 0 22.6l-72 72c-6.2 6.2-16.4 6.2-22.6 0l-40-40c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0L480 385.4l60.7-60.7c6.2-6.2 16.4-6.2 22.6 0z"
                            />
                        </svg>
                        <h3>
                            <span>Active in-flight reqs</span>
                            <span>total reqs - total res</span>
                        </h3>
                    </div>
                    <p>{metrics.total_requests_received - metrics.total_responses_sent}</p>
                    <div>{`${metrics.total_requests_received} - ${metrics.total_responses_sent}`}</div>
                </article>
                <article class="tile">
                    <div class="tile-header">
                        <svg xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512">
                            <path
                                d="M448 160H320V128H448v32zM48 64C21.5 64 0 85.5 0 112v64c0 26.5 21.5 48 48 48H464c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zM448 352v32H192V352H448zM48 288c-26.5 0-48 21.5-48 48v64c0 26.5 21.5 48 48 48H464c26.5 0 48-21.5 48-48V336c0-26.5-21.5-48-48-48H48z"
                            />
                        </svg>
                        <h3>
                            <span>Goroutines used</span>
                            <span>No. of active goroutines</span>
                        </h3>
                    </div>
                    <p>{metrics.goroutines}</p>
                    <div>No. of active goroutines</div>
                </article>
            </div>

            <div class="stats">
                <div class="stats-heading-container">
                    <h3 class="stats-heading ss-heading">Database</h3>
                    <span>App's database statistics</span>
                </div>
                <ul class="stats-list">
                    {#each turnMemstatsObjToArray(metrics.database) as stat, idx}
                        <List {stat} {idx} />
                    {/each}
                </ul>
            </div>
            <div class="stats">
                <div class="stats-heading-container">
                    <h3 class="stats-heading ss-heading">Memstats</h3>
                    <span>App's memory usage statistics</span>
                </div>
                <ul class="stats-list">
                    {#each turnMemstatsObjToArray(metrics.memstats) as stat, idx}
                        <List {stat} {idx} />
                    {/each}
                </ul>
            </div>
            <div class="stats">
                <div class="stats-heading-container">
                    <h3 class="stats-heading ss-heading">Responses by status</h3>
                    <span>App's responses by HTTP status</span>
                </div>
                <ul class="stats-list">
                    {#each turnMemstatsObjToArray(metrics.total_responses_sent_by_status) as stat, idx}
                        <List {stat} {idx} />
                    {/each}
                </ul>
            </div>
        </div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

The page looks like this:

Application's admin page

It has a sub-component:

<!-- frontend/src/lib/components/Admin/List.svelte -->
<script>
    import { receive, send } from '$lib/utils/helpers';

    /** @type {any} */
    export let stat;

    /** @type {number} */
    export let idx;
</script>

<li class="stats-item" in:receive={{ key: stat.id }} out:send={{ key: stat.id }}>
    <h4 class="stats-item-heading">{stat.name}</h4>
    <p class="stats-item-sub">{stat.value}</p>
    <div class="stats_more">
        <div
            class="stats_more-svg"
            style="background: linear-gradient(20deg, hsla({20 * idx}, 60%, 50%, .2), 
            hsla({20 * idx + 20}, 60%, 50%, .3));"
        >
            <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
                <defs>
                    <linearGradient id="myGradient2" gradientTransform="rotate(20)">
                        <stop offset="0%" stop-color="hsl({20 * idx}, 60%, 50%)" />
                        <stop offset="50%" stop-color="hsl({20 * idx + 20}, 60%, 50%)" />
                    </linearGradient>
                </defs>
                <path
                    d="M11 17a1 1 0 001.447.894l4-2A1 1 0 0017 15V9.236a1 1 0 00-1.447-.894l-4 2a1 1 0 00-.553.894V17zM15.211 6.276a1 1 0 000-1.788l-4.764-2.382a1 1 0 00-.894 0L4.789 4.488a1 1 0 000 1.788l4.764 2.382a1 1 0 00.894 0l4.764-2.382zM4.447 8.342A1 1 0 003 9.236V15a1 1 0 00.553.894l4 2A1 1 0 009 17v-5.764a1 1 0 00-.553-.894l-4-2z"
                    fill="url(#myGradient2)"
                />
            </svg>
        </div>
    </div>
</li>
Enter fullscreen mode Exit fullscreen mode

The data for the page was fetched by the page's +page.server.js file:

// frontend/src/routes/auth/admin/+page.server.js
import { BASE_API_URI } from '$lib/utils/constants';
import { redirect } from '@sveltejs/kit';

/** @type {import('./$types').PageServerLoad} */
export async function load({ locals, cookies }) {
    // redirect user if not logged in or not a superuser
    if (!locals.user || !locals.user.is_superuser) {
        throw redirect(302, `/auth/login?next=/auth/admin`);
    }

    const fetchMetrics = async () => {
        const res = await fetch(`${BASE_API_URI}/metrics/`, {
            credentials: 'include',
            headers: {
                Cookie: `sessionid=${cookies.get('go-auth-sessionid')}`
            }
        });
        return res.ok && (await res.json());
    };

    return {
        metrics: fetchMetrics()
    };
}
Enter fullscreen mode Exit fullscreen mode

It first ensures that only users with superuser status can access the page. Then, it fetches the metrics to visualize. Notice the use of an async function to do the fetching. It may not be evident now — since we are only fetching data from one endpoint — but that prevents waterfall issues thereby improving performance.

I apologize for the rather long article.

The coming articles will be based on automated testing, dockerization of the backend and deployments on fly.io (backend) and vercel (frontend). See you.

Outro

Enjoyed this article? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.

If you found this article valuable, consider sharing it with your network to help spread the knowledge!

Top comments (0)