Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8e733ba
chore(api): update api definitions
brucetony Jul 30, 2026
2c47831
build(charts): add nuxt charts dep
brucetony Jul 30, 2026
cb314ec
revert(charts): remove nuxt charts deps since good stuff is behind a …
brucetony Jul 30, 2026
4f51762
build(uptime): add charts deps
brucetony Jul 31, 2026
c14f3eb
chore(uptime): update openapi models
brucetony Jul 31, 2026
ee81c58
chore(typing): fix typescript errors and usage
brucetony Aug 2, 2026
0f4d805
feat(uptime): first working version
brucetony Aug 2, 2026
b06f53e
refactor(uptime): map service names
brucetony Aug 2, 2026
17f0c28
test(uptime): add unit tests for uptime components
brucetony Aug 2, 2026
ee63b1c
test(ds): update tests to include new numeric suffix
brucetony Aug 2, 2026
e9e553d
fix(uptime): show proper service name in bucket dialog
brucetony Aug 3, 2026
c4e9da6
Merge branch 'develop' into 396-service-status-page
brucetony Aug 3, 2026
772d82a
ci: fix nuxt caching problem
brucetony Aug 3, 2026
9eb7e89
ci: update deprecated actions
brucetony Aug 3, 2026
c75d579
perf(uptime): properly manage uptime slots and their overlaps
brucetony Aug 4, 2026
f9b1104
ci: remove redundant image compilations
brucetony Aug 4, 2026
92cd854
refactor(uptime): use fetch instead of composable after setup
brucetony Aug 4, 2026
de6200b
chore(api): use fecth where needed and remove unused endpoints
brucetony Aug 4, 2026
f081601
build(nuxt): guard against lazy import bug in config
brucetony Aug 4, 2026
ba35b53
chore: clean house
brucetony Aug 4, 2026
b0991c4
perf(ds): make sync reqs sync
brucetony Aug 4, 2026
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
9 changes: 4 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,17 @@ RUN corepack enable

WORKDIR /app

# pnpm-workspace.yaml carries patchedDependencies and allowBuilds (pnpm v11+);
# without it, patches are silently skipped and build scripts are not run.
# for patchedDependencies
COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./
COPY patches /app/patches

# Remove once corepack bug fixed https://github.com/nodejs/corepack/issues/612#issuecomment-2629613697
ENV COREPACK_INTEGRITY_KEYS=0

RUN pnpm install --frozen-lockfile

COPY . .

# always from scratch
RUN rm -rf .nuxt

ENV NUXT_HOST=0.0.0.0
ENV NUXT_PORT=3000

Expand Down
2 changes: 1 addition & 1 deletion app/components/analysis/AnalysesTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -551,7 +551,7 @@ const onCloseNavToast = () => {
<div class="card flex justify-content-center refresh-switch">
<Button
v-tooltip.top="'Refresh table'"
:loading="status.value === 'pending'"
:loading="status === 'pending'"
aria-label="Filter"
class="table-refresh-btn"
icon="pi pi-refresh"
Expand Down
19 changes: 13 additions & 6 deletions app/components/header/MenuHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,6 @@ const allLinks = [
icon: "pi pi-lightbulb",
route: "/analyses",
},
{
label: "Events",
icon: "pi pi-list",
route: "/events",
},
{
label: "Data Stores",
icon: "pi pi-warehouse",
Expand All @@ -47,6 +42,16 @@ const allLinks = [
},
],
},
{
label: "Events",
icon: "pi pi-list",
route: "/events",
},
{
label: "Uptime",
icon: "pi pi-wave-pulse",
route: "/uptime",
},
];

const links = computed(() =>
Expand Down Expand Up @@ -136,7 +141,9 @@ const links = computed(() =>

.menu-bar-header {
border-radius: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.06);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 1px 2px rgba(0, 0, 0, 0.06);
}

.menu-bar-header .menu-bar-item {
Expand Down
6 changes: 3 additions & 3 deletions app/components/table/SearchBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
import IconField from "primevue/iconfield";
import InputIcon from "primevue/inputicon";

const props = defineProps({
searchTerm: [String, undefined],
});
const props = defineProps<{
searchTerm?: string;
}>();

const emit = defineEmits(["clearFilters", "updateSearch"]);

Expand Down
206 changes: 206 additions & 0 deletions app/components/uptime/BucketDrilldownDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
<script lang="ts" setup>
import Button from "primevue/button";
import Column from "primevue/column";
import DataTable from "primevue/datatable";
import Dialog from "primevue/dialog";
import Tag from "primevue/tag";
import { getServiceHealthHistory } from "~/composables/useAPIFetch";
import type { UptimeSlot } from "~/composables/useServiceHealth";
import { formatClockTime, SLOW_LATENCY_MS } from "~/utils/uptime-state";
import { ServiceCheckStatus, type ServiceHealthPoint } from "~/services/Api";

const props = withDefaults(
defineProps<{
visible: boolean;
serviceTitle: string | null;
service: string | null;
slotRange: UptimeSlot | null;
slots?: UptimeSlot[];
}>(),
{ slots: () => [] },
);

const emit = defineEmits<{
"update:visible": [value: boolean];
"update:slotRange": [value: UptimeSlot];
}>();

const checks = ref<ServiceHealthPoint[]>([]);
const loading = ref(false);

let latestRequest = 0;

async function loadChecks() {
const service = props.service;
const range = props.slotRange;

if (!props.visible || !service || !range) {
checks.value = [];
return;
}

const request = ++latestRequest;
loading.value = true;

try {
const { data } = await getServiceHealthHistory({
start_date: range.start.toISOString(),
end_date: range.end.toISOString(),
service: [service],
include_checks: true,
});

if (request !== latestRequest) return;

checks.value = data.value?.services?.[service]?.checks ?? [];
} finally {
if (request === latestRequest) loading.value = false;
}
}

const currentIndex = computed(() => {
const range = props.slotRange;
if (!range) return -1;

return props.slots.findIndex(
(slot) => slot.start.getTime() === range.start.getTime(),
);
});

const hasPrevious = computed(() => currentIndex.value > 0);
const hasNext = computed(
() => currentIndex.value >= 0 && currentIndex.value < props.slots.length - 1,
);

const headerLabel = computed(() =>
props.service ? `${props.serviceTitle} Health Checks` : "Health Checks",
);

const windowLabel = computed(() => {
const range = props.slotRange;
if (!range) return "";

const window = `${formatClockTime(range.start)} - ${formatClockTime(range.end)}`;

return currentIndex.value < 0
? window
: `${window} · slice ${currentIndex.value + 1} of ${props.slots.length}`;
});

function step(offset: number) {
const target = props.slots[currentIndex.value + offset];
if (currentIndex.value < 0 || !target) return;

emit("update:slotRange", target);
}

function isSlow(latency: number | null | undefined): boolean {
return latency != null && latency > SLOW_LATENCY_MS;
}

function formatTime(value: string): string {
return new Date(value).toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "medium",
});
}

watch(() => [props.visible, props.service, props.slotRange], loadChecks, {
immediate: true,
});
</script>

<template>
<Dialog
:header="headerLabel"
:style="{ width: '48rem' }"
:visible="visible"
dismissable-mask
modal
@update:visible="emit('update:visible', $event)"
>
<div class="bucket-drilldown-nav">
<Button
:disabled="!hasPrevious"
data-testid="uptime-drilldown-prev"
icon="pi pi-chevron-left"
label="Previous slice"
severity="secondary"
size="small"
text
@click="step(-1)"
/>

<span
aria-live="polite"
class="bucket-drilldown-window"
data-testid="uptime-drilldown-window"
>
{{ windowLabel }}
</span>

<Button
:disabled="!hasNext"
data-testid="uptime-drilldown-next"
icon="pi pi-chevron-right"
icon-pos="right"
label="Next slice"
severity="secondary"
size="small"
text
@click="step(1)"
/>
</div>

<DataTable :loading="loading" :value="checks" size="small">
<Column field="checked_at" header="Checked at">
<template #body="{ data }">{{ formatTime(data.checked_at) }}</template>
</Column>
<Column field="status" header="Status">
<template #body="{ data }">
<Tag
:severity="
data.status === ServiceCheckStatus.OK ? 'success' : 'danger'
"
:value="data.status"
/>
</template>
</Column>
<Column field="status_code" header="Code" />
<Column field="latency_ms" header="Latency (ms)">
<template #body="{ data }">
<span :class="{ 'bucket-drilldown-slow': isSlow(data.latency_ms) }">
{{ data.latency_ms ?? "-" }}
</span>
</template>
</Column>
<Column field="message" header="Message" />

<template #empty>No checks recorded in this window.</template>
</DataTable>
</Dialog>
</template>

<style lang="scss" scoped>
.bucket-drilldown-nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.5rem;
}

.bucket-drilldown-window {
font-size: 0.875rem;
color: var(--p-text-muted-color);
}

.bucket-drilldown-slow {
color: var(--p-amber-700);
font-weight: 700;
}

:global(html.flame-dark .bucket-drilldown-slow) {
color: var(--p-amber-400);
}
</style>
Loading
Loading