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:
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>
The route will be /auth/regenerate-token
. It only has one input and the page looks like this:
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}`);
}
};
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:
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>
The page ordinarily displays the user's data based on the fields filled. It looks like this:
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>
On the user profile page, clicking the EDIT PROFILE
button shows something like the image below (the screenshot isn't exact):
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>
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: ''
};
}
};
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>
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(μ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>
The page looks like this:
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>
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()
};
}
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)