Added rudimentary entity health overview.

This commit is contained in:
DebaucheryLibrarian 2025-09-15 05:07:02 +02:00
parent 37b40f1744
commit 32202d8ab5
6 changed files with 248 additions and 9 deletions

View File

@ -17,6 +17,14 @@
:class="{ active: pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'actors' }" :class="{ active: pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'actors' }"
>Actor Revisions</a> >Actor Revisions</a>
</li> </li>
<li class="nav-item">
<a
href="/admin/entities"
class="nav-link nolink"
:class="{ active: pageContext.routeParams.section === 'entities' }"
>Entity Health</a>
</li>
</ul> </ul>
</nav> </nav>

View File

@ -16,6 +16,13 @@
class="link" class="link"
>Actor Revisions</a> >Actor Revisions</a>
</li> </li>
<li>
<a
href="/admin/entities"
class="link"
>Entity Health</a>
</li>
</ul> </ul>
</Admin> </Admin>
</template> </template>

View File

@ -0,0 +1,157 @@
<template>
<Admin class="page">
<div class="header">
<div class="params">
<label>
Alert: <input
v-model="alertThreshold"
type="number"
placeholder="Alert threshold"
class="input"
> months
</label>
<label>
Dead: <input
v-model="deadThreshold"
type="number"
placeholder="Alert threshold"
class="input"
> months
</label>
</div>
<span class="attention">{{ alertEntities.length }} entities might require your attention</span>
</div>
<table class="table">
<thead>
<tr>
<th class="table-header">Entity</th>
<th
class="table-header noselect"
@click="sort('releases')"
>Releases</th>
<th
class="table-header noselect"
@click="sort('latest')"
>Latest release</th>
</tr>
</thead>
<tbody>
<tr
v-for="entity in alertEntities"
:key="`entity-${entity.id}`"
>
<td class="table-cell table-name ellipsis">{{ entity.name }}</td>
<td class="table-cell table-total">{{ entity.totalReleases }}</td>
<td
class="table-cell table-date"
:class="{ alert: entity.latestReleaseDate && entity.latestReleaseDate < alertDate }"
>{{ entity.latestReleaseDate && format(entity.latestReleaseDate, 'yyyy-MM-dd hh:mm') }}</td>
</tr>
</tbody>
</table>
</Admin>
</template>
<script setup>
import {
ref,
computed,
watch,
inject,
} from 'vue';
import { format, subMonths } from 'date-fns';
import navigate from '#/src/navigate.js';
import Admin from '#/components/admin/admin.vue';
const {
pageProps,
urlParsed,
meta,
} = inject('pageContext');
const { entities } = pageProps;
const alertThreshold = ref(Number(urlParsed.search.alert) || 3);
const deadThreshold = ref(Number(urlParsed.search.dead) || 36);
const order = urlParsed.search.order || 'desc';
const alertDate = computed(() => subMonths(meta.now, alertThreshold.value));
const deadDate = computed(() => subMonths(meta.now, deadThreshold.value));
const alertEntities = computed(() => entities.filter((entity) => entity.latestReleaseDate > deadDate.value && entity.latestReleaseDate < alertDate.value));
function sort(sorting) {
navigate('/admin/entities', {
sort: sorting,
order: order === 'desc' ? 'asc' : 'desc',
alert: alertThreshold.value,
dead: deadThreshold.value,
}, {
redirect: true,
});
}
watch([alertThreshold, deadThreshold], () => {
navigate('/admin/entities', {
...urlParsed.search,
alert: alertThreshold.value,
dead: deadThreshold.value,
}, {
redirect: false,
});
});
</script>
<style scoped>
.page {
flex-grow: 1;
}
.header {
display: flex;
align-items: center;
margin-bottom: 1rem;
}
.params {
display: flex;
align-items: center;
gap: 2rem;
.input {
width: 5rem;
}
}
.attention {
margin-left: 2rem;
color: var(--warn);
font-weight: bold;
}
.table-header {
text-align: left;
}
.table-name {
width: 10rem;
}
.table-total {
width: 6rem;
}
.alert {
color: var(--warn);
font-weight: bold;
}
</style>

View File

@ -0,0 +1,27 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
import { fetchEntityHealths } from '#/src/entities.js';
export async function onBeforeRender(pageContext) {
if (!pageContext.user || pageContext.user.role === 'user') {
throw render(404);
}
const {
entities,
} = await fetchEntityHealths({
sort: pageContext.urlParsed.search.sort || 'releases',
order: pageContext.urlParsed.search.order || 'desc',
}, pageContext.user);
return {
pageContext: {
title: pageContext.routeParams.section,
pageProps: {
entities,
},
routeParams: {
section: 'entities',
},
},
};
}

View File

