175 lines
6.5 KiB
Vue
175 lines
6.5 KiB
Vue
<template>
|
|
<LogItem :logEntry>
|
|
<div class="alert-row w-full min-w-0 border-l-3 pl-4 font-sans" :data-alert-level="level">
|
|
<div class="flex flex-col gap-1.5 py-1">
|
|
<div class="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
|
<span class="chip">
|
|
<mdi:bell-alert class="size-3" />
|
|
{{ $t("label.alert") }}
|
|
</span>
|
|
<span class="font-semibold">{{ alert.headline }}</span>
|
|
<!-- Only the count rides the summary line. Everything else —
|
|
containers, what triage held back, the investigation — sits
|
|
behind Details. -->
|
|
<span class="shrink-0 opacity-40">·</span>
|
|
<span class="shrink-0 opacity-60">
|
|
{{ $t("label.alert-events", alert.eventCount) }}<template v-if="ranFor">, {{ ranFor }}</template>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Actions get their own line rather than riding the headline: at
|
|
the end of a long headline they were easy to miss, and a fixed
|
|
position means they land in the same place on every alert down
|
|
the stream. -->
|
|
<div class="flex items-center justify-end gap-1.5">
|
|
<button v-if="hasDetail" type="button" class="act" @click="expanded = !expanded">
|
|
{{ $t("label.alert-details") }}
|
|
<span class="caret" :data-open="expanded">›</span>
|
|
</button>
|
|
<a v-if="alert.url" :href="alert.url" target="_blank" rel="noopener" class="act act-tinted">
|
|
{{ $t("label.alert-view-in-cloud") }}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="expanded" class="mt-1 flex flex-col gap-1 text-xs opacity-70">
|
|
<div class="flex flex-wrap gap-x-4 gap-y-0.5">
|
|
<span v-if="alert.containerCount && alert.containerCount > 1">
|
|
{{ $t("label.alert-containers", alert.containerCount) }}
|
|
</span>
|
|
<span v-if="alert.suppressedCount">
|
|
{{ $t("label.alert-held-back", alert.suppressedCount) }}
|
|
</span>
|
|
</div>
|
|
<!-- summary is the alert's description and is usually all a
|
|
non-triaged alert has; investigation is triage's longer write-up
|
|
and exists only when triage actually ran. -->
|
|
<div v-if="alert.summary" class="max-w-prose whitespace-pre-wrap">{{ alert.summary }}</div>
|
|
<div v-if="alert.investigation" class="max-w-prose whitespace-pre-wrap">
|
|
{{ alert.investigation }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</LogItem>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import { AlertLogEntry } from "@/models/LogEntry";
|
|
|
|
const { logEntry } = defineProps<{
|
|
logEntry: AlertLogEntry;
|
|
showContainerName?: boolean;
|
|
}>();
|
|
|
|
const alert = computed(() => logEntry.alert);
|
|
const expanded = ref(false);
|
|
|
|
// Anything under a minute is one moment, not a duration — Cloud's aggregation
|
|
// window is 15s, so sub-minute spans are an artefact of batching rather than a
|
|
// real incident length.
|
|
const MIN_REPORTABLE_MS = 60_000;
|
|
|
|
/**
|
|
* How long the incident kept firing. Cloud folds follow-up batches into the
|
|
* original alert, so lastActivityAt is already on the row — without it a card
|
|
* reads as a single moment when the incident may have run for hours.
|
|
*/
|
|
const ranFor = computed(() => {
|
|
const { lastActivityAt, ts } = alert.value;
|
|
if (!lastActivityAt) return null;
|
|
const ms = (lastActivityAt - ts) / 1_000_000;
|
|
if (ms < MIN_REPORTABLE_MS) return null;
|
|
return formatDuration(Math.round(ms / 1000), locale.value === "" ? undefined : locale.value);
|
|
});
|
|
|
|
/**
|
|
* alerts.level is free text and defaults to '' — an alert that never went
|
|
* through a summarizer has no level at all. A neutral fallback made the common
|
|
* case indistinguishable from a log line, which is the one thing this row must
|
|
* not be. Unknown reads as error.
|
|
*/
|
|
const level = computed(() => {
|
|
const l = alert.value.level;
|
|
if (l === "warn" || l === "warning") return "warn";
|
|
if (l === "info" || l === "debug" || l === "trace") return "info";
|
|
return "error";
|
|
});
|
|
|
|
const hasDetail = computed(
|
|
() =>
|
|
!!alert.value.summary ||
|
|
!!alert.value.investigation ||
|
|
!!alert.value.suppressedCount ||
|
|
(alert.value.containerCount ?? 0) > 1,
|
|
);
|
|
</script>
|
|
|
|
<style scoped>
|
|
@reference "@/main.css";
|
|
|
|
/* font-sans, not the monospace the log list sets on its <ul>: this row is
|
|
prose about the logs rather than log output, and monospace made a sentence
|
|
of it read as another line of the stream.
|
|
|
|
No fill: a rail and a chip mark the row, and the log background is left
|
|
alone. Colour is spent where the eye finds it fastest — a small,
|
|
high-contrast marker against a calm surface.
|
|
|
|
Keyed on data-alert-level, NOT data-level: LogLevel.vue ships a second,
|
|
UNSCOPED style block whose `[data-level="error"] { @apply !bg-red }` paints
|
|
any element in the app carrying that attribute, !important and all. Reusing
|
|
the name filled this row solid red and made every local rule unwinnable. */
|
|
.alert-row {
|
|
border-color: var(--tint);
|
|
}
|
|
.alert-row[data-alert-level="error"] {
|
|
--tint: var(--color-error);
|
|
--tint-content: var(--color-error-content);
|
|
}
|
|
.alert-row[data-alert-level="warn"] {
|
|
--tint: var(--color-warning);
|
|
--tint-content: var(--color-base-300);
|
|
}
|
|
.alert-row[data-alert-level="info"] {
|
|
--tint: var(--color-info);
|
|
--tint-content: var(--color-info-content);
|
|
}
|
|
|
|
.chip {
|
|
background-color: var(--tint);
|
|
color: var(--tint-content);
|
|
@apply inline-flex shrink-0 items-center gap-1 rounded-xs px-1.5 py-px text-[0.62rem] font-bold tracking-wider uppercase;
|
|
}
|
|
|
|
/* Actions read as controls, not as more log text: a hairline button for the
|
|
secondary one and a tint of the level colour for the primary. Deliberately
|
|
not daisyUI's .btn — btn-ghost renders borderless (invisible as a control)
|
|
and plain .btn renders a heavy filled box that outweighs the row. */
|
|
.act {
|
|
@apply inline-flex shrink-0 items-center gap-1 rounded border px-2 py-0.5 text-[0.7rem] leading-normal;
|
|
border-color: color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
|
}
|
|
.act:hover {
|
|
background-color: color-mix(in oklab, var(--color-base-content) 10%, transparent);
|
|
}
|
|
.act:focus-visible {
|
|
@apply outline-primary outline-2 outline-offset-1;
|
|
}
|
|
.act-tinted {
|
|
border-color: transparent;
|
|
background-color: color-mix(in oklab, var(--tint) 20%, transparent);
|
|
color: var(--tint);
|
|
@apply font-semibold;
|
|
}
|
|
.act-tinted:hover {
|
|
background-color: color-mix(in oklab, var(--tint) 30%, transparent);
|
|
}
|
|
|
|
.caret {
|
|
@apply inline-block transition-transform;
|
|
}
|
|
.caret[data-open="true"] {
|
|
@apply rotate-90;
|
|
}
|
|
</style>
|