mirror of
https://github.com/koichixD/uptime-kuma.git
synced 2026-09-04 17:23:21 +00:00
feat: implement incident history (#6469)
Co-authored-by: Frank Elsinga <frank@elsinga.de> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Frank Elsinga
autofix-ci[bot]
parent
b638ae48ef
commit
9169a647cb
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<div
|
||||
class="shadow-box alert mb-4 p-4 incident"
|
||||
role="alert"
|
||||
:class="'bg-' + modelValue.style"
|
||||
data-testid="incident-edit"
|
||||
>
|
||||
<strong>{{ $t("Title") }}:</strong>
|
||||
<Editable
|
||||
:model-value="modelValue.title"
|
||||
tag="h4"
|
||||
:contenteditable="true"
|
||||
:noNL="true"
|
||||
class="alert-heading"
|
||||
data-testid="incident-title"
|
||||
@update:model-value="updateField('title', $event)"
|
||||
/>
|
||||
|
||||
<strong>{{ $t("Content") }}:</strong>
|
||||
<Editable
|
||||
:model-value="modelValue.content"
|
||||
tag="div"
|
||||
:contenteditable="true"
|
||||
class="content"
|
||||
data-testid="incident-content-editable"
|
||||
@update:model-value="updateField('content', $event)"
|
||||
/>
|
||||
<div class="form-text">
|
||||
{{ $t("markdownSupported") }}
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-light me-2" data-testid="post-incident-button" @click="$emit('post')">
|
||||
<font-awesome-icon icon="bullhorn" />
|
||||
{{ $t("Post") }}
|
||||
</button>
|
||||
|
||||
<button class="btn btn-light me-2" @click="$emit('cancel')">
|
||||
<font-awesome-icon icon="times" />
|
||||
{{ $t("Cancel") }}
|
||||
</button>
|
||||
|
||||
<div class="dropdown d-inline-block me-2">
|
||||
<button
|
||||
id="dropdownMenuButton1"
|
||||
class="btn btn-secondary dropdown-toggle"
|
||||
type="button"
|
||||
data-bs-toggle="dropdown"
|
||||
aria-expanded="false"
|
||||
>
|
||||
{{ $t("Style") }}: {{ $t(modelValue.style) }}
|
||||
</button>
|
||||
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton1">
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'info')">
|
||||
{{ $t("info") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'warning')">
|
||||
{{ $t("warning") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'danger')">
|
||||
{{ $t("danger") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'primary')">
|
||||
{{ $t("primary") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'light')">
|
||||
{{ $t("light") }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="#" @click.prevent="updateField('style', 'dark')">
|
||||
{{ $t("dark") }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "IncidentEditForm",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue", "post", "cancel"],
|
||||
methods: {
|
||||
updateField(field, value) {
|
||||
this.$emit("update:modelValue", {
|
||||
...this.modelValue,
|
||||
[field]: value,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.incident {
|
||||
.content {
|
||||
&[contenteditable="true"] {
|
||||
min-height: 60px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,154 @@
|
||||
<template>
|
||||
<div class="incident-group" data-testid="incident-group">
|
||||
<div v-if="loading && incidents.length === 0" class="text-center py-4">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">{{ $t("Loading...") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="incidents.length === 0" class="text-center py-4 text-muted">
|
||||
{{ $t("No incidents recorded") }}
|
||||
</div>
|
||||
|
||||
<div v-else class="incident-list">
|
||||
<div
|
||||
v-for="incident in incidents"
|
||||
:key="incident.id"
|
||||
class="incident-item"
|
||||
:class="{ resolved: !incident.active }"
|
||||
>
|
||||
<div class="incident-style-indicator" :class="`bg-${incident.style}`"></div>
|
||||
<div class="incident-body">
|
||||
<div class="incident-header d-flex justify-content-between align-items-start">
|
||||
<h5 class="incident-title mb-0">{{ incident.title }}</h5>
|
||||
<div v-if="editMode" class="incident-actions">
|
||||
<button
|
||||
v-if="incident.active"
|
||||
class="btn btn-success btn-sm me-1"
|
||||
:title="$t('Resolve')"
|
||||
@click="$emit('resolve-incident', incident)"
|
||||
>
|
||||
<font-awesome-icon icon="check" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary btn-sm me-1"
|
||||
:title="$t('Edit')"
|
||||
@click="$emit('edit-incident', incident)"
|
||||
>
|
||||
<font-awesome-icon icon="edit" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-danger btn-sm"
|
||||
:title="$t('Delete')"
|
||||
@click="$emit('delete-incident', incident)"
|
||||
>
|
||||
<font-awesome-icon icon="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html-->
|
||||
<div class="incident-content mt-1" v-html="getIncidentHTML(incident.content)"></div>
|
||||
<div class="incident-meta text-muted small mt-2">
|
||||
<div>{{ $t("createdAt", { date: datetime(incident.createdDate) }) }}</div>
|
||||
<div v-if="incident.lastUpdatedDate">
|
||||
{{ $t("lastUpdatedAt", { date: datetime(incident.lastUpdatedDate) }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { marked } from "marked";
|
||||
import DOMPurify from "dompurify";
|
||||
import datetimeMixin from "../mixins/datetime";
|
||||
|
||||
export default {
|
||||
name: "IncidentHistory",
|
||||
mixins: [datetimeMixin],
|
||||
props: {
|
||||
incidents: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
editMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["edit-incident", "delete-incident", "resolve-incident"],
|
||||
methods: {
|
||||
/**
|
||||
* Get sanitized HTML for incident content
|
||||
* @param {string} content - Markdown content
|
||||
* @returns {string} Sanitized HTML
|
||||
*/
|
||||
getIncidentHTML(content) {
|
||||
if (content != null) {
|
||||
return DOMPurify.sanitize(marked(content));
|
||||
}
|
||||
return "";
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../assets/vars.scss";
|
||||
|
||||
.incident-group {
|
||||
padding: 10px;
|
||||
|
||||
.incident-list {
|
||||
.incident-item {
|
||||
display: flex;
|
||||
padding: 13px 15px 10px 15px;
|
||||
border-radius: 10px;
|
||||
transition: all ease-in-out 0.15s;
|
||||
|
||||
&:hover {
|
||||
background-color: $highlight-white;
|
||||
}
|
||||
|
||||
&.resolved {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.incident-style-indicator {
|
||||
width: 6px;
|
||||
min-height: 100%;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.incident-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.incident-meta {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.incident-group {
|
||||
.incident-list {
|
||||
.incident-item {
|
||||
&:hover {
|
||||
background-color: $dark-bg2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div ref="modal" class="modal fade" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
{{ $t("Edit Incident") }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="submit">
|
||||
<div class="mb-3">
|
||||
<label for="incident-title" class="form-label">{{ $t("Title") }}</label>
|
||||
<input
|
||||
id="incident-title"
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="$t('Incident title')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="incident-content" class="form-label">{{ $t("Content") }}</label>
|
||||
<textarea
|
||||
id="incident-content"
|
||||
v-model="form.content"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
:placeholder="$t('Incident description')"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="incident-style" class="form-label">{{ $t("Style") }}</label>
|
||||
<select id="incident-style" v-model="form.style" class="form-select">
|
||||
<option value="info">{{ $t("info") }}</option>
|
||||
<option value="warning">
|
||||
{{ $t("warning") }}
|
||||
</option>
|
||||
<option value="danger">
|
||||
{{ $t("danger") }}
|
||||
</option>
|
||||
<option value="primary">
|
||||
{{ $t("primary") }}
|
||||
</option>
|
||||
<option value="light">{{ $t("light") }}</option>
|
||||
<option value="dark">{{ $t("dark") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3 form-check">
|
||||
<input id="incident-pin" v-model="form.pin" type="checkbox" class="form-check-input" />
|
||||
<label for="incident-pin" class="form-check-label">
|
||||
{{ $t("Pin this incident") }}
|
||||
</label>
|
||||
<div class="form-text">
|
||||
{{ $t("Pinned incidents are shown prominently on the status page") }}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
{{ $t("Cancel") }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" :disabled="processing" @click="submit">
|
||||
<span v-if="processing" class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
{{ $t("Save") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Confirm
|
||||
ref="confirmDelete"
|
||||
btn-style="btn-danger"
|
||||
:yes-text="$t('Yes')"
|
||||
:no-text="$t('No')"
|
||||
@yes="confirmDeleteIncident"
|
||||
>
|
||||
{{ $t("deleteIncidentMsg") }}
|
||||
</Confirm>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Modal } from "bootstrap";
|
||||
import Confirm from "./Confirm.vue";
|
||||
|
||||
export default {
|
||||
name: "IncidentManageModal",
|
||||
components: {
|
||||
Confirm,
|
||||
},
|
||||
props: {
|
||||
slug: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["incident-updated"],
|
||||
data() {
|
||||
return {
|
||||
modal: null,
|
||||
processing: false,
|
||||
incidentId: null,
|
||||
pendingDeleteIncident: null,
|
||||
form: {
|
||||
title: "",
|
||||
content: "",
|
||||
style: "warning",
|
||||
pin: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.modal = new Modal(this.$refs.modal);
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Show the modal for editing an existing incident
|
||||
* @param {object} incident - The incident to edit
|
||||
* @returns {void}
|
||||
*/
|
||||
showEdit(incident) {
|
||||
this.incidentId = incident.id;
|
||||
this.form = {
|
||||
title: incident.title,
|
||||
content: incident.content,
|
||||
style: incident.style || "warning",
|
||||
pin: !!incident.pin,
|
||||
};
|
||||
this.modal.show();
|
||||
},
|
||||
|
||||
/**
|
||||
* Show delete confirmation dialog
|
||||
* @param {object} incident - The incident to delete
|
||||
* @returns {void}
|
||||
*/
|
||||
showDelete(incident) {
|
||||
this.pendingDeleteIncident = incident;
|
||||
this.$refs.confirmDelete.show();
|
||||
},
|
||||
|
||||
/**
|
||||
* Submit the form to edit the incident
|
||||
* @returns {void}
|
||||
*/
|
||||
submit() {
|
||||
if (!this.form.title || this.form.title.trim() === "") {
|
||||
this.$root.toastError(this.$t("Please input title"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.form.content || this.form.content.trim() === "") {
|
||||
this.$root.toastError(this.$t("Please input content"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
|
||||
this.$root.getSocket().emit("editIncident", this.slug, this.incidentId, this.form, (res) => {
|
||||
this.processing = false;
|
||||
this.$root.toastRes(res);
|
||||
if (res.ok) {
|
||||
this.modal.hide();
|
||||
this.$emit("incident-updated");
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Confirm and delete the incident
|
||||
* @returns {void}
|
||||
*/
|
||||
confirmDeleteIncident() {
|
||||
if (!this.pendingDeleteIncident) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$root.getSocket().emit("deleteIncident", this.slug, this.pendingDeleteIncident.id, (res) => {
|
||||
this.$root.toastRes(res);
|
||||
if (res.ok) {
|
||||
this.$emit("incident-updated");
|
||||
}
|
||||
this.pendingDeleteIncident = null;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-body {
|
||||
.form-text {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user