Skip to content

Commit 82136cd

Browse files
authored
feature: mesh stats / breakdown stats (#451)
1 parent 5ef0887 commit 82136cd

53 files changed

Lines changed: 17208 additions & 1653 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

components/TacticalPageHeader.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ const tacticalTabs = {
3636
></span>
3737

3838
<div
39-
class="flex flex-wrap items-end justify-between gap-6 max-sm:items-start"
39+
class="flex items-end justify-between gap-4 sm:gap-6 max-sm:items-center"
4040
>
4141
<div class="flex min-w-0 flex-col gap-[0.35rem]">
4242
<span
@@ -69,7 +69,7 @@ const tacticalTabs = {
6969

7070
<div
7171
v-if="$slots.actions"
72-
class="ml-auto flex shrink-0 items-center gap-3 max-sm:w-full max-sm:justify-start"
72+
class="ml-auto flex shrink-0 items-center gap-3"
7373
>
7474
<slot name="actions" :tabs="tacticalTabs"></slot>
7575
</div>

components/clips/RenderQueuePanel.vue

Lines changed: 38 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { computed, onBeforeUnmount, ref, shallowRef } from "vue";
2+
import { computed, onBeforeUnmount, ref, shallowRef, watch } from "vue";
33
import { useI18n } from "vue-i18n";
44
55
const { t } = useI18n();
@@ -30,6 +30,7 @@ import { generateMutation, generateSubscription } from "~/graphql/graphqlGen";
3030
import { clipRenderJobFields } from "~/graphql/clipRenderJob";
3131
import { Button } from "~/components/ui/button";
3232
import RenderQueueBatchRow from "~/components/clips/RenderQueueBatchRow.vue";
33+
import Pagination from "~/components/Pagination.vue";
3334
import BootSequence from "~/components/match/BootSequence.vue";
3435
import ServiceLogs from "~/components/ServiceLogs.vue";
3536
import SnapshotQuickView from "~/components/match/SnapshotQuickView.vue";
@@ -471,6 +472,34 @@ const canLoadMoreFinished = computed(
471472
() => finishedJobs.value.length >= finishedLimit.value,
472473
);
473474
475+
// Paginate the finished batches client-side over the loaded window.
476+
// When the user reaches the last page and the server still has older
477+
// rows, pull the next chunk (bumps finishedLimit) transparently.
478+
const FINISHED_BATCHES_PER_PAGE = 10;
479+
const finishedPage = ref(1);
480+
const finishedPageCount = computed(() =>
481+
Math.max(
482+
1,
483+
Math.ceil(recentlyDoneGroups.value.length / FINISHED_BATCHES_PER_PAGE),
484+
),
485+
);
486+
watch(finishedPageCount, (n) => {
487+
if (finishedPage.value > n) finishedPage.value = n;
488+
});
489+
const pagedRecentlyDoneGroups = computed(() => {
490+
const start = (finishedPage.value - 1) * FINISHED_BATCHES_PER_PAGE;
491+
return recentlyDoneGroups.value.slice(
492+
start,
493+
start + FINISHED_BATCHES_PER_PAGE,
494+
);
495+
});
496+
function goToFinishedPage(page: number) {
497+
finishedPage.value = page;
498+
if (page >= finishedPageCount.value && canLoadMoreFinished.value) {
499+
loadMoreFinished();
500+
}
501+
}
502+
474503
const inFlight = computed(() =>
475504
jobs.value.filter((j) => !TERMINAL.has(j.status)),
476505
);
@@ -1401,7 +1430,7 @@ const queueStatus = computed<{
14011430
</div>
14021431
<TransitionGroup tag="div" name="batch" class="relative space-y-1">
14031432
<RenderQueueBatchRow
1404-
v-for="g in recentlyDoneGroups"
1433+
v-for="g in pagedRecentlyDoneGroups"
14051434
:key="g.matchMapId"
14061435
:expanded="!!finishedExpanded[g.matchMapId]"
14071436
:title="
@@ -1681,16 +1710,13 @@ const queueStatus = computed<{
16811710
</template>
16821711
</RenderQueueBatchRow>
16831712
</TransitionGroup>
1684-
<button
1685-
v-if="canLoadMoreFinished"
1686-
type="button"
1687-
class="mt-2 flex w-full items-center justify-center gap-1.5 rounded-md border border-border/40 bg-card/30 px-3 py-2 font-mono text-[0.6rem] uppercase tracking-[0.14em] text-muted-foreground hover:text-foreground hover:border-border transition-colors"
1688-
:disabled="finishedLoading"
1689-
@click="loadMoreFinished"
1690-
>
1691-
<Spinner v-if="finishedLoading" class="h-3 w-3" />
1692-
{{ $t("clips.render_queue.load_more") }}
1693-
</button>
1713+
<Pagination
1714+
v-if="finishedPageCount > 1 || canLoadMoreFinished"
1715+
:page="finishedPage"
1716+
:per-page="FINISHED_BATCHES_PER_PAGE"
1717+
:total="recentlyDoneGroups.length"
1718+
@page="goToFinishedPage"
1719+
/>
16941720
</div>
16951721
</TooltipProvider>
16961722
</div>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<script lang="ts" setup>
2+
import { Boxes, TriangleRight } from "lucide-vue-next";
3+
import {
4+
Tooltip,
5+
TooltipContent,
6+
TooltipProvider,
7+
TooltipTrigger,
8+
} from "~/components/ui/tooltip";
9+
import {
10+
tokenizeFormula,
11+
tokenKey,
12+
MESH_TOKENS,
13+
} from "~/utilities/statFormula";
14+
15+
defineProps<{ text: string }>();
16+
</script>
17+
18+
<template>
19+
<TooltipProvider :delay-duration="100">
20+
<template v-for="(seg, i) in tokenizeFormula(text)" :key="i">
21+
<template v-if="seg.v">
22+
<Tooltip>
23+
<TooltipTrigger as-child>
24+
<abbr
25+
v-if="seg.v === 'θ'"
26+
class="cursor-help text-[hsl(var(--tac-amber))] no-underline"
27+
>
28+
<TriangleRight class="inline-block h-4 w-4 align-text-bottom" />
29+
</abbr>
30+
<abbr
31+
v-else
32+
class="cursor-help underline decoration-dotted decoration-muted-foreground/70 underline-offset-2 hover:decoration-foreground"
33+
>{{ seg.t }}</abbr
34+
>
35+
</TooltipTrigger>
36+
<TooltipContent>{{
37+
$t(`glossary.var_help.${tokenKey(seg.v)}`)
38+
}}</TooltipContent>
39+
</Tooltip>
40+
<Tooltip v-if="MESH_TOKENS.has(seg.v)">
41+
<TooltipTrigger as-child>
42+
<span
43+
class="ml-0.5 inline-flex cursor-help items-center align-middle text-[hsl(var(--tac-amber))]"
44+
>
45+
<Boxes class="h-3 w-3" />
46+
</span>
47+
</TooltipTrigger>
48+
<TooltipContent>{{ $t("glossary.mesh_help") }}</TooltipContent>
49+
</Tooltip>
50+
</template>
51+
<span v-else>{{ seg.t }}</span>
52+
</template>
53+
</TooltipProvider>
54+
</template>
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
<script lang="ts" setup>
2+
import { ref, onMounted } from "vue";
3+
import { useApolloClient } from "@vue/apollo-composable";
4+
import gql from "graphql-tag";
5+
import { Boxes, Check, X } from "lucide-vue-next";
6+
7+
type MapRow = { name: string };
8+
9+
const { client: apolloClient } = useApolloClient();
10+
const meshCdn = (useRuntimeConfig().public.mapMeshCdn as string) || "";
11+
12+
const loading = ref(true);
13+
const availableMaps = ref<string[]>([]);
14+
const missingMaps = ref<string[]>([]);
15+
16+
const CACHE_KEY = `mesh-coverage:v1:${meshCdn}`;
17+
18+
const MAPS_QUERY = gql`
19+
query MeshKnownMaps {
20+
maps(
21+
where: { workshop_map_id: { _is_null: true }, enabled: { _eq: true } }
22+
) {
23+
name
24+
}
25+
}
26+
`;
27+
28+
function meshName(name: string): string {
29+
let n = name.toLowerCase().trim();
30+
const slash = n.lastIndexOf("/");
31+
if (slash >= 0) {
32+
n = n.slice(slash + 1);
33+
}
34+
return n.replace(/_night$/, "");
35+
}
36+
37+
async function probe(name: string): Promise<boolean | null> {
38+
if (!meshCdn) {
39+
return null;
40+
}
41+
try {
42+
const res = await fetch(`${meshCdn}/${name}.tri`, { method: "HEAD" });
43+
if (res.status === 404) {
44+
return false;
45+
}
46+
if (res.ok) {
47+
return true;
48+
}
49+
return null;
50+
} catch {
51+
return null;
52+
}
53+
}
54+
55+
async function probeRetry(name: string): Promise<boolean | null> {
56+
for (let attempt = 0; attempt < 3; attempt++) {
57+
const result = await probe(name);
58+
if (result !== null) {
59+
return result;
60+
}
61+
}
62+
return null;
63+
}
64+
65+
function readCache(): { available: string[]; missing: string[] } | null {
66+
try {
67+
const raw = localStorage.getItem(CACHE_KEY);
68+
return raw ? JSON.parse(raw) : null;
69+
} catch {
70+
return null;
71+
}
72+
}
73+
74+
function writeCache(value: { available: string[]; missing: string[] }) {
75+
try {
76+
localStorage.setItem(CACHE_KEY, JSON.stringify(value));
77+
} catch {
78+
loading.value = false;
79+
}
80+
}
81+
82+
onMounted(async () => {
83+
const cached = readCache();
84+
if (cached) {
85+
availableMaps.value = cached.available;
86+
missingMaps.value = cached.missing;
87+
loading.value = false;
88+
return;
89+
}
90+
91+
try {
92+
const { data } = await apolloClient.query({
93+
query: MAPS_QUERY,
94+
fetchPolicy: "network-only",
95+
});
96+
const names = new Set<string>();
97+
for (const m of ((data as any)?.maps ?? []) as MapRow[]) {
98+
const key = meshName(m.name);
99+
if (key) {
100+
names.add(key);
101+
}
102+
}
103+
const unique = [...names].sort();
104+
const results = await Promise.all(
105+
unique.map(async (name) => ({ name, has: await probeRetry(name) })),
106+
);
107+
108+
availableMaps.value = results
109+
.filter((r) => r.has === true)
110+
.map((r) => r.name);
111+
missingMaps.value = results
112+
.filter((r) => r.has !== true)
113+
.map((r) => r.name);
114+
115+
if (!results.some((r) => r.has === null)) {
116+
writeCache({
117+
available: availableMaps.value,
118+
missing: missingMaps.value,
119+
});
120+
}
121+
} catch {
122+
availableMaps.value = [];
123+
missingMaps.value = [];
124+
} finally {
125+
loading.value = false;
126+
}
127+
});
128+
</script>
129+
130+
<template>
131+
<div
132+
v-if="loading || availableMaps.length || missingMaps.length"
133+
class="flex flex-col gap-3"
134+
>
135+
<span
136+
class="flex items-center gap-2 font-mono text-[0.7rem] font-bold tracking-[0.16em] uppercase text-[hsl(var(--tac-amber))]"
137+
>
138+
<Boxes class="h-4 w-4" />
139+
{{ $t("glossary.mesh_coverage_title") }}
140+
</span>
141+
<p class="text-xs leading-snug text-muted-foreground/80">
142+
{{ $t("glossary.mesh_coverage_note") }}
143+
</p>
144+
145+
<div v-if="loading" class="text-xs text-muted-foreground">
146+
{{ $t("glossary.mesh_coverage_loading") }}
147+
</div>
148+
149+
<div v-else class="grid gap-4 sm:grid-cols-2">
150+
<div class="flex flex-col gap-2">
151+
<span
152+
class="flex items-center gap-1.5 text-xs font-semibold text-success"
153+
>
154+
<Check class="h-3.5 w-3.5" />
155+
{{ $t("glossary.mesh_have", { count: availableMaps.length }) }}
156+
</span>
157+
<div class="flex flex-wrap gap-1.5">
158+
<span
159+
v-for="name of availableMaps"
160+
:key="name"
161+
class="rounded border border-success/40 bg-success/10 px-1.5 py-0.5 font-mono text-[0.7rem] text-foreground/90"
162+
>
163+
{{ name }}
164+
</span>
165+
</div>
166+
</div>
167+
168+
<div class="flex flex-col gap-2">
169+
<span
170+
class="flex items-center gap-1.5 text-xs font-semibold text-destructive"
171+
>
172+
<X class="h-3.5 w-3.5" />
173+
{{ $t("glossary.mesh_missing", { count: missingMaps.length }) }}
174+
</span>
175+
<div v-if="missingMaps.length" class="flex flex-wrap gap-1.5">
176+
<span
177+
v-for="name of missingMaps"
178+
:key="name"
179+
class="rounded border border-destructive/40 bg-destructive/10 px-1.5 py-0.5 font-mono text-[0.7rem] text-foreground/90"
180+
>
181+
{{ name }}
182+
</span>
183+
</div>
184+
<span v-else class="text-xs text-muted-foreground">
185+
{{ $t("glossary.mesh_missing_none") }}
186+
</span>
187+
</div>
188+
</div>
189+
</div>
190+
</template>

0 commit comments

Comments
 (0)