Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7724290921 | ||
|
|
3b1804b093 | ||
|
|
4cd8dbfc73 | ||
|
|
dc4bdf6efc | ||
|
|
a458bded09 |
+26
-7
@@ -38,6 +38,9 @@ Each `Get` record (one per served document):
|
|||||||
(`x-pagerite-preload` header): never counted as a view, crawler hit or
|
(`x-pagerite-preload` header): never counted as a view, crawler hit or
|
||||||
abuse — recorded only so a navigation later served from the in-memory page
|
abuse — recorded only so a navigation later served from the in-memory page
|
||||||
cache (which issues no GET at all) can be attributed this GET's status,
|
cache (which issues no GET at all) can be attributed this GET's status,
|
||||||
|
- `lang` — rendered content language of the served document (the resolved
|
||||||
|
language of a localized page), `""` for non-localized responses (404
|
||||||
|
probes, reserved paths),
|
||||||
- `client` — 6-byte blake3 hash referencing `Analytics.clients`.
|
- `client` — 6-byte blake3 hash referencing `Analytics.clients`.
|
||||||
|
|
||||||
304 revalidation responses return before recording and are not logged.
|
304 revalidation responses return before recording and are not logged.
|
||||||
@@ -51,7 +54,9 @@ Each `Msg` record (one per pagerite.js activity message over `/_ws`):
|
|||||||
- `to` — navigation target (validated at record time: internal slug path or
|
- `to` — navigation target (validated at record time: internal slug path or
|
||||||
external https URL; anything else is dropped — sanitation, not
|
external https URL; anything else is dropped — sanitation, not
|
||||||
classification),
|
classification),
|
||||||
- `read` — active seconds spent on `fr` since the previous report.
|
- `read` — active seconds spent on `fr` since the previous report,
|
||||||
|
- `lang` — rendered language reported by the client for the page the
|
||||||
|
activity happened on (the page's `<html lang>`; `""` from old clients).
|
||||||
|
|
||||||
Each `Client` record (shared by every event, keyed by hash):
|
Each `Client` record (shared by every event, keyed by hash):
|
||||||
|
|
||||||
@@ -104,7 +109,9 @@ The client (`pagerite.js`) keeps a WebSocket connection to `/_ws` for the
|
|||||||
whole browsing session and sends activity messages over it — JSON text
|
whole browsing session and sends activity messages over it — JSON text
|
||||||
frames matching the server's `Ping` msgspec struct with the fields `fr`
|
frames matching the server's `Ping` msgspec struct with the fields `fr`
|
||||||
(source path), `to` (navigation target), `read` (active seconds on `fr`
|
(source path), `to` (navigation target), `read` (active seconds on `fr`
|
||||||
since the last report) and `hide`; falsy fields are omitted. One channel
|
since the last report), `lang` (the rendered language of the page the
|
||||||
|
activity happened on — its `<html lang>`) and `hide`; falsy fields are
|
||||||
|
omitted. One channel
|
||||||
follows the session, so the activity of a visit stays tied together, and
|
follows the session, so the activity of a visit stays tied together, and
|
||||||
while the user is active the accumulated reading time is flushed every few
|
while the user is active the accumulated reading time is flushed every few
|
||||||
seconds: the times are incremental, so a disconnection simply leaves the
|
seconds: the times are incremental, so a disconnection simply leaves the
|
||||||
@@ -173,10 +180,16 @@ for misses.
|
|||||||
(`_SESSION_GAP`). A fresh page load with an already-open visit (second
|
(`_SESSION_GAP`). A fresh page load with an already-open visit (second
|
||||||
tab) extends it, logging a `(direct)` transition. The visit's trail holds
|
tab) extends it, logging a `(direct)` transition. The visit's trail holds
|
||||||
first-seen targets in order; `read` updates accumulate active seconds on
|
first-seen targets in order; `read` updates accumulate active seconds on
|
||||||
the trail item matching `fr`. Each trail item's HTTP status comes from
|
the trail item matching `fr` (preferring the item whose language matches
|
||||||
|
the report, so seconds after a language switch land on the new-language
|
||||||
|
step). Each trail item's HTTP status comes from
|
||||||
the client's latest GET for that path — preloads included, which is what
|
the client's latest GET for that path — preloads included, which is what
|
||||||
allows 404 pages to render red in the viewer even when the navigation
|
allows 404 pages to render red in the viewer even when the navigation
|
||||||
itself was served from the page cache. The entry page's referer and
|
itself was served from the page cache. Each trail item also carries the
|
||||||
|
rendered language: the client's report, for the entry page falling back
|
||||||
|
to its GET's rendered language (old clients don't send one); a page
|
||||||
|
re-visited in a different language becomes a distinct trail step instead
|
||||||
|
of merging into the existing item. The entry page's referer and
|
||||||
`utm_*` tags come from the GET that loaded it (within 10 s before the
|
`utm_*` tags come from the GET that loaded it (within 10 s before the
|
||||||
first message).
|
first message).
|
||||||
- **Crawler hits**: a document GET no activity message matched within
|
- **Crawler hits**: a document GET no activity message matched within
|
||||||
@@ -232,6 +245,10 @@ to tell misses from real pages at a glance.
|
|||||||
The `Display` payload contains the derived `visits`, `crawlers` and `abuse`
|
The `Display` payload contains the derived `visits`, `crawlers` and `abuse`
|
||||||
rows (structs `Visit`/`Nav`/`TrailItem`, `CrawlerHit`, `AbuseHit` — display
|
rows (structs `Visit`/`Nav`/`TrailItem`, `CrawlerHit`, `AbuseHit` — display
|
||||||
DTOs only, never persisted), the visible `clients`, the fetched `favicons`,
|
DTOs only, never persisted), the visible `clients`, the fetched `favicons`,
|
||||||
|
the site language context (`multilingual` — translation languages are
|
||||||
|
configured, so the viewer can suppress language UI on single-language
|
||||||
|
sites — and `primary_lang` — the front page's primary language, so the
|
||||||
|
viewer can skip the primary-language default case),
|
||||||
and the aggregates below.
|
and the aggregates below.
|
||||||
|
|
||||||
Each derived `Visit`:
|
Each derived `Visit`:
|
||||||
@@ -243,8 +260,9 @@ Each derived `Visit`:
|
|||||||
- `trail` — the entry page and everything seen afterwards, keyed by the
|
- `trail` — the entry page and everything seen afterwards, keyed by the
|
||||||
timestamp of first sight (insertion order = first-seen order). Each item
|
timestamp of first sight (insertion order = first-seen order). Each item
|
||||||
holds `to` (page path or external exit URL), the accumulated active
|
holds `to` (page path or external exit URL), the accumulated active
|
||||||
reading time in seconds (`read`) and the most recent HTTP status seen
|
reading time in seconds (`read`), the most recent HTTP status seen
|
||||||
for the target (`status`),
|
for the target (`status`) and the rendered language (`lang`; a page
|
||||||
|
seen in two languages within one visit gets one item per language),
|
||||||
- `navs` — every navigation (`fr`, `to`), keyed by its timestamp, repeats
|
- `navs` — every navigation (`fr`, `to`), keyed by its timestamp, repeats
|
||||||
included. The aggregates are computed from this log,
|
included. The aggregates are computed from this log,
|
||||||
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
- `utm` — `utm_*` query parameters from the landing URL, as a dict.
|
||||||
@@ -257,7 +275,8 @@ Each derived `CrawlerHit`:
|
|||||||
- `referer` — external https origin of the request, `""` for direct/none,
|
- `referer` — external https origin of the request, `""` for direct/none,
|
||||||
- `query` — raw query string of the request,
|
- `query` — raw query string of the request,
|
||||||
- `status` — HTTP status of the served response (200 for a real page, 404
|
- `status` — HTTP status of the served response (200 for a real page, 404
|
||||||
for a category placeholder or missing page).
|
for a category placeholder or missing page),
|
||||||
|
- `lang` — rendered content language of the served document (from the GET).
|
||||||
|
|
||||||
Each derived `AbuseHit`:
|
Each derived `AbuseHit`:
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
formatVisitRows,
|
formatVisitRows,
|
||||||
} from './analytics/format.js'
|
} from './analytics/format.js'
|
||||||
import TrailLink from './TrailLink.vue'
|
import TrailLink from './TrailLink.vue'
|
||||||
|
import RefererBadge from './RefererBadge.vue'
|
||||||
import VisitorCell from './VisitorCell.vue'
|
import VisitorCell from './VisitorCell.vue'
|
||||||
import TransitionGraph from './TransitionGraph.vue'
|
import TransitionGraph from './TransitionGraph.vue'
|
||||||
import VisitorCharts from './VisitorCharts.vue'
|
import VisitorCharts from './VisitorCharts.vue'
|
||||||
@@ -160,9 +161,15 @@ watch(range, (r) => {
|
|||||||
|
|
||||||
const clients = computed(() => data.value?.clients || {})
|
const clients = computed(() => data.value?.clients || {})
|
||||||
const favicons = computed(() => data.value?.favicons || {})
|
const favicons = computed(() => data.value?.favicons || {})
|
||||||
const visitRows = computed(() => formatVisitRows(visits.value, clients.value, pageTree.value, now.value))
|
// Site language context from the payload: drives the discreet rendered-
|
||||||
|
// language markers in the visit/crawler rows (multilingual sites only).
|
||||||
|
const site = computed(() => ({
|
||||||
|
multilingual: !!data.value?.multilingual,
|
||||||
|
primaryLang: data.value?.primary_lang || '',
|
||||||
|
}))
|
||||||
|
const visitRows = computed(() => formatVisitRows(visits.value, clients.value, pageTree.value, now.value, site.value))
|
||||||
const crawlers = computed(() => rangeData.value?.crawlers || [])
|
const crawlers = computed(() => rangeData.value?.crawlers || [])
|
||||||
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, clients.value, pageTree.value, now.value))
|
const crawlerRows = computed(() => formatCrawlerRows(crawlers.value, clients.value, pageTree.value, now.value, site.value))
|
||||||
const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], clients.value, pageTree.value, now.value))
|
const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], clients.value, pageTree.value, now.value))
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
@@ -209,9 +216,9 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(v, i) in visitRows" :key="i">
|
<tr v-for="(v, i) in visitRows" :key="i">
|
||||||
<td class="trail">
|
<td class="trail">
|
||||||
<TrailLink v-if="v.refererStep" :step="v.refererStep" :favicons="favicons" @close="$emit('close')" />
|
<RefererBadge v-if="v.refererBadge" :badge="v.refererBadge" :favicons="favicons" />
|
||||||
<span v-if="v.utm && v.utm !== '—'" class="utm-tag small muted" :title="v.utmTitle">{{ v.utm }}</span>
|
<span v-if="v.rowFlag" class="flag" v-html="v.rowFlag" :title="v.rowFlagTitle"></span>
|
||||||
<TrailLink v-for="(s, si) in v.trail" :key="si" :step="s" :favicons="favicons" @close="$emit('close')" />
|
<TrailLink v-for="(s, si) in v.trail" :key="si" :step="s" :favicons="favicons" :flags="s.langFlags" @close="$emit('close')" />
|
||||||
</td>
|
</td>
|
||||||
<VisitorCell
|
<VisitorCell
|
||||||
:ip="v.ip"
|
:ip="v.ip"
|
||||||
@@ -246,8 +253,9 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(c, i) in crawlerRows" :key="i">
|
<tr v-for="(c, i) in crawlerRows" :key="i">
|
||||||
<td class="trail">
|
<td class="trail">
|
||||||
<TrailLink v-if="c.refererStep" :step="c.refererStep" :favicons="favicons" @close="$emit('close')" />
|
<RefererBadge v-if="c.refererBadge" :badge="c.refererBadge" :favicons="favicons" />
|
||||||
<TrailLink v-for="(s, si) in c.pages" :key="si" :step="s" :count="s.count" @close="$emit('close')" />
|
<TrailLink v-for="(s, si) in c.pages" :key="si" :step="s" :count="s.count" @close="$emit('close')" />
|
||||||
|
<span v-for="(f, fi) in c.readFlags" :key="fi" class="flag" v-html="f.flag" :title="f.name"></span>
|
||||||
</td>
|
</td>
|
||||||
<VisitorCell
|
<VisitorCell
|
||||||
:ip="c.ip"
|
:ip="c.ip"
|
||||||
@@ -472,16 +480,30 @@ const abuseRows = computed(() => formatAbuseRows(rangeData.value?.abuse || [], c
|
|||||||
color: var(--error, #c00);
|
color: var(--error, #c00);
|
||||||
}
|
}
|
||||||
|
|
||||||
.visit-table .utm-tag {
|
/* The referer badge outgrows the 8rem trail-link cap (it carries the UTM
|
||||||
display: inline-block;
|
summary too); keep the inline-flex layout from the component. */
|
||||||
|
.visit-table .trail a.referer-badge {
|
||||||
|
display: inline-flex;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0.05rem 0.4rem;
|
}
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 0.25rem;
|
/* Same flag chip as the visitor cells (VisitorCell.vue); the flags here
|
||||||
white-space: nowrap;
|
mark the language the page was read in. */
|
||||||
|
.visit-table .flag {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 18px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
border: 1px solid var(--line);
|
||||||
vertical-align: bottom;
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visit-table .flag :deep(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.visit-table .clickable-list {
|
.visit-table .clickable-list {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup>
|
||||||
|
// Referer + UTM as one badge in the analytics visit/crawler tables: the
|
||||||
|
// referer's favicon flush on the left, its host as the link text, then the
|
||||||
|
// UTM summary smaller/muted inside the same badge. The whole badge links to
|
||||||
|
// the referer origin (external, new tab) and carries a single one-fact-
|
||||||
|
// per-line tooltip (badge.title: origin, then each utm pair) — no titles on
|
||||||
|
// the inner elements. Referers are external, so there is no close event.
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
badge: { type: Object, required: true },
|
||||||
|
favicons: { type: Object, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const favicon = computed(() => (props.badge.origin ? props.favicons?.[props.badge.origin] : null))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<a class="referer-badge"
|
||||||
|
:href="badge.href || undefined"
|
||||||
|
:title="badge.title"
|
||||||
|
:target="badge.href ? '_blank' : undefined"
|
||||||
|
:rel="badge.href ? 'noopener' : undefined">
|
||||||
|
<img v-if="favicon" class="badge-favicon" :src="favicon" alt="" />
|
||||||
|
<span v-if="badge.label">{{ badge.label }}</span>
|
||||||
|
<small v-if="badge.utm" class="small">{{ badge.utm }}</small>
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Browser-chrome chip on a fixed neutral palette (--badge-* in
|
||||||
|
pagerite.css, deliberately unthemed): black-on-transparent and
|
||||||
|
white-on-transparent favicons both stay legible on it, and the text is
|
||||||
|
always dark regardless of the theme's link/text colors. Colors go on the
|
||||||
|
inner elements, so the theme's a / a:hover color rules (which target the
|
||||||
|
anchor) cannot cascade in. */
|
||||||
|
.referer-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35em;
|
||||||
|
padding-right: 0.45em;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: var(--badge-bg);
|
||||||
|
color: var(--badge-text);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flush with the badge's top/left/bottom edges: a full-height square (the
|
||||||
|
badge has no padding on those sides), corners clipped by the badge's
|
||||||
|
overflow: hidden border-radius. */
|
||||||
|
.badge-favicon {
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
object-fit: cover;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referer-badge > span,
|
||||||
|
.referer-badge > small {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.referer-badge > span { color: var(--badge-text); }
|
||||||
|
.referer-badge > small { color: var(--badge-muted); }
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,7 @@ const props = defineProps({
|
|||||||
step: { type: Object, required: true },
|
step: { type: Object, required: true },
|
||||||
count: { type: Number, default: 0 },
|
count: { type: Number, default: 0 },
|
||||||
favicons: { type: Object, default: null },
|
favicons: { type: Object, default: null },
|
||||||
|
flags: { type: Array, default: () => [] },
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits(['close'])
|
defineEmits(['close'])
|
||||||
@@ -38,6 +39,7 @@ const title = computed(() => {
|
|||||||
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
|
<small v-if="count > 1" class="muted">{{ formatCount(count) }}×</small>
|
||||||
<img v-if="favicon" class="favicon" :src="favicon" alt="" />
|
<img v-if="favicon" class="favicon" :src="favicon" alt="" />
|
||||||
<span>{{ step.slug }}</span>
|
<span>{{ step.slug }}</span>
|
||||||
|
<span v-for="(f, fi) in flags" :key="fi" class="flag" v-html="f"></span>
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -48,4 +50,23 @@ const title = computed(() => {
|
|||||||
margin-right: 0.25em;
|
margin-right: 0.25em;
|
||||||
vertical-align: -0.1em;
|
vertical-align: -0.1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Same flag chip as the visitor cells (VisitorCell.vue). */
|
||||||
|
.flag {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 18px;
|
||||||
|
height: 12px;
|
||||||
|
margin-left: 0.25em;
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.2) inset;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag :deep(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Formatters and aggregators for summary sections: totals and the recent
|
* Formatters and aggregators for summary sections: totals and the recent
|
||||||
* visit trail.
|
* visit trail.
|
||||||
*/
|
*/
|
||||||
|
import { flagFor, langName } from '../langs.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IPv4 unchanged, IPv6 returns the /64 network prefix in compact form.
|
* IPv4 unchanged, IPv6 returns the /64 network prefix in compact form.
|
||||||
@@ -176,6 +177,32 @@ function stepOf(path, titles) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge data combining a visit's/crawler's external referer origin with the
|
||||||
|
* visit's UTM tags: the origin as the badge link/label (the favicon is
|
||||||
|
* looked up by origin in the component), the known UTM values as a short
|
||||||
|
* inline summary, and a one-fact-per-line tooltip — the full origin URL on
|
||||||
|
* the first line, then every ``utm_*=value`` pair. Null when there is no
|
||||||
|
* external referer and no UTM tag (a plain direct visit).
|
||||||
|
*/
|
||||||
|
function refererBadgeOf(referer, titles, utmTags = {}) {
|
||||||
|
const step = stepOf(referer, titles)
|
||||||
|
const external = step?.external ? step : null
|
||||||
|
const known = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
|
||||||
|
const utm = known.map((k) => utmTags[k]).filter(Boolean).join(' · ')
|
||||||
|
if (!external && !utm) return null
|
||||||
|
return {
|
||||||
|
href: external?.origin || '',
|
||||||
|
label: external?.slug || '',
|
||||||
|
origin: external?.origin || '',
|
||||||
|
utm,
|
||||||
|
title: [
|
||||||
|
...(external ? [external.origin] : []),
|
||||||
|
...Object.entries(utmTags).map(([k, value]) => `${k}=${value}`),
|
||||||
|
].join('\n'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Human-readable relative timestamp. Adapted from cista-storage: uses
|
* Human-readable relative timestamp. Adapted from cista-storage: uses
|
||||||
* ``Intl.RelativeTimeFormat`` for short intervals and a compact date for
|
* ``Intl.RelativeTimeFormat`` for short intervals and a compact date for
|
||||||
@@ -368,12 +395,13 @@ export function mainDomain(host, limit = 24) {
|
|||||||
/**
|
/**
|
||||||
* Group raw crawler hits by client hash and format each group as a row showing
|
* Group raw crawler hits by client hash and format each group as a row showing
|
||||||
* every internal page that crawler visited. Rows are sorted by most recent hit
|
* every internal page that crawler visited. Rows are sorted by most recent hit
|
||||||
* first, with total hits as a tie-breaker. The group's ``refererStep`` is the
|
* first, with total hits as a tie-breaker. The group's ``refererBadge`` is
|
||||||
* latest external referer seen for the crawler — spiders often advertise
|
* the latest external referer seen for the crawler — spiders often advertise
|
||||||
* their own site there — rendered with its favicon like visit referers.
|
* their own site there — rendered as a badge with its favicon like visit
|
||||||
|
* referers.
|
||||||
* ``clients`` maps client hashes to client records.
|
* ``clients`` maps client hashes to client records.
|
||||||
*/
|
*/
|
||||||
export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now()) {
|
export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now(), site = { multilingual: false, primaryLang: '' }) {
|
||||||
const titles = buildTitleMap(pageTree)
|
const titles = buildTitleMap(pageTree)
|
||||||
const groups = new Map()
|
const groups = new Map()
|
||||||
for (const c of crawlers || []) {
|
for (const c of crawlers || []) {
|
||||||
@@ -384,10 +412,12 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
|||||||
lastStart: 0,
|
lastStart: 0,
|
||||||
referer: '',
|
referer: '',
|
||||||
pages: new Map(),
|
pages: new Map(),
|
||||||
|
langs: new Set(),
|
||||||
}
|
}
|
||||||
const start = new Date(c.start).getTime()
|
const start = new Date(c.start).getTime()
|
||||||
if (start > g.lastStart) g.lastStart = start
|
if (start > g.lastStart) g.lastStart = start
|
||||||
if (c.referer) g.referer = c.referer
|
if (c.referer) g.referer = c.referer
|
||||||
|
if (c.lang) g.langs.add(c.lang)
|
||||||
if (c.entry?.startsWith('/')) {
|
if (c.entry?.startsWith('/')) {
|
||||||
const existing = g.pages.get(c.entry) || { count: 0, status: c.status || 200 }
|
const existing = g.pages.get(c.entry) || { count: 0, status: c.status || 200 }
|
||||||
existing.count += 1
|
existing.count += 1
|
||||||
@@ -408,14 +438,22 @@ export function formatCrawlerRows(crawlers, clients, pageTree, now = Date.now())
|
|||||||
const client = g.client || {}
|
const client = g.client || {}
|
||||||
const host = client.host || ''
|
const host = client.host || ''
|
||||||
const isHost = !!host
|
const isHost = !!host
|
||||||
|
// Rendered languages read, shown only when they say something the
|
||||||
|
// primary language alone would not (multilingual sites only).
|
||||||
|
const langs = [...g.langs].sort()
|
||||||
|
const showLangs =
|
||||||
|
site.multilingual && (langs.length > 1 || (langs[0] && langs[0] !== site.primaryLang))
|
||||||
return {
|
return {
|
||||||
lastSeen: formatWhen(g.lastStart, now),
|
lastSeen: formatWhen(g.lastStart, now),
|
||||||
lastSeenIso: formatWhenIso(g.lastStart),
|
lastSeenIso: formatWhenIso(g.lastStart),
|
||||||
lastSeenLocal: formatWhenLocal(g.lastStart),
|
lastSeenLocal: formatWhenLocal(g.lastStart),
|
||||||
refererStep: stepOf(g.referer, titles),
|
refererBadge: refererBadgeOf(g.referer, titles),
|
||||||
pages: [...g.pages.entries()]
|
pages: [...g.pages.entries()]
|
||||||
.sort((a, b) => b[1].count - a[1].count)
|
.sort((a, b) => b[1].count - a[1].count)
|
||||||
.map(([path, info]) => ({ ...stepOf(path, titles), count: info.count, status: info.status })),
|
.map(([path, info]) => ({ ...stepOf(path, titles), count: info.count, status: info.status })),
|
||||||
|
readFlags: showLangs
|
||||||
|
? langs.map((l) => ({ flag: flagFor(l), name: langName(l) })).filter((f) => f.flag)
|
||||||
|
: [],
|
||||||
ip: client.ip || '',
|
ip: client.ip || '',
|
||||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||||
isHost,
|
isHost,
|
||||||
@@ -548,42 +586,57 @@ export function formatAbuseRows(abuse, clients, pageTree, now = Date.now()) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Format raw visit records as rows for a technical table. Returns objects
|
* Format raw visit records as rows for a technical table. Returns objects
|
||||||
* with display strings; missing values become "—". ``trail`` starts with the
|
* with display strings; missing values become "—". The external referer
|
||||||
* external referer (when present), then the entry page and any further internal
|
* (when present) and the UTM tags ride along as ``refererBadge``; ``trail``
|
||||||
* pages or external exit origins. Only the 20 most recent visits are shown.
|
* holds the entry page and any further internal pages or external exit
|
||||||
* ``clients`` maps client hashes to client records.
|
* origins; consecutive views of the same page (e.g. a
|
||||||
|
* language switch re-view) merge into one step that keeps the
|
||||||
|
* consecutive-distinct rendered languages, summed read time, and the latest
|
||||||
|
* status. On multilingual sites the rendered languages surface as flag
|
||||||
|
* icons: a visit read entirely in one non-primary language gets ``rowFlag``,
|
||||||
|
* and a visit spanning languages gets per-step ``langFlags`` markers where
|
||||||
|
* the language begins or changes. Only the 20 most recent visits are shown.
|
||||||
|
* ``clients`` maps client hashes to client records; ``site`` carries the
|
||||||
|
* payload's multilingual/primary-language context.
|
||||||
*/
|
*/
|
||||||
export function formatVisitRows(visits, clients, pageTree, now = Date.now()) {
|
export function formatVisitRows(visits, clients, pageTree, now = Date.now(), site = { multilingual: false, primaryLang: '' }) {
|
||||||
const titles = buildTitleMap(pageTree)
|
const titles = buildTitleMap(pageTree)
|
||||||
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
return [...(visits || [])].reverse().slice(0, 20).map((v) => {
|
||||||
const client = (clients || {})[v.client] || {}
|
const client = (clients || {})[v.client] || {}
|
||||||
const trail = Object.values(v.trail || {})
|
const steps = Object.values(v.trail || {})
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const step = stepOf(item.to, titles)
|
const step = stepOf(item.to, titles)
|
||||||
if (step) {
|
if (step) {
|
||||||
if (item.read) step.readSeconds = item.read
|
if (item.read) step.readSeconds = item.read
|
||||||
if (item.status) step.status = item.status
|
if (item.status) step.status = item.status
|
||||||
|
if (item.lang) step.lang = item.lang
|
||||||
}
|
}
|
||||||
return step
|
return step
|
||||||
})
|
})
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
const utmKeys = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content']
|
const trail = []
|
||||||
const utmValues = utmKeys.map((k) => (v.utm || {})[k]).filter(Boolean)
|
for (const step of steps) {
|
||||||
const utm = utmValues.length ? utmValues.join(' · ') : ''
|
const prev = trail[trail.length - 1]
|
||||||
const utmTitle = Object.entries(v.utm || {})
|
if (prev && prev.path === step.path) {
|
||||||
.map(([k, value]) => `${k}=${value}`)
|
if (step.lang && step.lang !== prev.langs[prev.langs.length - 1]) prev.langs.push(step.lang)
|
||||||
.join(', ')
|
if (step.readSeconds) prev.readSeconds = (prev.readSeconds || 0) + step.readSeconds
|
||||||
|
if (step.status) prev.status = step.status
|
||||||
|
} else {
|
||||||
|
step.langs = step.lang ? [step.lang] : []
|
||||||
|
trail.push(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const distinctLangs = new Set(trail.flatMap((s) => s.langs))
|
||||||
const dash = (s) => (s || '—')
|
const dash = (s) => (s || '—')
|
||||||
const host = client.host || ''
|
const host = client.host || ''
|
||||||
const isHost = !!host
|
const isHost = !!host
|
||||||
return {
|
const row = {
|
||||||
lastSeen: formatWhen(v.start, now),
|
lastSeen: formatWhen(v.start, now),
|
||||||
lastSeenIso: formatWhenIso(v.start),
|
lastSeenIso: formatWhenIso(v.start),
|
||||||
lastSeenLocal: formatWhenLocal(v.start),
|
lastSeenLocal: formatWhenLocal(v.start),
|
||||||
langDisplay: formatLang(client.lang),
|
langDisplay: formatLang(client.lang),
|
||||||
trail,
|
trail,
|
||||||
refererStep: stepOf(v.referer, titles),
|
refererBadge: refererBadgeOf(v.referer, titles, v.utm),
|
||||||
referer: dash(v.referer),
|
|
||||||
ip: client.ip || '',
|
ip: client.ip || '',
|
||||||
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
ipDisplay: isHost ? mainDomain(host) : hostIP(client.ip) || client.ip || '—',
|
||||||
isHost,
|
isHost,
|
||||||
@@ -593,8 +646,28 @@ export function formatVisitRows(visits, clients, pageTree, now = Date.now()) {
|
|||||||
ua: client.uarite?.pretty || client.ua || '—',
|
ua: client.uarite?.pretty || client.ua || '—',
|
||||||
uaRaw: client.ua || '',
|
uaRaw: client.ua || '',
|
||||||
uaUrl: client.uarite?.url || '',
|
uaUrl: client.uarite?.url || '',
|
||||||
utm: utm || '—',
|
|
||||||
utmTitle,
|
|
||||||
}
|
}
|
||||||
|
if (site.multilingual && distinctLangs.size) {
|
||||||
|
if (distinctLangs.size === 1) {
|
||||||
|
const [tag] = distinctLangs
|
||||||
|
const flag = flagFor(tag)
|
||||||
|
if (flag && tag !== site.primaryLang) {
|
||||||
|
row.rowFlag = flag
|
||||||
|
row.rowFlagTitle = langName(tag)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Flag the steps where the rendered language begins or changes;
|
||||||
|
// lang-less steps keep the comparison chain going, they never flag.
|
||||||
|
let lastLang = null
|
||||||
|
for (const step of trail) {
|
||||||
|
if (!step.langs.length) continue
|
||||||
|
if (!lastLang || step.langs[step.langs.length - 1] !== lastLang) {
|
||||||
|
step.langFlags = step.langs.map(flagFor).filter(Boolean)
|
||||||
|
}
|
||||||
|
lastLang = step.langs[step.langs.length - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,14 @@
|
|||||||
/* Selection fill for page text and the CodeMirror editors; themes
|
/* Selection fill for page text and the CodeMirror editors; themes
|
||||||
override when the accent tint clashes with accent-colored text. */
|
override when the accent tint clashes with accent-colored text. */
|
||||||
--selection-bg: color-mix(var(--accent) 30%, transparent);
|
--selection-bg: color-mix(var(--accent) 30%, transparent);
|
||||||
|
/* Referer-badge chip in the analytics viewer: a fixed neutral palette,
|
||||||
|
deliberately NOT themed — the chip sits behind transparent favicons, so
|
||||||
|
black-on-transparent and white-on-transparent glyphs must both stay
|
||||||
|
legible on every theme (dark greys kill black glyphs, pure white kills
|
||||||
|
white ones); its text likewise stays dark on any theme. */
|
||||||
|
--badge-bg: #c9d1d9;
|
||||||
|
--badge-text: #1f2328;
|
||||||
|
--badge-muted: #59636e;
|
||||||
/* Code highlighting palette, consumed by pygments.css: complete light and
|
/* Code highlighting palette, consumed by pygments.css: complete light and
|
||||||
dark sets (background included), resolved by light-dark() from the
|
dark sets (background included), resolved by light-dark() from the
|
||||||
used color-scheme. A theme picks a set simply by declaring
|
used color-scheme. A theme picks a set simply by declaring
|
||||||
|
|||||||
@@ -637,6 +637,8 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
if (to) msg.to = to;
|
if (to) msg.to = to;
|
||||||
const secs = Math.round(read / 1000);
|
const secs = Math.round(read / 1000);
|
||||||
if (secs > 0) msg.read = secs;
|
if (secs > 0) msg.read = secs;
|
||||||
|
const lang = document.documentElement.lang;
|
||||||
|
if (lang) msg.lang = lang;
|
||||||
if (!msg.to && !msg.read) return;
|
if (!msg.to && !msg.read) return;
|
||||||
report(msg);
|
report(msg);
|
||||||
}
|
}
|
||||||
@@ -815,6 +817,10 @@ import { reconnectPolicy, socketSlot, watchConnecting } from "./reconnect";
|
|||||||
const y = scrollY; // a language switch is not a navigation: keep scroll
|
const y = scrollY; // a language switch is not a navigation: keep scroll
|
||||||
await load(currentPath, false);
|
await load(currentPath, false);
|
||||||
scrollTo(0, y);
|
scrollTo(0, y);
|
||||||
|
// Log the switch as a trail event in the new language (load() updated
|
||||||
|
// <html lang>): the ping matches the switch's GET server-side, so it is
|
||||||
|
// not misclassified as a crawler hit.
|
||||||
|
ping({ to: currentPath });
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Fetch navigation ------------------------------------------------
|
// --- Fetch navigation ------------------------------------------------
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
* - Disables Vite's screen clearing on startup
|
* - Disables Vite's screen clearing on startup
|
||||||
*
|
*
|
||||||
* Options:
|
* Options:
|
||||||
* paths - Array of paths to proxy (default: ["/api"])
|
* paths - Array of paths to proxy (default: ['/api'])
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
export default function fastapiVue({ paths = ['/api'] } = {}) {
|
||||||
const backendUrl = process.env.PAGERITE_BACKEND_URL || "http://localhost:8210"
|
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:8210'
|
||||||
|
|
||||||
// Build proxy configuration for each path
|
// Build proxy configuration for each path
|
||||||
const proxy = {}
|
const proxy = {}
|
||||||
@@ -25,12 +25,12 @@ export default function fastapiVue({ paths = ["/api"] } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "vite-plugin-fastapi-pagerite",
|
name: 'vite-plugin-fastapi-pagerite',
|
||||||
config: () => ({
|
config: () => ({
|
||||||
clearScreen: false,
|
clearScreen: false,
|
||||||
server: { proxy },
|
server: { proxy },
|
||||||
build: {
|
build: {
|
||||||
outDir: "../pagerite/frontend-build",
|
outDir: '../pagerite/frontend-build',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { defineConfig } from 'vite'
|
|||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
|
||||||
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:3200'
|
const backendUrl = process.env.PAGERITE_BACKEND_URL || 'http://localhost:8210'
|
||||||
|
|
||||||
// Proxy everything except Vite's own dev-time paths and the backend machinery
|
// Proxy everything except Vite's own dev-time paths and the backend machinery
|
||||||
// to the FastAPI backend in dev. /_api, /_f, /_themes, /_fonts and /_a are
|
// to the FastAPI backend in dev. /_api, /_f, /_themes, /_fonts and /_a are
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import os
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from fastapi_vue import server
|
from fastapi_vue import env, server
|
||||||
|
|
||||||
from pagerite.config import Config
|
from pagerite.config import Config
|
||||||
|
|
||||||
DEFAULT_PORT = 8100
|
DEFAULT_PORT = 8100
|
||||||
DEVMODE = os.getenv("PAGERITE_DEV") == "1"
|
os.environ["FASTAPI_VUE"] = "PAGERITE"
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -54,7 +54,7 @@ def main() -> None:
|
|||||||
listen=args.listen,
|
listen=args.listen,
|
||||||
default_port=DEFAULT_PORT,
|
default_port=DEFAULT_PORT,
|
||||||
server_header=False,
|
server_header=False,
|
||||||
reload=Path(__file__).parent if DEVMODE else False,
|
reload=Path(__file__).parent if env.dev else False,
|
||||||
**run_args,
|
**run_args,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+114
-24
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Raw recording, display-time classification. Every document GET is appended
|
Raw recording, display-time classification. Every document GET is appended
|
||||||
to ``Analytics.gets`` as a raw access-log line (path with query string, true
|
to ``Analytics.gets`` as a raw access-log line (path with query string, true
|
||||||
HTTP status, external referer origin, preload flag) and every pagerite.js
|
HTTP status, external referer origin, preload flag, rendered content
|
||||||
activity message from the /_ws WebSocket is appended to ``Analytics.msgs``
|
language) and every pagerite.js activity message from the /_ws WebSocket is
|
||||||
|
appended to ``Analytics.msgs``
|
||||||
(navigations ``fr`` -> ``to`` and active reading-time updates). Nothing is
|
(navigations ``fr`` -> ``to`` and active reading-time updates). Nothing is
|
||||||
classified when it is recorded: whether a client turns out to be a reader,
|
classified when it is recorded: whether a client turns out to be a reader,
|
||||||
a crawler or a scanner is decided by ``Store.display()`` from the raw
|
a crawler or a scanner is decided by ``Store.display()`` from the raw
|
||||||
@@ -92,6 +93,9 @@ class Ping(msgspec.Struct, omit_defaults=True):
|
|||||||
read: int = 0
|
read: int = 0
|
||||||
#: Admin client: record but hide everything from the statistics.
|
#: Admin client: record but hide everything from the statistics.
|
||||||
hide: bool = False
|
hide: bool = False
|
||||||
|
#: Rendered language of the page the activity happened on (the page's
|
||||||
|
#: ``<html lang>``, sent by pagerite.js).
|
||||||
|
lang: str = ""
|
||||||
|
|
||||||
|
|
||||||
class Get(msgspec.Struct, omit_defaults=True):
|
class Get(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -115,6 +119,9 @@ class Get(msgspec.Struct, omit_defaults=True):
|
|||||||
#: never counted as a view/crawler/abuse hit; recorded only so a later
|
#: never counted as a view/crawler/abuse hit; recorded only so a later
|
||||||
#: cache-served navigation can be attributed this GET's status.
|
#: cache-served navigation can be attributed this GET's status.
|
||||||
pre: bool = False
|
pre: bool = False
|
||||||
|
#: Rendered content language of the served document; "" for
|
||||||
|
#: non-localized responses (404 probes, reserved paths).
|
||||||
|
lang: str = ""
|
||||||
|
|
||||||
|
|
||||||
class Msg(msgspec.Struct, omit_defaults=True):
|
class Msg(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -135,6 +142,9 @@ class Msg(msgspec.Struct, omit_defaults=True):
|
|||||||
to: str = ""
|
to: str = ""
|
||||||
#: Active reading time (seconds) spent on ``fr`` since the last report.
|
#: Active reading time (seconds) spent on ``fr`` since the last report.
|
||||||
read: int = 0
|
read: int = 0
|
||||||
|
#: Rendered language reported by the client for the page the activity
|
||||||
|
#: happened on.
|
||||||
|
lang: str = ""
|
||||||
|
|
||||||
|
|
||||||
class Client(msgspec.Struct, omit_defaults=True):
|
class Client(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -191,6 +201,8 @@ class TrailItem(msgspec.Struct, omit_defaults=True):
|
|||||||
|
|
||||||
``read`` accumulates active reading time (seconds) across the whole
|
``read`` accumulates active reading time (seconds) across the whole
|
||||||
visit; ``status`` is the most recent HTTP status seen for the target.
|
visit; ``status`` is the most recent HTTP status seen for the target.
|
||||||
|
A page seen in two rendered languages within one visit (a mid-article
|
||||||
|
language switch) gets one item per language.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
to: str
|
to: str
|
||||||
@@ -198,6 +210,10 @@ class TrailItem(msgspec.Struct, omit_defaults=True):
|
|||||||
read: int = 0
|
read: int = 0
|
||||||
#: Most recent HTTP status of the response (200 or 404).
|
#: Most recent HTTP status of the response (200 or 404).
|
||||||
status: int = 200
|
status: int = 200
|
||||||
|
#: Rendered language of the target: the client's report, for the entry
|
||||||
|
#: page falling back to its GET's rendered language; "" when unknown
|
||||||
|
#: (old clients or data from before language recording).
|
||||||
|
lang: str = ""
|
||||||
|
|
||||||
|
|
||||||
class Visit(msgspec.Struct, omit_defaults=True):
|
class Visit(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -206,8 +222,9 @@ class Visit(msgspec.Struct, omit_defaults=True):
|
|||||||
``trail`` holds the entry page and everything seen afterwards, keyed by
|
``trail`` holds the entry page and everything seen afterwards, keyed by
|
||||||
the timestamp of first sight (insertion order = first-seen order);
|
the timestamp of first sight (insertion order = first-seen order);
|
||||||
re-visiting an already seen target updates its item instead of
|
re-visiting an already seen target updates its item instead of
|
||||||
appending. Client metadata is held in ``Analytics.clients`` keyed by
|
appending — unless the client reports a different rendered language for
|
||||||
``client``.
|
it, which appends a distinct item (a mid-article language switch).
|
||||||
|
Client metadata is held in ``Analytics.clients`` keyed by ``client``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
start: datetime
|
start: datetime
|
||||||
@@ -241,6 +258,8 @@ class CrawlerHit(msgspec.Struct, omit_defaults=True):
|
|||||||
query: str = ""
|
query: str = ""
|
||||||
#: HTTP status of the served response (200 or 404 for content pages).
|
#: HTTP status of the served response (200 or 404 for content pages).
|
||||||
status: int = 200
|
status: int = 200
|
||||||
|
#: Rendered content language of the served document (from the GET).
|
||||||
|
lang: str = ""
|
||||||
|
|
||||||
|
|
||||||
class AbuseHit(msgspec.Struct, omit_defaults=True):
|
class AbuseHit(msgspec.Struct, omit_defaults=True):
|
||||||
@@ -319,6 +338,12 @@ class Display(msgspec.Struct, omit_defaults=True):
|
|||||||
views: dict[str, dict[str, int]] = {}
|
views: dict[str, dict[str, int]] = {}
|
||||||
#: New visits per 5-minute bucket: bucket ISO -> count (sparse).
|
#: New visits per 5-minute bucket: bucket ISO -> count (sparse).
|
||||||
site_visits: dict[str, int] = {}
|
site_visits: dict[str, int] = {}
|
||||||
|
#: Site context: true when translation languages are configured, so the
|
||||||
|
#: viewer can suppress language UI on single-language sites.
|
||||||
|
multilingual: bool = False
|
||||||
|
#: The site's primary language (the front page's), so the viewer can
|
||||||
|
#: skip the primary-language default case.
|
||||||
|
primary_lang: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _bucket(now: datetime) -> str:
|
def _bucket(now: datetime) -> str:
|
||||||
@@ -594,22 +619,25 @@ class Store:
|
|||||||
referer: str = "",
|
referer: str = "",
|
||||||
accept_language: str = "",
|
accept_language: str = "",
|
||||||
pre: bool = False,
|
pre: bool = False,
|
||||||
|
lang: str = "",
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
"""Append one document GET to the raw log.
|
"""Append one document GET to the raw log.
|
||||||
|
|
||||||
``path`` is the full request path, query string included; ``status``
|
``path`` is the full request path, query string included; ``status``
|
||||||
the true HTTP status of the response; ``referer`` the raw Referer
|
the true HTTP status of the response; ``referer`` the raw Referer
|
||||||
header (reduced here to an external https origin, "" when internal
|
header (reduced here to an external https origin, "" when internal
|
||||||
or absent); ``pre`` marks idle-time preloads from pagerite.js.
|
or absent); ``pre`` marks idle-time preloads from pagerite.js;
|
||||||
|
``lang`` the rendered content language of the served document (""
|
||||||
|
for non-localized responses such as 404 probes and reserved paths).
|
||||||
|
|
||||||
Returns the client hash when the client record was just created (so
|
Returns the client hash when the client record was just created (so
|
||||||
the caller can schedule async enrichment), else None.
|
the caller can schedule async enrichment), else None.
|
||||||
"""
|
"""
|
||||||
lang, country = _parse_accept_language(accept_language)
|
client_lang, country = _parse_accept_language(accept_language)
|
||||||
client_hash = _client_hash(ip, ua, lang)
|
client_hash = _client_hash(ip, ua, client_lang)
|
||||||
new = client_hash not in self.data.clients
|
new = client_hash not in self.data.clients
|
||||||
if new:
|
if new:
|
||||||
self._ensure_client(ip, ua, lang, country=country)
|
self._ensure_client(ip, ua, client_lang, country=country)
|
||||||
self.data.gets.append(
|
self.data.gets.append(
|
||||||
Get(
|
Get(
|
||||||
t=datetime.now(UTC),
|
t=datetime.now(UTC),
|
||||||
@@ -618,6 +646,7 @@ class Store:
|
|||||||
status=status,
|
status=status,
|
||||||
ref=_origin(referer) or "",
|
ref=_origin(referer) or "",
|
||||||
pre=pre,
|
pre=pre,
|
||||||
|
lang=lang,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._save()
|
self._save()
|
||||||
@@ -632,6 +661,7 @@ class Store:
|
|||||||
accept_language: str = "",
|
accept_language: str = "",
|
||||||
hide: bool = False,
|
hide: bool = False,
|
||||||
read: int = 0,
|
read: int = 0,
|
||||||
|
lang: str = "",
|
||||||
) -> bytes | None:
|
) -> bytes | None:
|
||||||
"""Append one client activity message (``Ping`` from pagerite.js) to
|
"""Append one client activity message (``Ping`` from pagerite.js) to
|
||||||
the raw log.
|
the raw log.
|
||||||
@@ -642,16 +672,18 @@ class Store:
|
|||||||
stored raw and filtered at display time, so future rule changes lose
|
stored raw and filtered at display time, so future rule changes lose
|
||||||
nothing. ``hide`` flags the client record as an admin; the message
|
nothing. ``hide`` flags the client record as an admin; the message
|
||||||
itself is recorded normally and hidden at display time like
|
itself is recorded normally and hidden at display time like
|
||||||
everything else the client ever did.
|
everything else the client ever did. ``lang`` is the rendered
|
||||||
|
language reported by the client for the page the activity happened
|
||||||
|
on.
|
||||||
|
|
||||||
Returns the client hash when the client record was just created (so
|
Returns the client hash when the client record was just created (so
|
||||||
the caller can schedule async enrichment), else None.
|
the caller can schedule async enrichment), else None.
|
||||||
"""
|
"""
|
||||||
lang, country = _parse_accept_language(accept_language)
|
client_lang, country = _parse_accept_language(accept_language)
|
||||||
client_hash = _client_hash(ip, ua, lang)
|
client_hash = _client_hash(ip, ua, client_lang)
|
||||||
new = client_hash not in self.data.clients
|
new = client_hash not in self.data.clients
|
||||||
if new:
|
if new:
|
||||||
self._ensure_client(ip, ua, lang, country=country)
|
self._ensure_client(ip, ua, client_lang, country=country)
|
||||||
if hide:
|
if hide:
|
||||||
self.data.clients[client_hash].hide = True
|
self.data.clients[client_hash].hide = True
|
||||||
fr = (_internal_path(fr) or "") if fr else ""
|
fr = (_internal_path(fr) or "") if fr else ""
|
||||||
@@ -663,7 +695,14 @@ class Store:
|
|||||||
target = _external_target(to) or ""
|
target = _external_target(to) or ""
|
||||||
if target or read > 0:
|
if target or read > 0:
|
||||||
self.data.msgs.append(
|
self.data.msgs.append(
|
||||||
Msg(t=datetime.now(UTC), client=client_hash, fr=fr, to=target, read=read)
|
Msg(
|
||||||
|
t=datetime.now(UTC),
|
||||||
|
client=client_hash,
|
||||||
|
fr=fr,
|
||||||
|
to=target,
|
||||||
|
read=read,
|
||||||
|
lang=lang,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if target or read > 0 or hide:
|
if target or read > 0 or hide:
|
||||||
self._save()
|
self._save()
|
||||||
@@ -700,7 +739,13 @@ class Store:
|
|||||||
self.data.favicons[origin] = Favicon(file=file, fetched=datetime.now(UTC))
|
self.data.favicons[origin] = Favicon(file=file, fetched=datetime.now(UTC))
|
||||||
self._save()
|
self._save()
|
||||||
|
|
||||||
def display(self, in_menu: Callable[[str], bool] | None = None) -> Display:
|
def display(
|
||||||
|
self,
|
||||||
|
in_menu: Callable[[str], bool] | None = None,
|
||||||
|
*,
|
||||||
|
multilingual: bool = False,
|
||||||
|
primary_lang: str = "",
|
||||||
|
) -> Display:
|
||||||
"""Build the viewer payload from the raw events.
|
"""Build the viewer payload from the raw events.
|
||||||
|
|
||||||
All classification happens here, so the stored data is independent
|
All classification happens here, so the stored data is independent
|
||||||
@@ -722,10 +767,17 @@ class Store:
|
|||||||
- visits: the remaining messages, grouped per client with a new
|
- visits: the remaining messages, grouped per client with a new
|
||||||
visit after ``_SESSION_GAP`` of inactivity. Trail statuses come
|
visit after ``_SESSION_GAP`` of inactivity. Trail statuses come
|
||||||
from the client's GETs (preloads included — a cache-served
|
from the client's GETs (preloads included — a cache-served
|
||||||
navigation's only GET is its preload); the entry referer and UTM
|
navigation's only GET is its preload); trail languages come from
|
||||||
tags from the GET that loaded the entry page.
|
the client's messages (the entry item falling back to its GET's
|
||||||
|
rendered language), and a page re-visited in a different rendered
|
||||||
|
language becomes a distinct trail step. The entry referer and
|
||||||
|
UTM tags come from the GET that loaded the entry page.
|
||||||
|
|
||||||
Hidden (admin) clients are excluded from every list and aggregate.
|
Hidden (admin) clients are excluded from every list and aggregate.
|
||||||
|
``multilingual`` and ``primary_lang`` are site context (translation
|
||||||
|
languages configured, the front page's primary language) copied
|
||||||
|
onto the payload so the viewer can suppress language UI on
|
||||||
|
single-language sites and skip the primary-language default case.
|
||||||
"""
|
"""
|
||||||
in_menu = in_menu or (lambda path: False)
|
in_menu = in_menu or (lambda path: False)
|
||||||
data = self.data
|
data = self.data
|
||||||
@@ -836,7 +888,11 @@ class Store:
|
|||||||
g.path.split("?", 1)[1] if "?" in g.path else ""
|
g.path.split("?", 1)[1] if "?" in g.path else ""
|
||||||
)
|
)
|
||||||
visit.trail[m.t] = TrailItem(
|
visit.trail[m.t] = TrailItem(
|
||||||
to=m.to, status=status_at(h, m.to, m.t)
|
to=m.to,
|
||||||
|
status=status_at(h, m.to, m.t),
|
||||||
|
# The client's report wins; the entry GET fills
|
||||||
|
# in for old clients that don't send lang.
|
||||||
|
lang=m.lang or (g.lang if g is not None else ""),
|
||||||
)
|
)
|
||||||
visits.append(visit)
|
visits.append(visit)
|
||||||
else:
|
else:
|
||||||
@@ -844,18 +900,40 @@ class Store:
|
|||||||
visit.navs[m.t] = Nav(fr=fr, to=m.to)
|
visit.navs[m.t] = Nav(fr=fr, to=m.to)
|
||||||
status = status_at(h, m.to, m.t)
|
status = status_at(h, m.to, m.t)
|
||||||
# First-seen only: repeat pages and repeated exits
|
# First-seen only: repeat pages and repeated exits
|
||||||
# update the existing trail item instead of appending.
|
# update the existing trail item instead of
|
||||||
|
# appending — but a repeat in a different rendered
|
||||||
|
# language (a mid-article language switch) becomes
|
||||||
|
# a distinct step.
|
||||||
for item in visit.trail.values():
|
for item in visit.trail.values():
|
||||||
if item.to == m.to:
|
if item.to == m.to:
|
||||||
item.status = status
|
if m.lang and item.lang and m.lang != item.lang:
|
||||||
|
visit.trail[m.t] = TrailItem(
|
||||||
|
to=m.to, status=status, lang=m.lang
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
item.status = status
|
||||||
|
if not item.lang:
|
||||||
|
item.lang = m.lang
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
visit.trail[m.t] = TrailItem(to=m.to, status=status)
|
visit.trail[m.t] = TrailItem(
|
||||||
|
to=m.to, status=status, lang=m.lang
|
||||||
|
)
|
||||||
if m.read > 0 and m.fr and visit is not None:
|
if m.read > 0 and m.fr and visit is not None:
|
||||||
|
# A page appears in the trail once per language seen:
|
||||||
|
# land the seconds on the matching-language step when
|
||||||
|
# the client reports one, else on the first-seen item.
|
||||||
|
read_item: TrailItem | None = None
|
||||||
for item in visit.trail.values():
|
for item in visit.trail.values():
|
||||||
if item.to == m.fr:
|
if item.to != m.fr:
|
||||||
item.read += m.read
|
continue
|
||||||
|
if read_item is None:
|
||||||
|
read_item = item
|
||||||
|
if m.lang and item.lang == m.lang:
|
||||||
|
read_item = item
|
||||||
break
|
break
|
||||||
|
if read_item is not None:
|
||||||
|
read_item.read += m.read
|
||||||
last_t = m.t
|
last_t = m.t
|
||||||
|
|
||||||
# --- crawler hits: document GETs no message matched
|
# --- crawler hits: document GETs no message matched
|
||||||
@@ -889,6 +967,7 @@ class Store:
|
|||||||
referer=g.ref,
|
referer=g.ref,
|
||||||
query=query,
|
query=query,
|
||||||
status=g.status,
|
status=g.status,
|
||||||
|
lang=g.lang,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -912,6 +991,7 @@ class Store:
|
|||||||
referer=visit.referer if first else "",
|
referer=visit.referer if first else "",
|
||||||
query=query if first else "",
|
query=query if first else "",
|
||||||
status=item.status,
|
status=item.status,
|
||||||
|
lang=item.lang,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
first = False
|
first = False
|
||||||
@@ -938,6 +1018,8 @@ class Store:
|
|||||||
for origin, f in data.favicons.items()
|
for origin, f in data.favicons.items()
|
||||||
if f.file
|
if f.file
|
||||||
},
|
},
|
||||||
|
multilingual=multilingual,
|
||||||
|
primary_lang=primary_lang,
|
||||||
)
|
)
|
||||||
for visit in kept:
|
for visit in kept:
|
||||||
bucket = _bucket(visit.start)
|
bucket = _bucket(visit.start)
|
||||||
@@ -959,6 +1041,14 @@ class Store:
|
|||||||
nbuckets[nb] = nbuckets.get(nb, 0) + 1
|
nbuckets[nb] = nbuckets.get(nb, 0) + 1
|
||||||
return display
|
return display
|
||||||
|
|
||||||
def display_json(self, in_menu: Callable[[str], bool] | None = None) -> str:
|
def display_json(
|
||||||
|
self,
|
||||||
|
in_menu: Callable[[str], bool] | None = None,
|
||||||
|
*,
|
||||||
|
multilingual: bool = False,
|
||||||
|
primary_lang: str = "",
|
||||||
|
) -> str:
|
||||||
"""The ``display()`` payload as a JSON string for the WebSocket."""
|
"""The ``display()`` payload as a JSON string for the WebSocket."""
|
||||||
return msgspec.json.encode(self.display(in_menu)).decode()
|
return msgspec.json.encode(
|
||||||
|
self.display(in_menu, multilingual=multilingual, primary_lang=primary_lang)
|
||||||
|
).decode()
|
||||||
|
|||||||
+2
-3
@@ -36,11 +36,10 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import FastAPI, Request
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from fastapi_vue import Frontend
|
from fastapi_vue import Frontend, env
|
||||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||||
|
|
||||||
from pagerite import api, files, pages, tracking
|
from pagerite import api, files, pages, tracking
|
||||||
from pagerite.__main__ import DEVMODE
|
|
||||||
from pagerite.files import file_store
|
from pagerite.files import file_store
|
||||||
from pagerite.state import analytics_store, config, kanta
|
from pagerite.state import analytics_store, config, kanta
|
||||||
|
|
||||||
@@ -99,7 +98,7 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator:
|
|||||||
# is not meant to be browsable by the public anyway.
|
# is not meant to be browsable by the public anyway.
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Pagerite",
|
title="Pagerite",
|
||||||
debug=DEVMODE,
|
debug=env.dev,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
|
|||||||
+2
-7
@@ -39,10 +39,6 @@ from pagerite.state import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# mediapreview logs pyvips noise ("VipsForeignSaveJpegTarget argument strip is
|
|
||||||
# deprecated", "threadpool completed with N workers") at INFO; keep warnings.
|
|
||||||
logging.getLogger("mediapreview").setLevel(logging.WARNING)
|
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -159,14 +155,13 @@ def _svg_to_png(body: bytes, maxsize: int) -> bytes | None:
|
|||||||
|
|
||||||
def _avif_to_format(avif: bytes, suffix: str, quality: int) -> bytes:
|
def _avif_to_format(avif: bytes, suffix: str, quality: int) -> bytes:
|
||||||
"""Re-encode the AVIF derivative into a fallback format (WebP/JPEG)
|
"""Re-encode the AVIF derivative into a fallback format (WebP/JPEG)
|
||||||
via pyvips. JPEG has no alpha, so it is flattened onto white;
|
via pyvips. JPEG has no alpha, so it is flattened onto white."""
|
||||||
``strip`` keeps metadata (EXIF) out of the fallbacks."""
|
|
||||||
import pyvips
|
import pyvips
|
||||||
|
|
||||||
img = pyvips.Image.new_from_buffer(avif, "")
|
img = pyvips.Image.new_from_buffer(avif, "")
|
||||||
if suffix == ".jpg" and img.hasalpha():
|
if suffix == ".jpg" and img.hasalpha():
|
||||||
img = img.flatten(background=[255, 255, 255])
|
img = img.flatten(background=[255, 255, 255])
|
||||||
return img.write_to_buffer(suffix, Q=quality, strip=True)
|
return img.write_to_buffer(suffix, Q=quality, keep="none")
|
||||||
|
|
||||||
|
|
||||||
def _image_derivatives(
|
def _image_derivatives(
|
||||||
|
|||||||
+4
-3
@@ -152,7 +152,8 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
if node is not None and node.published and node.chunks is not None:
|
if node is not None and node.published and node.chunks is not None:
|
||||||
# Language selection (docs/localization.md): ?lang= wins when a
|
# Language selection (docs/localization.md): ?lang= wins when a
|
||||||
# translation exists, else header logic. Analytics keep the raw
|
# translation exists, else header logic. Analytics keep the raw
|
||||||
# Accept-Language header regardless of the selection.
|
# Accept-Language header regardless of the selection, and record
|
||||||
|
# the resolved language as the GET's rendered language.
|
||||||
query_lang = request.query_params.get("lang")
|
query_lang = request.query_params.get("lang")
|
||||||
lang = i18n.select_language(
|
lang = i18n.select_language(
|
||||||
query_lang,
|
query_lang,
|
||||||
@@ -175,7 +176,7 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
if request.headers.get("if-none-match") == etag:
|
if request.headers.get("if-none-match") == etag:
|
||||||
return Response(status_code=304)
|
return Response(status_code=304)
|
||||||
if _is_trackable_path(path):
|
if _is_trackable_path(path):
|
||||||
_record_get(request)
|
_record_get(request, lang=lang)
|
||||||
return _html_response(
|
return _html_response(
|
||||||
request,
|
request,
|
||||||
"page",
|
"page",
|
||||||
@@ -205,7 +206,7 @@ async def show_page(request: Request, path: str) -> Response:
|
|||||||
)
|
)
|
||||||
link_lang = i18n.base_tag(query_lang or "")
|
link_lang = i18n.base_tag(query_lang or "")
|
||||||
if _is_trackable_path(path):
|
if _is_trackable_path(path):
|
||||||
_record_get(request, status=404)
|
_record_get(request, status=404, lang=lang)
|
||||||
return _html_response(
|
return _html_response(
|
||||||
request,
|
request,
|
||||||
"category",
|
"category",
|
||||||
|
|||||||
+6
-2
@@ -22,11 +22,11 @@ from pathlib import Path
|
|||||||
import blake3
|
import blake3
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
from fastapi_vue import env
|
||||||
from kanta import Kanta
|
from kanta import Kanta
|
||||||
from zstandard import ZstdCompressor
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from pagerite import analytics, i18n, seed, translate, views
|
from pagerite import analytics, i18n, seed, translate, views
|
||||||
from pagerite.__main__ import DEVMODE
|
|
||||||
from pagerite.chunks import store_chunks
|
from pagerite.chunks import store_chunks
|
||||||
from pagerite.config import load
|
from pagerite.config import load
|
||||||
from pagerite.data import (
|
from pagerite.data import (
|
||||||
@@ -56,6 +56,10 @@ DB_PATH = os.getenv("PAGERITE_DB", str(SITE_DIR / "content.kantadb"))
|
|||||||
|
|
||||||
# Visit analytics go to their own JSON file, not the kanta database.
|
# Visit analytics go to their own JSON file, not the kanta database.
|
||||||
ANALYTICS_PATH = Path(os.getenv("PAGERITE_ANALYTICS", str(SITE_DIR / "analytics.json")))
|
ANALYTICS_PATH = Path(os.getenv("PAGERITE_ANALYTICS", str(SITE_DIR / "analytics.json")))
|
||||||
|
# The per-hostname data directory may not exist yet on first run; kanta
|
||||||
|
# creates the database file but not its parent directory.
|
||||||
|
Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
ANALYTICS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
analytics_store = analytics.Store(ANALYTICS_PATH)
|
analytics_store = analytics.Store(ANALYTICS_PATH)
|
||||||
|
|
||||||
# Content-addressed file store (uploads, seed assets, fetched favicons):
|
# Content-addressed file store (uploads, seed assets, fetched favicons):
|
||||||
@@ -226,7 +230,7 @@ def _html_response(
|
|||||||
# Absolute social/canonical URLs use the site's public origin; on
|
# Absolute social/canonical URLs use the site's public origin; on
|
||||||
# localhost (varying ports) fall back to the request's own base URL.
|
# localhost (varying ports) fall back to the request's own base URL.
|
||||||
base_url = SITE_URL or str(request.base_url).rstrip("/")
|
base_url = SITE_URL or str(request.base_url).rstrip("/")
|
||||||
if DEVMODE:
|
if env.dev:
|
||||||
identity = _render_html(kind, path, base_url, lang, link_lang).encode()
|
identity = _render_html(kind, path, base_url, lang, link_lang).encode()
|
||||||
body = _zstd.compress(identity) if zstd else identity
|
body = _zstd.compress(identity) if zstd else identity
|
||||||
else:
|
else:
|
||||||
|
|||||||
+25
-6
@@ -28,7 +28,7 @@ from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
|||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from uarite import uaparse
|
from uarite import uaparse
|
||||||
|
|
||||||
from pagerite import analytics
|
from pagerite import analytics, i18n
|
||||||
from pagerite.data import resolve
|
from pagerite.data import resolve
|
||||||
from pagerite.files import _hash_name, file_store
|
from pagerite.files import _hash_name, file_store
|
||||||
from pagerite.state import SITE_URL, _html_response, analytics_store, data
|
from pagerite.state import SITE_URL, _html_response, analytics_store, data
|
||||||
@@ -334,7 +334,7 @@ async def _broadcast_analytics() -> None:
|
|||||||
"""Send the current analytics snapshot to every connected WS client."""
|
"""Send the current analytics snapshot to every connected WS client."""
|
||||||
if not _analytics_ws_clients:
|
if not _analytics_ws_clients:
|
||||||
return
|
return
|
||||||
payload = analytics_store.display_json(_in_menu)
|
payload = _display_json()
|
||||||
closed = set()
|
closed = set()
|
||||||
for ws in _analytics_ws_clients:
|
for ws in _analytics_ws_clients:
|
||||||
try:
|
try:
|
||||||
@@ -370,12 +370,29 @@ def _in_menu(path: str) -> bool:
|
|||||||
return resolve(data.menu, path.strip("/")) is not None
|
return resolve(data.menu, path.strip("/")) is not None
|
||||||
|
|
||||||
|
|
||||||
def _record_get(request: Request, *, status: int = 200) -> None:
|
def _display_json() -> str:
|
||||||
|
"""The current analytics snapshot as JSON for the admin stream.
|
||||||
|
|
||||||
|
Adds the site's language context: ``multilingual`` (translation
|
||||||
|
languages configured) lets the viewer suppress language UI on
|
||||||
|
single-language sites, ``primary_lang`` (the front page's) lets it skip
|
||||||
|
the primary-language default case.
|
||||||
|
"""
|
||||||
|
return analytics_store.display_json(
|
||||||
|
_in_menu,
|
||||||
|
multilingual=bool(data.translate_langs),
|
||||||
|
primary_lang=i18n.primary_lang(data.menu, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_get(request: Request, *, status: int = 200, lang: str = "") -> None:
|
||||||
"""Record the document GET as one raw access-log line in analytics.
|
"""Record the document GET as one raw access-log line in analytics.
|
||||||
|
|
||||||
Nothing is classified here — the true HTTP status, the full request path
|
Nothing is classified here — the true HTTP status, the full request path
|
||||||
(query included), an external referer origin and the preload flag are
|
(query included), an external referer origin, the preload flag and the
|
||||||
stored, and visitor/crawler/abuse classification happens at display time
|
rendered content language (``lang``, "" for non-localized responses such
|
||||||
|
as 404 probes and reserved paths) are stored, and
|
||||||
|
visitor/crawler/abuse classification happens at display time
|
||||||
(see analytics.Store.display). Idle-time preloads from pagerite.js
|
(see analytics.Store.display). Idle-time preloads from pagerite.js
|
||||||
(``x-pagerite-preload`` header) are recorded with ``pre=True``: never
|
(``x-pagerite-preload`` header) are recorded with ``pre=True``: never
|
||||||
counted, but a navigation later served from the in-memory page cache is
|
counted, but a navigation later served from the in-memory page cache is
|
||||||
@@ -403,6 +420,7 @@ def _record_get(request: Request, *, status: int = 200) -> None:
|
|||||||
referer=referer,
|
referer=referer,
|
||||||
accept_language=request.headers.get("accept-language", ""),
|
accept_language=request.headers.get("accept-language", ""),
|
||||||
pre=bool(request.headers.get("x-pagerite-preload")),
|
pre=bool(request.headers.get("x-pagerite-preload")),
|
||||||
|
lang=lang,
|
||||||
)
|
)
|
||||||
if client_hash is not None:
|
if client_hash is not None:
|
||||||
_schedule_client_enrichment([client_hash])
|
_schedule_client_enrichment([client_hash])
|
||||||
@@ -462,6 +480,7 @@ async def activity_ws(ws: WebSocket) -> None:
|
|||||||
accept_language,
|
accept_language,
|
||||||
hide=msg.hide,
|
hide=msg.hide,
|
||||||
read=msg.read,
|
read=msg.read,
|
||||||
|
lang=msg.lang,
|
||||||
)
|
)
|
||||||
if new_client is not None:
|
if new_client is not None:
|
||||||
_schedule_client_enrichment([new_client])
|
_schedule_client_enrichment([new_client])
|
||||||
@@ -478,7 +497,7 @@ async def analytics_websocket(ws: WebSocket) -> None:
|
|||||||
endpoint. Powers the analytics viewer rendered at /_a.
|
endpoint. Powers the analytics viewer rendered at /_a.
|
||||||
"""
|
"""
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
await ws.send_text(analytics_store.display_json(_in_menu))
|
await ws.send_text(_display_json())
|
||||||
_analytics_ws_clients.add(ws)
|
_analytics_ws_clients.add(ws)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|||||||
+7
-6
@@ -21,6 +21,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from fastapi_vue import env
|
||||||
from html5tagger import HTML, Document, E, Template
|
from html5tagger import HTML, Document, E, Template
|
||||||
from platformdirs import site_data_dir, user_data_path
|
from platformdirs import site_data_dir, user_data_path
|
||||||
|
|
||||||
@@ -337,7 +338,7 @@ def _layout(
|
|||||||
) -> Template:
|
) -> Template:
|
||||||
"""Page layout template with standard assets and ES-module scripts.
|
"""Page layout template with standard assets and ES-module scripts.
|
||||||
|
|
||||||
In dev (PAGERITE_VITE_URL set) assets are linked from the Vite dev
|
In dev (Vite dev-server URL set) assets are linked from the Vite dev
|
||||||
server and stylesheets use ``blocking="render"`` so the browser waits
|
server and stylesheets use ``blocking="render"`` so the browser waits
|
||||||
for them before showing the page, avoiding a flash of unstyled content.
|
for them before showing the page, avoiding a flash of unstyled content.
|
||||||
In production all page assets are inlined into the document: stylesheets
|
In production all page assets are inlined into the document: stylesheets
|
||||||
@@ -394,7 +395,7 @@ def _layout(
|
|||||||
# dev-server URLs as meta tags (Vite serves the modules and injects
|
# dev-server URLs as meta tags (Vite serves the modules and injects
|
||||||
# their CSS for hot reloads); production inlines all page assets and
|
# their CSS for hot reloads); production inlines all page assets and
|
||||||
# carries the on-demand URLs in one JSON script instead.
|
# carries the on-demand URLs in one JSON script instead.
|
||||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
vite_url = env.vite_url
|
||||||
editor_scripts, editor_css = _editor_assets()
|
editor_scripts, editor_css = _editor_assets()
|
||||||
langselect_scripts, langselect_css = _langselect_assets()
|
langselect_scripts, langselect_css = _langselect_assets()
|
||||||
config = {
|
config = {
|
||||||
@@ -1226,7 +1227,7 @@ def _page_assets() -> tuple[list[str], list[str]]:
|
|||||||
by the entry (e.g. overlayscrollbars.css) is extracted by Vite and must
|
by the entry (e.g. overlayscrollbars.css) is extracted by Vite and must
|
||||||
be linked separately.
|
be linked separately.
|
||||||
"""
|
"""
|
||||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
vite_url = env.vite_url
|
||||||
if vite_url:
|
if vite_url:
|
||||||
return [f"{vite_url}/src/pagerite.js"], []
|
return [f"{vite_url}/src/pagerite.js"], []
|
||||||
if "page" not in _asset_cache:
|
if "page" not in _asset_cache:
|
||||||
@@ -1244,7 +1245,7 @@ def _editor_assets() -> tuple[list[str], str | None]:
|
|||||||
The shared CSS is already linked on the page, so the pen only needs the
|
The shared CSS is already linked on the page, so the pen only needs the
|
||||||
editor-specific stylesheet.
|
editor-specific stylesheet.
|
||||||
"""
|
"""
|
||||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
vite_url = env.vite_url
|
||||||
if vite_url:
|
if vite_url:
|
||||||
return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None
|
return [f"{vite_url}/@vite/client", f"{vite_url}/src/main.js"], None
|
||||||
if "editor" not in _asset_cache:
|
if "editor" not in _asset_cache:
|
||||||
@@ -1256,7 +1257,7 @@ def _editor_assets() -> tuple[list[str], str | None]:
|
|||||||
|
|
||||||
def _analytics_assets() -> tuple[list[str], list[str]]:
|
def _analytics_assets() -> tuple[list[str], list[str]]:
|
||||||
"""Script and stylesheet URLs for the analytics page entry."""
|
"""Script and stylesheet URLs for the analytics page entry."""
|
||||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
vite_url = env.vite_url
|
||||||
if vite_url:
|
if vite_url:
|
||||||
return [f"{vite_url}/src/analytics-main.js"], []
|
return [f"{vite_url}/src/analytics-main.js"], []
|
||||||
if "analytics" not in _asset_cache:
|
if "analytics" not in _asset_cache:
|
||||||
@@ -1270,7 +1271,7 @@ def _analytics_assets() -> tuple[list[str], list[str]]:
|
|||||||
|
|
||||||
def _langselect_assets() -> tuple[list[str], list[str]]:
|
def _langselect_assets() -> tuple[list[str], list[str]]:
|
||||||
"""Script and stylesheet URLs for the on-demand public language selector."""
|
"""Script and stylesheet URLs for the on-demand public language selector."""
|
||||||
vite_url = os.environ.get("PAGERITE_VITE_URL")
|
vite_url = env.vite_url
|
||||||
if vite_url:
|
if vite_url:
|
||||||
return [f"{vite_url}/src/langselect-main.js"], []
|
return [f"{vite_url}/src/langselect-main.js"], []
|
||||||
if "langselect" not in _asset_cache:
|
if "langselect" not in _asset_cache:
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3>=1.0.9",
|
"blake3>=1.0.9",
|
||||||
"fastapi-vue~=1.4.2",
|
"fastapi-vue~=1.6.1",
|
||||||
"fastapi[standard]>=0.141.1",
|
"fastapi[standard]>=0.141.1",
|
||||||
"html5tagger>=2.0.0",
|
"html5tagger>=2.0.0",
|
||||||
"httpx>=0.28.1",
|
"httpx>=0.28.1",
|
||||||
|
|||||||
+10
-5
@@ -1,11 +1,12 @@
|
|||||||
#!/usr/bin/env -S uv run
|
#!/usr/bin/env -S uv run
|
||||||
|
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||||
"""Run Vite development server for Vue app and FastAPI backend with auto-reload."""
|
"""Run Vite development server for Vue app and FastAPI backend with auto-reload."""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from contextlib import suppress
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import tracerite
|
import tracerite
|
||||||
@@ -47,11 +48,11 @@ async def run_devserver(
|
|||||||
os.environ["PAGERITE_DEV"] = "1"
|
os.environ["PAGERITE_DEV"] = "1"
|
||||||
|
|
||||||
async with ProcessGroup() as pg:
|
async with ProcessGroup() as pg:
|
||||||
|
pg.create_task(check_ports_free(viteurl, backurl))
|
||||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||||
await check_ports_free(viteurl, backurl)
|
await pg.spawn(*pagerite, *(extra_args or []), vital=True)
|
||||||
await pg.spawn(*pagerite, *(extra_args or []))
|
|
||||||
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
await pg.wait(npm_i, ready(backurl, path=HEALTH))
|
||||||
await pg.spawn(*vite, cwd=front)
|
await pg.spawn(*vite, cwd=front, vital=True)
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
@@ -74,8 +75,12 @@ def main() -> None:
|
|||||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||||
)
|
)
|
||||||
args, extra_args = parser.parse_known_args()
|
args, extra_args = parser.parse_known_args()
|
||||||
with suppress(KeyboardInterrupt):
|
try:
|
||||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||||
|
except* KeyboardInterrupt:
|
||||||
|
pass # user stopped the devserver: normal exit
|
||||||
|
except* subprocess.SubprocessError, RuntimeError:
|
||||||
|
raise SystemExit(1) from None # logged in devutil already; exit 1
|
||||||
|
|
||||||
|
|
||||||
HELP_EPILOG = """
|
HELP_EPILOG = """
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# ruff: noqa: INP001
|
|
||||||
"""Hatch build hook for building Vue frontend during package build."""
|
"""Hatch build hook for building Vue frontend during package build."""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
# ruff: noqa: INP001
|
|
||||||
"""Utilities used at build time and in devserver script. No dependencies."""
|
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -33,7 +32,7 @@ def _check_node_version(node_path: str) -> None:
|
|||||||
Raises RuntimeError if version is too old or cannot be determined.
|
Raises RuntimeError if version is too old or cannot be determined.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
result = subprocess.run( # noqa: S603
|
result = subprocess.run(
|
||||||
[node_path, "--version"],
|
[node_path, "--version"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
@@ -221,7 +220,7 @@ def build(folder: str = "frontend") -> None:
|
|||||||
def run(cmd: list[str]) -> None:
|
def run(cmd: list[str]) -> None:
|
||||||
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||||
logger.info("### %s", " ".join(display_cmd))
|
logger.info("### %s", " ".join(display_cmd))
|
||||||
subprocess.run(cmd, check=True, cwd=folder) # noqa: S603
|
subprocess.run(cmd, check=True, cwd=folder)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run(install_cmd)
|
run(install_cmd)
|
||||||
|
|||||||
@@ -1,111 +1,90 @@
|
|||||||
# ruff: noqa: INP001
|
|
||||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
|
from asyncio.subprocess import Process
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING, Any, Self
|
from subprocess import CalledProcessError
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from buildutil import find_dev_tool, find_install_tool, logger
|
|
||||||
from fastapi_vue.hostutil import parse_endpoint
|
from fastapi_vue.hostutil import parse_endpoint
|
||||||
|
|
||||||
|
from buildutil import find_dev_tool, find_install_tool, logger
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Coroutine
|
from collections.abc import Awaitable
|
||||||
|
|
||||||
|
|
||||||
class ProcessGroup:
|
class ProcessGroup(asyncio.TaskGroup):
|
||||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
"""TaskGroup with structured ownership of async subprocesses."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self, *, terminate_timeout: float = 10) -> None:
|
||||||
"""Initialize empty process tracking."""
|
"""Set the grace period before terminate() escalates to kill()."""
|
||||||
self._procs: list[asyncio.subprocess.Process] = []
|
super().__init__()
|
||||||
self._cmds: dict[int, str] = {} # pid -> command name
|
self._terminate_timeout = terminate_timeout
|
||||||
|
self._cmds: dict[Process, tuple[str, ...]] = {}
|
||||||
|
|
||||||
async def spawn(
|
async def spawn(
|
||||||
self,
|
self, *cmd: str, cwd: str | None = None, vital: bool = False
|
||||||
*cmd: str,
|
) -> Process:
|
||||||
cwd: str | None = None,
|
"""Spawn and own a subprocess. If a vital process exits, the group cancels."""
|
||||||
) -> asyncio.subprocess.Process:
|
|
||||||
"""Spawn a subprocess and track it."""
|
|
||||||
cmd_name = Path(cmd[0]).stem
|
|
||||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
|
||||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
|
||||||
self._procs.append(proc)
|
|
||||||
self._cmds[proc.pid] = cmd_name
|
|
||||||
return proc
|
|
||||||
|
|
||||||
async def wait(
|
async def run() -> None:
|
||||||
self,
|
name = Path(cmd[0]).stem
|
||||||
*waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]",
|
logger.info(">>> %s", " ".join([name, *cmd[1:]]))
|
||||||
) -> None:
|
try:
|
||||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||||
|
self._cmds[proc] = cmd
|
||||||
|
started.set_result(proc)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
started.set_exception(e)
|
||||||
|
return
|
||||||
|
|
||||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
try:
|
||||||
returncode = await proc.wait()
|
returncode = await proc.wait()
|
||||||
if returncode != 0:
|
finally:
|
||||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
|
||||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
|
||||||
|
|
||||||
tasks = [
|
|
||||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
|
||||||
for w in waitables
|
|
||||||
]
|
|
||||||
try:
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
|
||||||
raise SystemExit(1) from None
|
|
||||||
|
|
||||||
async def __aenter__(self) -> Self:
|
|
||||||
"""Enter the async context manager."""
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type: type[BaseException] | None, *_: object) -> None:
|
|
||||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
|
||||||
await self._cleanup(immediate=exc_type is not None)
|
|
||||||
|
|
||||||
async def _cleanup(self, *, immediate: bool = False) -> None:
|
|
||||||
running = [p for p in self._procs if p.returncode is None]
|
|
||||||
if not running:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not immediate:
|
|
||||||
# Wait for any one process to exit
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
await asyncio.wait(
|
|
||||||
[asyncio.create_task(p.wait()) for p in running],
|
|
||||||
return_when=asyncio.FIRST_COMPLETED,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Terminate remaining processes
|
|
||||||
for p in self._procs:
|
|
||||||
if p.returncode is None:
|
|
||||||
with suppress(ProcessLookupError):
|
with suppress(ProcessLookupError):
|
||||||
p.terminate()
|
proc.terminate()
|
||||||
|
|
||||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
|
||||||
still_running = [p for p in self._procs if p.returncode is None]
|
|
||||||
if still_running:
|
|
||||||
with suppress(asyncio.CancelledError):
|
|
||||||
try:
|
try:
|
||||||
await asyncio.shield(
|
await asyncio.wait_for(proc.wait(), self._terminate_timeout)
|
||||||
asyncio.wait_for(
|
|
||||||
asyncio.gather(*[p.wait() for p in still_running]),
|
|
||||||
timeout=10,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
for p in self._procs:
|
with suppress(ProcessLookupError):
|
||||||
if p.returncode is None:
|
proc.kill()
|
||||||
with suppress(ProcessLookupError):
|
await proc.wait()
|
||||||
p.kill()
|
|
||||||
await p.wait()
|
if vital:
|
||||||
|
logger.warning("Vital process %s exited", name)
|
||||||
|
raise CalledProcessError(returncode, cmd)
|
||||||
|
|
||||||
|
started = asyncio.get_running_loop().create_future()
|
||||||
|
self.create_task(run())
|
||||||
|
return await asyncio.shield(started)
|
||||||
|
|
||||||
|
async def wait(self, *waitables: Process | Awaitable) -> tuple[Any, ...]:
|
||||||
|
"""Wait concurrently and return results in argument order."""
|
||||||
|
|
||||||
|
async def task(w: Process | Awaitable) -> Any:
|
||||||
|
if not isinstance(w, Process):
|
||||||
|
return await w
|
||||||
|
if retcode := await w.wait():
|
||||||
|
cmd = self._cmds[w]
|
||||||
|
logger.warning(
|
||||||
|
"Process %s exited with status %d", Path(cmd[0]).stem, retcode
|
||||||
|
)
|
||||||
|
raise CalledProcessError(retcode, cmd)
|
||||||
|
return retcode
|
||||||
|
|
||||||
|
async with asyncio.TaskGroup() as group:
|
||||||
|
tasks = [group.create_task(task(w)) for w in waitables]
|
||||||
|
|
||||||
|
return tuple(task.result() for task in tasks)
|
||||||
|
|
||||||
|
|
||||||
async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYNC109
|
async def http_get_server(url: str, timeout: float) -> str | None:
|
||||||
"""GET url with plain asyncio streams, return the response Server header.
|
"""GET url with plain asyncio streams, return the response Server header.
|
||||||
|
|
||||||
Returns an empty string when the server responds without a Server header,
|
Returns an empty string when the server responds without a Server header,
|
||||||
@@ -128,31 +107,32 @@ async def http_get_server(url: str, timeout: float) -> str | None: # noqa: ASYN
|
|||||||
writer.close()
|
writer.close()
|
||||||
except OSError, EOFError, ValueError, TimeoutError:
|
except OSError, EOFError, ValueError, TimeoutError:
|
||||||
return None
|
return None
|
||||||
for line in data.decode("latin-1").split("\r\n"):
|
for line in data.decode(errors="replace").split("\r\n"):
|
||||||
if line.lower().startswith("server:"):
|
if line.lower().startswith("server:"):
|
||||||
return line.split(":", 1)[1].strip()
|
return line[7:].strip()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def check_ports_free(*urls: str) -> None:
|
async def check_ports_free(*urls: str) -> None:
|
||||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
"""Verify URLs are not responding (ports are free).
|
||||||
|
|
||||||
async def check(url: str) -> None:
|
Meant to run as a task inside a TaskGroup. Logs the conflict and raises
|
||||||
server = await http_get_server(url, timeout=0.1)
|
RuntimeError (handled like a failed process) if any URL responds.
|
||||||
|
"""
|
||||||
|
servers = await asyncio.gather(*(http_get_server(url, timeout=0.1) for url in urls))
|
||||||
|
for url, server in zip(urls, servers, strict=True):
|
||||||
if server is not None:
|
if server is not None:
|
||||||
logger.warning(
|
logger.error(
|
||||||
"Conflicting %s already running at %s", server or "server", url
|
"Conflicting %s already running at %s", server or "server", url
|
||||||
)
|
)
|
||||||
raise SystemExit(1)
|
raise RuntimeError(url)
|
||||||
|
|
||||||
await asyncio.gather(*[check(url) for url in urls])
|
|
||||||
|
|
||||||
|
|
||||||
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
||||||
"""Wait for the server to be ready by polling an endpoint.
|
"""Wait for the server to be ready by polling an endpoint.
|
||||||
|
|
||||||
Use empty path to disable the check and make this return immediately.
|
Use empty path to disable the check and make this return immediately.
|
||||||
Raises SystemExit(1) if server doesn't start in time.
|
Logs, then raises RuntimeError if the server doesn't start in time.
|
||||||
"""
|
"""
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
@@ -162,8 +142,8 @@ async def ready(url: str, path: str = "", max_attempts: int = 50) -> None:
|
|||||||
logger.info("✓ Backend ready!")
|
logger.info("✓ Backend ready!")
|
||||||
return
|
return
|
||||||
if attempt == max_attempts - 1:
|
if attempt == max_attempts - 1:
|
||||||
logger.warning("Backend didn't start in time")
|
logger.error("Backend at %s didn't start in time", url)
|
||||||
raise SystemExit(1)
|
raise RuntimeError(url)
|
||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user