@ -61,7 +61,7 @@
</template> </template>
<script setup> <script setup>
import { ref, inject, onMounted } from 'vue'; import { ref, inject } from 'vue';
import navigate from '#/src/navigate.js'; import navigate from '#/src/navigate.js';
@ -119,15 +119,11 @@ const sections = [
}, },
].filter(Boolean); ].filter(Boolean);
// const tags = Object.values(Object.fromEntries(networks.flatMap((entity) => entity.tags).map((tag) => [tag.id, tag])));
async function search() { async function search() {
navigate('/channels', { q: query.value || undefined }, { redirect: true }); navigate('/channels', { q: query.value || undefined }, { redirect: true });
} }
onMounted(() => {
window.addEventListener('load', (event) => {
console.log(event);
});
});
</script> </script>
<style scoped> <style scoped>

View File

@ -19,6 +19,11 @@ export function curateEntity(entity, context) {
isIndependent: entity.independent, isIndependent: entity.independent,
hasLogo: entity.has_logo, hasLogo: entity.has_logo,
parent: curateEntity(entity.parent, context), parent: curateEntity(entity.parent, context),
tags: context?.tags?.map((tag) => ({
id: tag.id,
name: tag.name,
slug: tag.slug,
})),
children: context?.children?.filter((child) => child.parent_id === entity.id).map((child) => curateEntity({ ...child, parent: entity }, { parent: entity })) || [], children: context?.children?.filter((child) => child.parent_id === entity.id).map((child) => curateEntity({ ...child, parent: entity }, { parent: entity })) || [],
affiliate: entity.affiliate ? { affiliate: entity.affiliate ? {
id: entity.affiliate.id, id: entity.affiliate.id,
@ -75,11 +80,18 @@ export async function fetchEntities(options = {}) {
.offset((options.page - 1) * options.limit) .offset((options.page - 1) * options.limit)
.limit(options.limit || 1000); .limit(options.limit || 1000);
return entities.map((entityEntry) => curateEntity(entityEntry)); const entitiesTags = await knex('entities_tags')
.select('entity_id', 'tags.*')
.leftJoin('tags', 'tags.id', 'tag_id')
.whereIn('entity_id', entities.map((entity) => entity.id));
return entities.map((entityEntry) => curateEntity(entityEntry, {
tags: entitiesTags.filter((tag) => tag.entity_id === entityEntry.id),
}));
} }
export async function fetchEntitiesById(entityIds, options = {}, reqUser) { export async function fetchEntitiesById(entityIds, options = {}, reqUser) {
const [entities, children, alerts] = await Promise.all([ const [entities, children, tags, alerts] = await Promise.all([
knex('entities') knex('entities')
.select( .select(
'entities.*', 'entities.*',
@ -99,6 +111,10 @@ export async function fetchEntitiesById(entityIds, options = {}, reqUser) {
.whereIn('entities.parent_id', entityIds) .whereIn('entities.parent_id', entityIds)
.whereNot('type', 'info') .whereNot('type', 'info')
.orderBy('slug') : [], .orderBy('slug') : [],
knex('entities_tags')
.select('entity_id', 'tags.*')
.leftJoin('tags', 'tags.id', 'tag_id')
.whereIn('entity_id', entityIds),
reqUser reqUser
? knex('alerts_users_entities') ? knex('alerts_users_entities')
.where('user_id', reqUser.id) .where('user_id', reqUser.id)
@ -125,6 +141,7 @@ export async function fetchEntitiesById(entityIds, options = {}, reqUser) {
return curateEntity(entity, { return curateEntity(entity, {
append: options.append, append: options.append,
children: children.filter((channel) => channel.parent_id === entity.id), children: children.filter((channel) => channel.parent_id === entity.id),
tags: tags.filter((tag) => tag.entity_id === entity.id),
alerts: alerts.filter((alert) => alert.entity_id === entity.id), alerts: alerts.filter((alert) => alert.entity_id === entity.id),
}); });
}).filter(Boolean); }).filter(Boolean);
@ -158,3 +175,30 @@ export async function cacheEntityIds() {
logger.info('Cached entity IDs by slug'); logger.info('Cached entity IDs by slug');
} }
const sortMap = {
releases: knex.raw('count(releases.id)'),
latest: 'latest_release_date',
};
export async function fetchEntityHealths(options) {
const entities = await knex('entities')
.select(
'entities.*',
knex.raw('max(effective_date) as latest_release_date'),
knex.raw('count(releases.id) as total_releases'),
)
.leftJoin('releases', 'releases.entity_id', 'entities.id')
.orderBy(sortMap[options.sort] || options.sort || sortMap.releases, options.order || 'desc')
.groupBy('entities.id');
const curatedEntities = entities.map((entity) => ({
...curateEntity(entity),
totalReleases: entity.total_releases,
latestReleaseDate: entity.latest_release_date,
}));
return {
entities: curatedEntities,
};
}