Skip to content

Commit dad8ce4

Browse files
authored
feat: add ability to share a run permalink (#1)
* feat: add ability to share a run permalink * feat: extract shareable run to separate cached page * fix: bump to latest nuxt for new netlify preset * chore: specify min node version
1 parent 62eeb7c commit dad8ce4

File tree

11 files changed

+2076
-2060
lines changed

11 files changed

+2076
-2060
lines changed

app/app.vue

Lines changed: 7 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,3 @@
1-
<script setup lang="ts">
2-
interface Run {
3-
url: string
4-
status: number
5-
cacheHeaders: Record<string, string>
6-
durationInMs: number
7-
}
8-
9-
const runs = ref<Run[]>([])
10-
const error = ref<string | null>(null)
11-
12-
const handleRequestFormSubmit = async ({
13-
url,
14-
}: {
15-
url: string
16-
}): Promise<void> => {
17-
try {
18-
// Destructuring would be confusing, since the response body contains fields named `status` and
19-
// `headers` (it's a request about a request...)
20-
const responseBody = await $fetch(
21-
`/api/inspect-url/${encodeURIComponent(url)}`,
22-
)
23-
24-
runs.value.push({
25-
url,
26-
status: responseBody.status,
27-
cacheHeaders: getCacheHeaders(responseBody.headers),
28-
durationInMs: responseBody.durationInMs,
29-
})
30-
31-
error.value = null
32-
}
33-
catch (
34-
// TODO(serhalp) nuxt doesn't appear to re-export the `FetchError` types from ofetch. Look into this.
35-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
36-
err: any
37-
) {
38-
error.value
39-
= err?.data?.message
40-
?? err?.toString?.()
41-
?? new Error(`Fetch error: ${err}`)
42-
return
43-
}
44-
}
45-
46-
const handleClickClear = (): void => {
47-
runs.value = []
48-
}
49-
</script>
50-
511
<template>
522
<NuxtRouteAnnouncer />
533

@@ -60,72 +10,29 @@ const handleClickClear = (): void => {
6010
</header>
6111

6212
<main>
63-
<RequestForm @submit="handleRequestFormSubmit" />
64-
65-
<div
66-
v-if="error"
67-
class="error"
68-
>
69-
{{ error }}
70-
</div>
71-
72-
<div class="flex-btwn run-panels">
73-
<RunPanel
74-
v-for="(run, i) in runs"
75-
v-bind="run"
76-
:key="i"
77-
/>
78-
</div>
13+
<NuxtPage />
7914
</main>
80-
81-
<div class="reset-container">
82-
<button
83-
v-if="runs.length > 0"
84-
@click="handleClickClear()"
85-
>
86-
Clear
87-
</button>
88-
</div>
8915
</template>
9016

9117
<style>
9218
body {
9319
/* Override weird default from Netlify Examples style */
9420
text-align: left;
9521
}
96-
</style>
97-
98-
<style scoped>
99-
header {
100-
text-align: center;
101-
}
102-
103-
.subheading {
104-
font-size: 1.5em;
105-
}
10622
10723
main {
10824
/* Override very airy defaults from Netlify Examples style, not great for a utility app */
10925
margin-top: 3em;
11026
padding-bottom: 3em;
11127
}
28+
</style>
11229

113-
.error {
114-
color: var(--red-400);
115-
}
116-
117-
.run-panels {
118-
flex-wrap: wrap;
119-
align-items: stretch;
120-
}
121-
122-
.run-panels>* {
123-
flex: 1 1 20em;
124-
}
125-
126-
.reset-container {
30+
<style scoped>
31+
header {
12732
text-align: center;
33+
}
12834
129-
background-color: inherit;
35+
.subheading {
36+
font-size: 1.5em;
13037
}
13138
</style>

app/components/CacheAnalysis.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ const formatHumanSeconds = (seconds: number): string => {
2020
}
2121
2222
const formatDate = (date: Date): string =>
23+
// TODO(serhalp) This results in a hydration mismatch error since the locale is different on the
24+
// server than in the user's browser... I'm not sure how to solve this, but the impact is pretty minor.
2325
date.toLocaleString(undefined, {
2426
timeZoneName: 'short',
2527
})

app/components/RunPanel.vue

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
const props = defineProps<{
3+
runId: string
34
url: string
45
status: number
56
durationInMs: number
@@ -11,7 +12,17 @@ const props = defineProps<{
1112
<div class="panel run-panel">
1213
<h3>{{ props.url }}</h3>
1314

14-
<small>HTTP {{ props.status }} ({{ props.durationInMs }} ms)</small>
15+
<div class="flex-btwn">
16+
<small>HTTP {{ props.status }} ({{ props.durationInMs }} ms)</small>
17+
<NuxtLink
18+
:to="`/run/${props.runId}`"
19+
class="run-permalink"
20+
title="Share this run"
21+
target="_blank"
22+
>
23+
🔗 Permalink
24+
</NuxtLink>
25+
</div>
1526

1627
<CacheAnalysis :cache-headers="props.cacheHeaders" />
1728
<RawCacheHeaders :cache-headers="props.cacheHeaders" />
@@ -32,4 +43,9 @@ const props = defineProps<{
3243
font-size: 1em;
3344
align-self: start;
3445
}
46+
47+
.run-permalink {
48+
align-self: flex-end;
49+
font-size: 0.7em;
50+
}
3551
</style>

app/pages/index.vue

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<script setup lang="ts">
2+
// TODO(serhalp) Extract this whole script into something shared, probably RequestForm
3+
interface Run {
4+
runId: string
5+
url: string
6+
status: number
7+
cacheHeaders: Record<string, string>
8+
durationInMs: number
9+
}
10+
11+
const runs = ref<Run[]>([])
12+
const error = ref<string | null>(null)
13+
14+
// TODO(serhalp) Improve types
15+
type ApiRun = Omit<Run, 'cacheHeaders'> & { headers: Record<string, string> }
16+
const getRunFromApiRun = (apiRun: ApiRun): Run => {
17+
const { headers, ...run } = apiRun
18+
return { ...run, cacheHeaders: getCacheHeaders(headers) }
19+
}
20+
21+
const handleRequestFormSubmit = async ({
22+
url,
23+
}: {
24+
url: string
25+
}): Promise<void> => {
26+
try {
27+
const responseBody: ApiRun = await $fetch(
28+
'/api/inspect-url',
29+
{
30+
method: 'POST',
31+
body: { url },
32+
},
33+
)
34+
runs.value.push(getRunFromApiRun(responseBody))
35+
error.value = null
36+
}
37+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
38+
catch (err: any) {
39+
error.value
40+
= err?.data?.message
41+
?? err?.toString?.()
42+
?? new Error(`Fetch error: ${err}`)
43+
return
44+
}
45+
}
46+
47+
const handleClickClear = (): void => {
48+
runs.value = []
49+
}
50+
</script>
51+
52+
<template>
53+
<main>
54+
<RequestForm @submit="handleRequestFormSubmit" />
55+
56+
<div
57+
v-if="error"
58+
class="error"
59+
>
60+
{{ error }}
61+
</div>
62+
63+
<div class="flex-btwn run-panels">
64+
<RunPanel
65+
v-for="(run, i) in runs"
66+
v-bind="run"
67+
:key="i"
68+
/>
69+
</div>
70+
71+
<div class="reset-container">
72+
<button
73+
v-if="runs.length > 0"
74+
@click="handleClickClear()"
75+
>
76+
Clear
77+
</button>
78+
</div>
79+
</main>
80+
</template>
81+
82+
<style scoped>
83+
.error {
84+
color: var(--red-400);
85+
}
86+
87+
.run-panels {
88+
flex-wrap: wrap;
89+
align-items: stretch;
90+
}
91+
92+
.run-panels>* {
93+
flex: 1 1 20em;
94+
}
95+
96+
.reset-container {
97+
text-align: center;
98+
99+
background-color: inherit;
100+
}
101+
</style>

app/pages/run/[runId].vue

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<script setup lang="ts">
2+
// TODO(serhalp) Extract most of this script into something shared, probably RequestForm
3+
interface Run {
4+
runId: string
5+
url: string
6+
status: number
7+
cacheHeaders: Record<string, string>
8+
durationInMs: number
9+
}
10+
11+
const runs = ref<Run[]>([])
12+
const error = ref<string | null>(null)
13+
14+
// TODO(serhalp) Improve types
15+
type ApiRun = Omit<Run, 'cacheHeaders'> & { headers: Record<string, string> }
16+
const getRunFromApiRun = (apiRun: ApiRun): Run => {
17+
const { headers, ...run } = apiRun
18+
return { ...run, cacheHeaders: getCacheHeaders(headers) }
19+
}
20+
21+
const route = useRoute()
22+
23+
const { data: initialRuns, pending: _pending, error: preloadedRunsError } = await useAsyncData('preloadedRuns', async (): Promise<Run[]> => {
24+
const { runId } = route.params
25+
if (typeof runId === 'string') {
26+
const responseBody: ApiRun = await $fetch(`/api/runs/${runId}`)
27+
return [getRunFromApiRun(responseBody)]
28+
}
29+
return []
30+
})
31+
if (preloadedRunsError.value) {
32+
error.value = preloadedRunsError.value.toString()
33+
}
34+
if (initialRuns.value) {
35+
runs.value = initialRuns.value
36+
}
37+
38+
const handleRequestFormSubmit = async ({
39+
url,
40+
}: {
41+
url: string
42+
}): Promise<void> => {
43+
try {
44+
const responseBody: ApiRun = await $fetch(
45+
'/api/inspect-url',
46+
{
47+
method: 'POST',
48+
body: { url },
49+
},
50+
)
51+
runs.value.push(getRunFromApiRun(responseBody))
52+
error.value = null
53+
}
54+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
55+
catch (err: any) {
56+
error.value
57+
= err?.data?.message
58+
?? err?.toString?.()
59+
?? new Error(`Fetch error: ${err}`)
60+
return
61+
}
62+
}
63+
64+
const handleClickClear = (): void => {
65+
runs.value = []
66+
}
67+
</script>
68+
69+
<template>
70+
<main>
71+
<RequestForm @submit="handleRequestFormSubmit" />
72+
73+
<div
74+
v-if="error"
75+
class="error"
76+
>
77+
{{ error }}
78+
</div>
79+
80+
<div class="flex-btwn run-panels">
81+
<RunPanel
82+
v-for="(run, i) in runs"
83+
v-bind="run"
84+
:key="i"
85+
/>
86+
</div>
87+
88+
<div class="reset-container">
89+
<button
90+
v-if="runs.length > 0"
91+
@click="handleClickClear()"
92+
>
93+
Clear
94+
</button>
95+
</div>
96+
</main>
97+
</template>
98+
99+
<style scoped>
100+
.error {
101+
color: var(--red-400);
102+
}
103+
104+
.run-panels {
105+
flex-wrap: wrap;
106+
align-items: stretch;
107+
}
108+
109+
.run-panels>* {
110+
flex: 1 1 20em;
111+
}
112+
113+
.reset-container {
114+
text-align: center;
115+
116+
background-color: inherit;
117+
}
118+
</style>

0 commit comments

Comments
 (0)