Implement dashboard content

Signed-off-by: Knut Ahlers <knut@ahlers.me>
This commit is contained in:
Knut Ahlers 2024-06-16 16:23:46 +02:00
parent 9004ced776
commit 2df250aad9
Signed by: luzifer
SSH key fingerprint: SHA256:/xtE5lCgiRDQr8SLxHMS92ZBlACmATUmF1crK16Ks4E
16 changed files with 444 additions and 10 deletions

View file

@ -11,6 +11,7 @@ const buildOpts = {
entryPoints: ['src/main.ts'],
legalComments: 'none',
loader: {
'.md': 'text',
'.ttf': 'file',
'.woff2': 'file',
},

13
package-lock.json generated
View file

@ -8,6 +8,7 @@
"@fortawesome/fontawesome-free": "^6.5.2",
"bootstrap": "^5.3.3",
"codejar": "^4.2.0",
"marked": "^13.0.0",
"mitt": "^3.0.1",
"prismjs": "^1.29.0",
"vue": "^3.4.28",
@ -2687,6 +2688,18 @@
"integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"license": "MIT"
},
"node_modules/marked": {
"version": "13.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-13.0.3.tgz",
"integrity": "sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",

View file

@ -3,6 +3,7 @@
"@fortawesome/fontawesome-free": "^6.5.2",
"bootstrap": "^5.3.3",
"codejar": "^4.2.0",
"marked": "^13.0.0",
"mitt": "^3.0.1",
"prismjs": "^1.29.0",
"vue": "^3.4.28",
@ -22,4 +23,4 @@
"eslint-plugin-vue": "^9.26.0",
"typescript": "^5.4.5"
}
}
}

View file

@ -1,5 +1,5 @@
<template>
<nav class="navbar navbar-expand-lg bg-body-tertiary user-select-none">
<nav class="navbar fixed-top navbar-expand-lg bg-body-tertiary user-select-none">
<div class="container-fluid">
<span class="navbar-brand user-select-none">
<i class="fas fa-robot fa-fw me-1 text-info" />
@ -91,7 +91,7 @@ export default defineComponent({
})
</script>
<style>
<style scoped>
.nav-profile-image {
max-width: 24px;
}

View file

@ -20,7 +20,6 @@
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import { Toast } from 'bootstrap'

View file

@ -33,6 +33,7 @@ import Toaster from './_toaster.vue'
min-height: calc(100vh - 56px);
min-width: 1;
padding-left: 225px;
padding-top: 56px;
position: relative;
}

View file

@ -1,13 +1,54 @@
<template>
<div class="container">
<!---->
<div class="container my-3">
<div class="row justify-content-center">
<!-- Here Number Scheduled Events Panel -->
<div
v-for="component in statusComponents"
:key="component"
class="col col-2"
>
<component
:is="component"
/>
</div>
</div>
<div class="row mt-3">
<div class="col">
<!-- Here Bot-Logs: #44 -->
</div>
<div class="col">
<DashboardChangelog />
</div>
</div>
</div>
</template>
<script lang="ts">
import DashboardActiveRaffles from './dashboard/activeRaffles.vue'
import DashboardBotScopes from './dashboard/scopes.vue'
import DashboardChangelog from './dashboard/changelog.vue'
import DashboardHealthCheck from './dashboard/healthcheck.vue'
import { defineComponent } from 'vue'
export default defineComponent({
components: {
DashboardActiveRaffles,
DashboardBotScopes,
DashboardChangelog,
DashboardHealthCheck,
},
data() {
return {
statusComponents: [
'DashboardHealthCheck',
'DashboardBotScopes',
'DashboardActiveRaffles',
],
}
},
name: 'TwitchBotEditorDashboard',
})
</script>

View file

