Added rudimentary entity health overview.
This commit is contained in:
parent
37b40f1744
commit
32202d8ab5
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue