Skip to content
Open
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
15 changes: 12 additions & 3 deletions src/lib/alert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,33 +18,42 @@ export const ALERT_METRICS: AlertMetricMeta[] = [
{ value: 'egress', label: 'Egress (bytes per minute)' }
]

export const ALERT_CUSTOM_METRICS: AlertMetricMeta[] = [
{ value: 'value', label: 'Value (gauge)' },
{ value: 'rate', label: 'Rate (per minute)' }
]

export const ALERT_OPS = [
{ value: '>=', label: '>= (at or above)' },
{ value: '<=', label: '<= (at or below)' }
]

export function alertMetricLabel (metric: string): string {
return ALERT_METRICS.find((m) => m.value === metric)?.label ?? metric
return ALERT_METRICS.concat(ALERT_CUSTOM_METRICS).find((m) => m.value === metric)?.label ?? metric
}

/**
* Format a threshold/value for its metric's unit — percent for cpu/memory,
* binary bytes/min for egress, req/min otherwise.
* binary bytes/min for egress, the raw gauge for kind=custom value, per-minute
* for rate/requests.
*/
export function alertThresholdString (metric: string, value: number): string {
if (metric === 'cpu' || metric === 'memory') return `${value}%`
if (metric === 'egress') return `${format.storage(value)}/min`
if (metric === 'value') return String(value)
return `${value}/min`
}

/**
* Human-readable one-liner for a condition, e.g. "cpu >= 90% for 10m".
* Human-readable one-liner for a condition, e.g. "cpu >= 90% for 10m" or
* "value >= 10 for 5m".
*/
export function alertConditionString (c: Api.AlertCondition): string {
return `${c.metric} ${c.op} ${alertThresholdString(c.metric, c.threshold)} for ${c.forMinutes}m`
}

export function alertTargetString (t: Api.AlertTarget): string {
if (t.kind === 'custom') return `${t.source} / ${t.series}`
return `${t.location} / ${t.deployment}`
}

Expand Down
185 changes: 185 additions & 0 deletions src/lib/components/CustomMetricsChart.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
<script lang="ts">
import { untrack } from 'svelte'
import { browser } from '$app/environment'
import api from '$lib/api'
import Chart from '$lib/components/Chart.svelte'
import RangeSwitch from '$lib/components/RangeSwitch.svelte'
import OptionSelect from '$lib/components/OptionSelect.svelte'
import { RANGE_SECONDS } from '$lib/metrics'
import { TRUNCATED_BANNER, metricSourceChartSeries } from '$lib/metricsource'

interface Props {
project: string
source: Api.MetricSourceItem
seriesItems?: Api.MetricSourceSeriesItem[]
}

const { project, source, seriesItems }: Props = $props()

let range = $state('1h')
let selected = $state<string[]>([])
let items = $state<Api.UsageMetricsLine[]>([])
let fetchedSeries = $state<Api.MetricSourceSeriesItem[]>([])
let loading = $state(true)
let queryError = $state('')
let refreshTimer: ReturnType<typeof setTimeout> | undefined

const seriesList = $derived(seriesItems ?? fetchedSeries)
const seriesOptions = $derived(seriesList.map((s) => ({
value: s.series,
label: s.series
})))
const chartSeries = $derived(metricSourceChartSeries(items))
const isEmpty = $derived(!loading && items.length === 0 && !queryError)

function scheduleRefresh () {
clearTimeout(refreshTimer)
if (RANGE_SECONDS[range] <= RANGE_SECONDS['1d']) {
refreshTimer = setTimeout(fetchQuery, 60 * 1000)
}
}

async function fetchQuery () {
const r = untrack(() => range)
const s = untrack(() => selected)
const name = untrack(() => source.name)
await queryFor(name, s.join('\0'), r)
}

async function queryFor (name: string, seriesKey: string, timeRange?: string) {
loading = true
queryError = ''
const r = timeRange ?? untrack(() => range)
const s = seriesKey === '' ? [] : seriesKey.split('\0')
try {
const res = await api.invoke<Api.MetricSourceQueryResult>('metricSource.query', {
project,
name,
series: s,
timeRange: r
}, fetch)
if (!res.ok) {
queryError = res.error?.message ?? 'query failed'
items = []
return
}
items = res.result?.items ?? []
} finally {
loading = false
scheduleRefresh()
}
}

async function loadSeriesFor (name: string) {
if (seriesItems) {
fetchedSeries = seriesItems
return
}
const res = await api.invoke<Api.MetricSourceSeriesResult>('metricSource.series', {
project,
name
}, fetch)
fetchedSeries = res.result?.items ?? []
}

function selectRange (r: string) {
if (r === range) return
range = r
items = []
fetchQuery()
}

$effect(() => {
if (!browser) return
loadSeriesFor(source.name)
})

$effect(() => {
if (!browser) return
queryFor(source.name, selected.join('\0'))
return () => clearTimeout(refreshTimer)
})
</script>

{#if source.truncated}
<div class="banner is-warning" role="status">
<i class="fa-solid fa-triangle-exclamation"></i>
<span>{TRUNCATED_BANNER}</span>
</div>
{/if}
{#if source.lastError}
<div class="banner is-negative" role="alert">
<i class="fa-solid fa-circle-exclamation"></i>
<span>{source.lastError}</span>
</div>
{/if}
{#if queryError}
<div class="banner is-negative" role="alert">
<i class="fa-solid fa-circle-exclamation"></i>
<span>{queryError}</span>
</div>
{/if}

<div class="toolbar">
<div class="field series-field">
<label for="input-chart-series">Series</label>
<OptionSelect
id="input-chart-series"
multi
bind:tags={selected}
options={seriesOptions}
placeholder="All series (top by last seen)"
emptyText="No series discovered yet" />
</div>
<RangeSwitch value={range} onselect={selectRange} />
</div>

{#if isEmpty}
<div class="banner is-info" role="status">
<i class="fa-solid fa-chart-line"></i>
<span>No custom metric samples in this window</span>
</div>
{:else}
<Chart title={source.name} unit="count" series={chartSeries} {range} />
{/if}

<style>
.toolbar {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}

.series-field {
flex: 1;
min-width: 16rem;
}

.banner {
--tone: var(--hsl-content);
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
border: 1px solid hsl(var(--tone) / 0.3);
border-left-width: 3px;
border-radius: 8px;
background: hsl(var(--tone) / 0.06);
font-size: 0.8125rem;
line-height: 1.5;
color: hsl(var(--hsl-content) / 0.85);
}

.banner i {
color: hsl(var(--tone));
margin-top: 0.1rem;
}

.banner.is-warning { --tone: var(--hsl-warning); }
.banner.is-negative { --tone: var(--hsl-negative); }
.banner.is-info { --tone: var(--hsl-primary); }
</style>
20 changes: 20 additions & 0 deletions src/lib/metricsource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Status + chart helpers for metric sources. Mirrors api/metricsource.go's
* metricSourceStatus (disabled → error → truncated → ok).
*/
import type { MetricSeries } from '$lib/charts/util'

export type MetricSourceStatus = 'ok' | 'disabled' | 'truncated' | 'error'

export function metricSourceStatus (s: Api.MetricSourceItem): MetricSourceStatus {
if (s.disabled) return 'disabled'
if (s.lastError) return 'error'
if (s.truncated) return 'truncated'
return 'ok'
}

export const TRUNCATED_BANNER = 'series cap hit — extra series were dropped'

export function metricSourceChartSeries (items: Api.UsageMetricsLine[]): MetricSeries[] {
return items.map((l) => ({ prefix: l.name, lines: [l] }))
}
1 change: 1 addition & 0 deletions src/lib/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,6 @@ export const projectMenu: ProjectMenuItem[] = [
{ id: 'scheduler', title: 'Scheduler', icon: 'fa-clock', link: '/scheduler', preview: true },
{ id: 'notification', title: 'Notifications', icon: 'fa-bell', link: '/notification', preview: true },
{ id: 'alert', title: 'Alerts', icon: 'fa-bell-exclamation', link: '/alert', preview: true },
{ id: 'metric-source', title: 'Metric sources', icon: 'fa-satellite-dish', link: '/metrics-sources', preview: true },
{ id: 'audit-log', title: 'Audit Logs', icon: 'fa-clipboard-list', link: '/audit-log' }
]
Loading
Loading