@ -0,0 +1,107 @@
<template>
<div
:class="cardClass"
@click="navigate"
>
<div class="card-body">
<div class="fs-6 text-center">
{{ header }}
</div>
<template v-if="loading">
<div class="fs-1 text-center">
<i class="fa-solid fa-circle-notch fa-spin" />
</div>
</template>
<template v-else>
<div :class="valueClass">
{{ value }}
</div>
</template>
<div
v-if="caption"
class="text-muted text-center"
>
<small>{{ caption }}</small>
</div>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, type PropType } from 'vue'
import { type RouteLocationRaw } from 'vue-router'
export default defineComponent({
computed: {
cardClass(): string {
const classList = ['card user-select-none']
if (this.clickRoute) {
classList.push('pointer-click')
}
return classList.join(' ')
},
valueClass(): string {
const classList = ['fs-1 text-center']
if (this.valueExtraClass) {
classList.push(this.valueExtraClass)
}
return classList.join(' ')
},
},
methods: {
navigate(): void {
if (!this.clickRoute) {
return
}
this.$router.push(this.clickRoute)
},
},
name: 'DashboardStatusPanel',
props: {
caption: {
default: null,
type: String,
},
clickRoute: {
default: null,
type: {} as PropType<RouteLocationRaw>,
},
header: {
required: true,
type: String,
},
loading: {
default: false,
type: Boolean,
},
value: {
required: true,
type: String,
},
valueExtraClass: {
default: null,
type: String,
},
},
})
</script>
<style scoped>
.pointer-click {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,52 @@
<template>
<StatusPanel
:header="$t('dashboard.activeRaffles.header')"
:loading="loading"
:value="value"
:click-route="{name: 'rafflesList'}"
:caption="$t('dashboard.activeRaffles.caption')"
/>
</template>
<script lang="ts">
import BusEventTypes from '../../helpers/busevents'
import { defineComponent } from 'vue'
import StatusPanel from './_statuspanel.vue'
export default defineComponent({
components: { StatusPanel },
computed: {
value(): string {
return `${this.activeRaffles}`
},
},
data() {
return {
activeRaffles: 0,
loading: true,
}
},
methods: {
fetchRaffleCount(): void {
fetch('raffle/', this.$root?.fetchOpts)
.then((resp: Response) => this.$root?.parseResponseFromJSON(resp))
.then((data: any) => {
this.activeRaffles = data.filter((raffle: any) => raffle.status === 'active').length
this.loading = false
})
},
},
mounted() {
this.bus.on(BusEventTypes.RaffleChanged, () => {
this.fetchRaffleCount()
})
this.fetchRaffleCount()
},
name: 'DashboardBotRaffles',
})
</script>

View file

@ -0,0 +1,59 @@
<template>
<div class="card user-select-none">
<div class="card-header">
{{ $t('dashboard.changelog.heading') }}
</div>
<div
class="card-body"
v-html="changelog"
/>
</div>
</template>
<script lang="ts">
// @ts-ignore - Has an esbuild loader to be loaded as text
import ChangeLog from '../../../History.md'
import { defineComponent } from 'vue'
import { parse as marked } from 'marked'
export default defineComponent({
computed: {
changelog(): string {
const latestVersions = (ChangeLog as string)
.split('\n')
.filter((line: string) => line) // Remove empty lines to fix broken output
.join('\n')
.split('#')
.slice(0, 3) // Last 2 versions (first element is empty)
.join('###')
const parts = [
latestVersions,
'---',
this.$t('dashboard.changelog.fullLink'),
]
return marked(parts.join('\n'), {
async: false,
breaks: false,
extensions: null,
gfm: true,
hooks: null,
pedantic: false,
silent: true,
tokenizer: null,
walkTokens: null,
}) as string
},
},
name: 'DashboardChangelog',
})
</script>
<style scoped>
.card-body {
user-select: text !important;
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<StatusPanel
:header="$t('dashboard.healthCheck.header')"
:loading="!status.checks"
:value="value"
:value-extra-class="valueClass"
:caption="$t('dashboard.healthCheck.caption')"
/>
</template>
<script lang="ts">
import { defineComponent } from 'vue'
import StatusPanel from './_statuspanel.vue'
export default defineComponent({
components: { StatusPanel },
computed: {
nChecks(): Number {
return this.status?.checks?.length || 0
},
nSuccess(): Number {
return this.status?.checks?.filter((check: any) => check?.success).length || 0
},
value(): string {
return `${this.nSuccess} / ${this.nChecks}`
},
valueClass(): string {
return this.nSuccess === this.nChecks ? 'text-success' : 'text-danger'
},
},
data() {
return {
status: {} as any,
}
},
methods: {
fetchStatus(): void {
fetch('status/status.json?fail-status=200')
.then((resp: Response) => resp.json())
.then((data: any) => {
this.status = data
})
},
},
mounted() {
this.$root?.registerTicker('dashboardHealthCheck', () => this.fetchStatus(), 30000)
this.fetchStatus()
},
name: 'DashboardHealthCheck',
unmounted() {
this.$root?.unregisterTicker('dashboardHealthCheck')
},
})
</script>

View file

@ -0,0 +1,62 @@
<template>
<StatusPanel
:header="$t('dashboard.botScopes.header')"
:loading="loading"
:value="value"
:value-extra-class="valueClass"
:caption="$t('dashboard.botScopes.caption')"
/>
</template>
<script lang="ts">
import BusEventTypes from '../../helpers/busevents'
import { defineComponent } from 'vue'
import StatusPanel from './_statuspanel.vue'
export default defineComponent({
components: { StatusPanel },
computed: {
nMissing(): number {
return this.$root?.vars?.DefaultBotScopes
?.filter((scope: string) => !this.botScopes.includes(scope))
.length || 0
},
value(): string {
return `${this.nMissing}`
},
valueClass(): string {
return this.nMissing === 0 ? 'text-success' : 'text-warning'
},
},
data() {
return {
botScopes: [] as string[],
loading: true,
}
},
methods: {
fetchGeneralConfig(): void {
fetch('config-editor/general', this.$root?.fetchOpts)
.then((resp: Response) => this.$root?.parseResponseFromJSON(resp))
.then((data: any) => {
this.botScopes = data.channel_scopes[data.bot_name] || []
this.loading = false
})
},
},
mounted() {
this.bus.on(BusEventTypes.ConfigReload, () => {
this.fetchGeneralConfig()
})
this.fetchGeneralConfig()
},
name: 'DashboardBotScopes',
})
</script>

12
src/global.d.ts vendored
View file

@ -1,7 +1,10 @@
/* eslint-disable no-unused-vars */
/* global RequestInit, TimerHandler */
import { Emitter, EventType } from 'mitt'
type CheckAccessFunction = (resp: Response) => Response
type EditorVars = {
DefaultBotScopes: string[]
IRCBadges: string[]
@ -11,6 +14,10 @@ type EditorVars = {
Version: string
}
type ParseResponseFunction = (resp: Response) => Promise<any>
type TickerRegisterFunction = (id: string, func: TimerHandler, intervalMs: number) => void
type TickerUnregisterFunction = (id: string) => void
type UserInfo = {
display_name: string
id: string
@ -23,6 +30,11 @@ declare module '@vue/runtime-core' {
bus: Emitter<Record<EventType, unknown>>
// On the $root
check403: CheckAccessFunction
fetchOpts: RequestInit
parseResponseFromJSON: ParseResponseFunction
registerTicker: TickerRegisterFunction
unregisterTicker: TickerUnregisterFunction
userInfo: UserInfo | null
vars: EditorVars | null
}

View file

@ -7,6 +7,8 @@ enum BusEventTypes {
FetchError = 'fetchError',
LoadingData = 'loadingData',
LoginProcessing = 'loginProcessing',
RaffleChanged = 'raffleChanged',
RaffleEntryChanged = 'raffleEntryChanged',
Toast = 'toast',
}

View file

@ -1,4 +1,22 @@
{
"dashboard": {
"activeRaffles": {
"caption": "Active",
"header": "Raffles"
},
"botScopes": {
"caption": "Missing Scopes",
"header": "Bot-Scopes"
},
"changelog": {
"fullLink": "See full changelog in [History.md](https://github.com/Luzifer/twitch-bot/blob/master/History.md) on Github",
"heading": "Changelog"
},
"healthCheck": {
"caption": "Checks OK",
"header": "Bot-Health"
}
},
"menu": {
"headers": {
"chatInteraction": "Chat Interaction",

View file

@ -91,8 +91,7 @@ const app = createApp({
window.history.replaceState(null, '', window.location.href.split('#')[0])
fetch(`config-editor/user?user=${this.tokenUser}`, this.$root.fetchOpts)
.then((resp: Response) => this.$root.check403(resp))
.then((resp: Response) => resp.json())
.then((resp: Response) => this.$root.parseResponseFromJSON(resp))
.then((data: any) => {
this.userInfo = data
})
@ -105,6 +104,11 @@ const app = createApp({
this.tokenUser = ''
},
parseResponseFromJSON(resp: Response): Promise<any> {
this.check403(resp)
return resp.json()
},
registerTicker(id: string, func: TimerHandler, intervalMs: number): void {
this.unregisterTicker(id)
this.tickers[id] = window.setInterval(func, intervalMs)
@ -116,8 +120,7 @@ const app = createApp({
}
fetch('config-editor/refreshToken', this.$root.fetchOpts)
.then((resp: Response) => this.$root.check403(resp))
.then((resp: Response) => resp.json())
.then((resp: Response) => this.$root.parseResponseFromJSON(resp))
.then((data: any) => this.login(data.token, new Date(data.expiresAt), data.user))
},