Files
dozzle/assets/components/WelcomeModal.vue
T
2026-05-19 15:46:54 +00:00

311 lines
9.9 KiB
Vue

<template>
<dialog ref="modal" class="modal" @close="onClose">
<div class="modal-box max-w-md p-8">
<!-- Step 1: Feedback -->
<template v-if="step === 'step1'">
<div class="flex flex-col items-center gap-2 text-center">
<mdi:check-circle class="text-success text-4xl" />
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.title") }}</h3>
<p class="text-base-content/60 text-sm">{{ $t("cloud.welcome.subtitle") }}</p>
</div>
<div class="divider"></div>
<p class="mb-3 text-sm font-medium">{{ $t("cloud.welcome.question") }}</p>
<textarea
v-model="intent"
class="textarea textarea-bordered w-full text-sm"
rows="3"
:placeholder="$t('cloud.welcome.placeholder')"
></textarea>
<p class="text-base-content/60 mt-3 mb-2 text-xs">{{ $t("cloud.welcome.or-pick") }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="option in chipOptions"
:key="option.value"
class="btn btn-sm"
:class="selectedOptions.has(option.value) ? 'btn-primary' : 'btn-outline'"
@click="toggleOption(option.value)"
>
{{ option.label }}
</button>
</div>
<button class="btn btn-primary btn-block mt-6" :disabled="submitting" @click="submitFeedback">
<span v-if="submitting" class="loading loading-spinner loading-xs"></span>
{{ $t("cloud.welcome.get-started") }}
</button>
<button class="btn btn-ghost btn-block btn-sm mt-1" :disabled="submitting" @click="skipFeedback">
{{ $t("cloud.welcome.skip") }}
</button>
</template>
<!-- Step 2: Triage signal checklist -->
<template v-else-if="step === 'step2'">
<h3 class="text-xl font-bold">{{ $t("cloud.welcome.step2-title") }}</h3>
<p class="text-base-content/60 mt-2 text-sm">{{ $t("cloud.welcome.step2-body") }}</p>
<div class="mt-5 space-y-3">
<label
v-for="signal in signals"
:key="signal.key"
class="border-base-300 hover:border-primary/40 flex cursor-pointer gap-3 rounded-lg border p-3"
>
<input
v-model="selectedSignals"
type="checkbox"
:value="signal.key"
class="checkbox checkbox-primary checkbox-sm mt-0.5"
/>
<div class="flex-1">
<p class="text-sm font-semibold">{{ signal.label }}</p>
<p class="text-base-content/60 text-xs">{{ signal.description }}</p>
</div>
</label>
</div>
<p class="text-base-content/60 mt-4 text-xs">{{ $t("cloud.welcome.footer") }}</p>
<button
class="btn btn-primary btn-block mt-5"
:disabled="creating || selectedSignals.length === 0"
@click="createDefaultAlerts"
>
<span v-if="creating" class="loading loading-spinner loading-xs"></span>
{{ $t("cloud.welcome.create-alerts") }}
</button>
<button class="btn btn-ghost btn-block btn-sm mt-1" :disabled="creating" @click="close">
{{ $t("cloud.welcome.later") }}
</button>
</template>
</div>
<form method="dialog" class="modal-backdrop">
<button></button>
</form>
</dialog>
</template>
<script lang="ts" setup>
const { t } = useI18n();
const router = useRouter();
const route = useRoute();
const { showToast } = useToast();
const modal = ref<HTMLDialogElement>();
const step = ref<"step1" | "step2">("step1");
const intent = ref("");
const selectedOptions = ref(new Set<string>());
const submitting = ref(false);
const creating = ref(false);
let feedbackSent = false;
const chipOptions = [
{ value: "error_alerts", label: t("cloud.welcome.chip-alerts") },
{ value: "ai_assistant", label: t("cloud.welcome.chip-assistant") },
{ value: "search_logs", label: t("cloud.welcome.chip-search-logs") },
{ value: "remote_access", label: t("cloud.welcome.chip-remote-access") },
{ value: "log_digests", label: t("cloud.welcome.chip-digests") },
{ value: "something_else", label: t("cloud.welcome.chip-other") },
];
type SignalKey = "exited" | "unhealthy" | "oom" | "restart" | "disk";
type SignalKind = "event" | "metric";
interface SignalDef {
key: SignalKey;
kind: SignalKind;
label: string;
description: string;
// ruleName is intentionally English/stable so the rule stays recognizable
// if the user later switches locale.
ruleName: string;
expression: string;
defaultOn: boolean;
}
const signals = computed<SignalDef[]>(() => [
{
key: "exited",
kind: "event",
label: t("cloud.welcome.signals.exited"),
description: t("cloud.welcome.signals.exited-desc"),
ruleName: "Container exited with an error",
expression: 'name == "die" && attributes["exitCode"] != "0"',
defaultOn: true,
},
{
key: "unhealthy",
kind: "event",
label: t("cloud.welcome.signals.unhealthy"),
description: t("cloud.welcome.signals.unhealthy-desc"),
ruleName: "Container became unhealthy",
expression: 'name == "health_status" && attributes["healthStatus"] == "unhealthy"',
defaultOn: true,
},
{
key: "oom",
kind: "event",
label: t("cloud.welcome.signals.oom"),
description: t("cloud.welcome.signals.oom-desc"),
ruleName: "Container killed (OOM)",
expression: 'name == "oom"',
defaultOn: true,
},
{
key: "restart",
kind: "event",
label: t("cloud.welcome.signals.restart"),
description: t("cloud.welcome.signals.restart-desc"),
ruleName: "Container restarted",
expression: 'name == "restart"',
defaultOn: false,
},
{
key: "disk",
kind: "metric",
label: t("cloud.welcome.signals.disk"),
description: t("cloud.welcome.signals.disk-desc"),
ruleName: "Volume running out of space",
expression: "any(mounts, .usedPercent >= 85)",
defaultOn: true,
},
]);
const selectedSignals = ref<SignalKey[]>([]);
function toggleOption(value: string) {
const next = new Set(selectedOptions.value);
if (next.has(value)) {
next.delete(value);
} else {
next.add(value);
}
selectedOptions.value = next;
}
async function postFeedback(skipped: boolean) {
try {
await fetch(withBase("/api/cloud/feedback"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source: "welcome_modal",
intent: skipped ? undefined : intent.value || undefined,
selectedOptions: skipped ? undefined : Array.from(selectedOptions.value),
skipped,
}),
});
} catch {
// Feedback failure should not block the user
}
}
const onNotificationsPage = computed(() => route.path === "/notifications");
async function submitFeedback() {
submitting.value = true;
feedbackSent = true;
await postFeedback(false);
submitting.value = false;
if (onNotificationsPage.value) {
await createDefaultAlerts();
} else {
step.value = "step2";
}
}
async function skipFeedback() {
submitting.value = true;
feedbackSent = true;
await postFeedback(true);
submitting.value = false;
if (onNotificationsPage.value) {
// User explicitly skipped — don't silently create defaults on their behalf.
// They're already on the notifications page; just dismiss.
close();
} else {
step.value = "step2";
}
}
async function createDefaultAlerts() {
if (creating.value) return;
creating.value = true;
const chosen = signals.value.filter((s) => selectedSignals.value.includes(s.key));
try {
const dispatchersRes = await fetch(withBase("/api/notifications/dispatchers"));
if (!dispatchersRes.ok) throw new Error("dispatchers fetch failed");
const dispatchers: Array<{ id: number; type: string }> = await dispatchersRes.json();
const cloud = dispatchers.find((d) => d.type === "cloud");
if (!cloud) throw new Error("cloud dispatcher missing");
// Fire rule POSTs in parallel. Partial failure is not cleaned up — if one
// rejects, the earlier ones are already saved and the user lands on the
// fallback toast path. Acceptable for a welcome modal; the user can edit
// or delete rules from /notifications.
await Promise.all(
chosen.map((signal) =>
fetch(withBase("/api/notifications/rules"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: signal.ruleName,
enabled: true,
dispatcherId: cloud.id,
logExpression: "",
containerExpression: "true",
eventExpression: signal.kind === "event" ? signal.expression : "",
metricExpression: signal.kind === "metric" ? signal.expression : "",
// Metric alerts: don't re-fire more than once an hour per container,
// and require the threshold to hold for the default sample window.
cooldown: signal.kind === "metric" ? 3600 : 0,
sampleWindow: signal.kind === "metric" ? 60 : 0,
}),
}).then((res) => {
if (!res.ok) throw new Error("rule POST failed");
}),
),
);
close();
router.push({ path: "/notifications" });
} catch {
close();
showToast(
{
type: "warning",
message: t("notifications.default-alert-failed"),
},
{ expire: 6000 },
);
router.push({ path: "/notifications", query: { action: "create-alert" } });
} finally {
creating.value = false;
}
}
function open() {
step.value = "step1";
intent.value = "";
selectedOptions.value = new Set();
selectedSignals.value = signals.value.filter((s) => s.defaultOn).map((s) => s.key);
feedbackSent = false;
modal.value?.showModal();
}
function close() {
modal.value?.close();
}
function onClose() {
if (step.value === "step1" && !feedbackSent) {
feedbackSent = true;
postFeedback(true);
}
}
defineExpose({ open });
</script>