feat: add more stats to admin dashboard

This commit is contained in:
NGPixel committed 2026-10-09 22:19:13 -04:00
1 parent 7f942b2d5e
commit 907774b1a7
14 files changed
+489 -40

No files matched your search

+67
View File
@@ -299,6 +299,73 @@ async function routes(app: FastifyInstance) {
}
)
/**
* LIST NEWEST PAGES
*/
app.get<{ Querystring: { limit?: number } }>(
'/pages/newest',
{
config: {
// -> `access:admin`, for the reason `pages/recent` gives: a dashboard panel, answered with
// where a page is and when it was created, and nothing of what it says.
permissions: ['access:admin']
},
schema: {
summary: 'List the newest pages',
description:
'The pages created most recently, newest first, across every site. Ordered by creation, which no later edit moves.',
tags: ['Pages'],
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 }
}
},
response: {
200: {
description: 'The newest pages, newest first',
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
siteId: { type: 'string', format: 'uuid' },
locale: { type: 'string' },
path: { type: 'string' },
title: { type: 'string' },
createdAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
},
url: {
type: 'string',
description:
"Where the page is, as a path on its own site — carrying a locale prefix only where that site's settings put one there."
},
hostname: {
type: 'string',
nullable: true,
description:
'The host that site answers on, for linking to a page on a site other than the one being browsed. Null for the catch-all site.'
},
creatorName: {
type: 'string',
nullable: true,
description: 'Who created the page. Null once that account is gone.'
}
}
}
}
}
}
},
async (req, reply) => {
reply.preventCache()
return WIKI.models.pages.getNewest({ limit: req.query.limit ?? 10 })
}
)
/**
* LIST PAGES
*/
+17
View File
@@ -146,6 +146,19 @@ async function routes(app: FastifyInstance) {
description:
'Jobs running right now on every instance combined, one worker slot each.'
},
assetsSize: {
type: 'number',
description: 'Combined size of every asset across all sites, in bytes.'
},
assetsTotal: {
type: 'number',
description: 'Assets across all sites.'
},
commentsTotal: {
type: 'number',
description:
'Comments across all sites, not counting the placeholders deleted comments leave behind.'
},
configFile: {
type: 'string'
},
@@ -241,8 +254,12 @@ async function routes(app: FastifyInstance) {
}
},
async () => {
const assetsTotals = await WIKI.models.assets.totals()
return {
activeWorkers: await WIKI.models.jobs.countActive(),
assetsSize: assetsTotals.bytes,
assetsTotal: assetsTotals.count,
commentsTotal: await WIKI.models.comments.countAll(),
configFile: path.join(process.cwd(), 'config.yml'),
cpuCores: os.cpus().length,
currentVersion: WIKI.version,
+48
View File
@@ -211,6 +211,54 @@ async function routes(app: FastifyInstance) {
}
)
/**
* NEWEST USERS
*/
app.get<{ Querystring: { limit?: number } }>(
'/newest',
{
config: {
// -> `access:admin`, for the reason `recent-logins` gives: a dashboard panel, answered with
// identity plus a timestamp and nothing that needs `read:users`.
permissions: ['access:admin']
},
schema: {
summary: 'List the newest users',
description:
'The most recently created accounts, newest first. System accounts are left out — they come with the installation.',
tags: ['Users'],
querystring: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 50, default: 10 }
}
},
response: {
200: {
description: 'The newest users, newest first',
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string', format: 'uuid' },
name: { type: 'string' },
email: { type: 'string' },
createdAt: {
type: 'string',
format: 'date-time',
description: 'RFC 3339 Date Time'
}
}
}
}
}
}
},
async (req) => {
return WIKI.models.users.getNewestUsers({ limit: req.query.limit ?? 10 })
}
)
app.get(
'/whoami',
{
+5
View File
@@ -407,6 +407,8 @@
"admin.comments.website": "Visit Website",
"admin.contribute.title": "Donate",
"admin.dashboard.activeWorkers": "Active Workers",
"admin.dashboard.assets": "Assets",
"admin.dashboard.assetsSize": "Assets Size",
"admin.dashboard.contributeHelp": "We need your help!",
"admin.dashboard.contributeLearnMore": "Learn More",
"admin.dashboard.contributeSubtitle": "Wiki.js is a free and open source project. There are several ways you can contribute to the project.",
@@ -414,6 +416,9 @@
"admin.dashboard.lastLogins": "Last Logins",
"admin.dashboard.lastLoginsNone": "No logins recorded yet.",
"admin.dashboard.mostPopularPages": "Most Popular Pages",
"admin.dashboard.newestPages": "Newest Pages",
"admin.dashboard.newestUsers": "Newest Users",
"admin.dashboard.newestUsersNone": "No users have been created yet.",
"admin.dashboard.pages": "Pages",
"admin.dashboard.recentPages": "Recently Edited",
"admin.dashboard.recentPagesAuthorGone": "Deleted user",
+14
View File
@@ -1234,6 +1234,20 @@ class Assets {
return results[0]?.preview ?? null
}
/**
* How many assets there are across every site, and their combined size in bytes — what the admin
* dashboard and the metrics endpoint both report.
*/
async totals(): Promise<{ count: number; bytes: number }> {
const results = await WIKI.db
.select({
count: sql<number>`count(*)::int`,
bytes: sql<number>`coalesce(sum(${assetsTable.fileSize}), 0)::bigint`.mapWith(Number)
})
.from(assetsTable)
return { count: results[0]?.count ?? 0, bytes: results[0]?.bytes ?? 0 }
}
// == SERVING CACHE ==================
/**
+8
View File
@@ -794,6 +794,14 @@ class Comments {
return Number(row?.total ?? 0)
}
/**
* How many comments there are across every site, for the admin dashboard. Counted the way the Talk
* tab's badge counts, so the placeholders of deleted comments are left out.
*/
async countAll(): Promise<number> {
return WIKI.db.$count(commentsTable, isNull(commentsTable.deletedAt))
}
/**
* One comment with the page it is on, which is what every permission check on it needs.
*
+4 -12
View File
@@ -5,7 +5,6 @@ import { RESERVED_ROOT_FILES } from '../helpers/common.ts'
import { classifyClientIp } from '../helpers/network.ts'
import type { ClientIpClass } from '../helpers/network.ts'
import {
assets as assetsTable,
groups as groupsTable,
jobs as jobsTable,
pageEditSubmissions as submissionsTable,
@@ -250,7 +249,7 @@ class Metrics {
groupsTotal,
sitesTotal,
tagsTotal,
assetsAggregate,
assetsTotals,
jobsQueued,
jobsActive,
submissionsPending,
@@ -262,12 +261,7 @@ class Metrics {
WIKI.db.$count(groupsTable),
WIKI.db.$count(sitesTable),
WIKI.db.$count(tagsTable),
WIKI.db
.select({
total: sql<number>`count(*)::int`,
bytes: sql<number>`coalesce(sum(${assetsTable.fileSize}), 0)::bigint`
})
.from(assetsTable),
WIKI.models.assets.totals(),
WIKI.db.$count(jobsTable),
WIKI.models.jobs.countActive(),
WIKI.db.$count(submissionsTable),
@@ -292,10 +286,8 @@ class Metrics {
gauge('wiki_groups_total', 'Groups.').set(groupsTotal)
gauge('wiki_sites_total', 'Sites served by this wiki.').set(sitesTotal)
gauge('wiki_tags_total', 'Distinct tags in use.').set(tagsTotal)
gauge('wiki_assets_total', 'Uploaded files.').set(assetsAggregate[0]?.total ?? 0)
gauge('wiki_assets_size_bytes', 'Total size of uploaded files.').set(
Number(assetsAggregate[0]?.bytes ?? 0)
)
gauge('wiki_assets_total', 'Uploaded files.').set(assetsTotals.count)
gauge('wiki_assets_size_bytes', 'Total size of uploaded files.').set(assetsTotals.bytes)
gauge('wiki_jobs_queued', 'Jobs waiting to be picked up by an instance.').set(jobsQueued)
gauge('wiki_jobs_active', 'Jobs running right now, across every instance.').set(jobsActive)
gauge('wiki_page_edit_submissions_pending', 'Suggested edits waiting for a reviewer.').set(
+57 -5
View File
@@ -430,6 +430,22 @@ export interface RecentPage {
authorName: string | null
}
/** One row of the admin dashboard's newest-pages panel. */
export interface NewestPage {
id: string
siteId: string
locale: string
path: string
title: string
createdAt: Date
/** As on `RecentPage`. */
url: string
/** As on `RecentPage`. */
hostname: string | null
/** Who created the page, whoever has written to it since. Null once that account is deleted. */
creatorName: string | null
}
/** A page as a sitemap entry sees it: where it is, when it last changed, and what it is a translation of. */
export interface SitemapPage {
locale: string
@@ -1343,15 +1359,51 @@ class Pages {
*/
isNew: row.createdAt.getTime() === row.updatedAt.getTime(),
url: this.urlFor(row.siteId, row.locale, row.path),
// -> `*` is the catch-all rather than a host; a link to it is whatever host you are already on
hostname:
WIKI.sites[row.siteId]?.hostname && WIKI.sites[row.siteId].hostname !== '*'
? WIKI.sites[row.siteId].hostname
: null,
hostname: this.dashboardHostnameFor(row.siteId),
authorName: row.authorName
}))
}
/**
* The pages created most recently, newest first — the admin dashboard's other page panel.
*
* Across every site, for the reason `getRecentlyEdited` gives. Ordered by `createdAt`, which no
* later save moves, and named after the page's creator rather than whoever wrote to it last.
*/
async getNewest({ limit = 10 }: { limit?: number } = {}): Promise<NewestPage[]> {
const rows = await WIKI.db
.select({
id: pagesTable.id,
siteId: pagesTable.siteId,
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
createdAt: pagesTable.createdAt,
creatorName: usersTable.name
})
.from(pagesTable)
// -> Left, so a page whose creator has since been deleted is still listed, without a name
.leftJoin(usersTable, eq(usersTable.id, pagesTable.creatorId))
.orderBy(desc(pagesTable.createdAt))
.limit(limit)
return rows.map((row) => ({
...row,
url: this.urlFor(row.siteId, row.locale, row.path),
hostname: this.dashboardHostnameFor(row.siteId)
}))
}
/**
* The host a dashboard row links to a page on, so a caller looking at one site can still reach a
* page on another. `*` is the catch-all rather than a host — a link to it is whatever host you are
* already on, hence null.
*/
private dashboardHostnameFor(siteId: string): string | null {
const hostname = WIKI.sites[siteId]?.hostname
return hostname && hostname !== '*' ? hostname : null
}
/**
* Every page of a site that belongs in its sitemap, cached.
*
+32
View File
@@ -51,6 +51,14 @@ export interface RecentLogin {
lastLoginAt: Date | null
}
/** A newly created account, as the admin dashboard lists it: identity and the moment only. */
export interface NewestUser {
id: string
name: string
email: string
createdAt: Date
}
/**
* An authentication provider linked to a user, as exposed by the API. Secrets held in the stored
* `auth` blob (the password hash, the TFA secret) are never included — `isPasswordSet` and
@@ -388,6 +396,30 @@ class Users {
.limit(limit)
}
/**
* Fetch the most recently created accounts, newest first.
*
* The same bargain as `getRecentLogins()`: a dashboard panel below `read:users`, so identity and the
* moment only. System accounts are left out — they are made when the wiki is installed, not by
* anybody joining it.
*
* @param limit How many to return
* @returns The newest accounts, newest first
*/
async getNewestUsers({ limit = 10 }: { limit?: number } = {}): Promise<NewestUser[]> {
return WIKI.db
.select({
id: usersTable.id,
name: usersTable.name,
email: usersTable.email,
createdAt: usersTable.createdAt
})
.from(usersTable)
.where(eq(usersTable.isSystem, false))
.orderBy(desc(usersTable.createdAt))
.limit(limit)
}
/**
* Fetch a page of users, optionally filtered by name or email
*
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="48px" height="48px"><g><radialGradient id="Yp1l7K9iBwNMskNzQGNbda" cx="23.764" cy="100.187" r="13.162" gradientTransform="matrix(.9728 0 0 -.9728 .653 115.634)" gradientUnits="userSpaceOnUse"><stop offset=".376" stop-color="#261103"/><stop offset=".68" stop-color="#431d06"/><stop offset="1" stop-color="#662c09"/></radialGradient><path fill="url(#Yp1l7K9iBwNMskNzQGNbda)" d="M24,6.587c-9.615,0-12,15.526-12,15.526c0,2.764,1.284,5.186,3.972,5.827 C18.274,28.49,21.172,29,24,29s5.726-0.51,8.028-1.059C34.716,27.299,36,24.877,36,22.114C36,22.113,33.615,6.587,24,6.587z"/><linearGradient id="Yp1l7K9iBwNMskNzQGNbdb" x1="24" x2="24" y1="96.599" y2="108.435" gradientTransform="matrix(1 0 0 -1 0 118)" gradientUnits="userSpaceOnUse"><stop offset=".125" stop-color="#ffdb80"/><stop offset=".141" stop-color="#fed97c"/><stop offset=".449" stop-color="#f4bf3b"/><stop offset=".594" stop-color="#f0b421"/></linearGradient><path fill="url(#Yp1l7K9iBwNMskNzQGNbdb)" d="M24,4.866c-3.455,0-7,2.525-7,9.991c0,0.598,0,4.571,0,5.21C17,22.733,21.2,26,24,26 s7-3.266,7-5.933c0-0.639,0-4.612,0-5.21C31,7.391,27.455,4.866,24,4.866z"/><linearGradient id="Yp1l7K9iBwNMskNzQGNbdglow" x1="24" x2="24" y1="26" y2="8" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff" stop-opacity="1"><animate attributeName="stop-color" dur="16.90s" keyTimes="0;0.0592;0.0621;0.0976;0.1006;0.2308;0.2337;0.2426;0.2456;0.2574;0.2604;0.3373;0.3402;0.3639;0.3669;0.5089;0.5118;0.5651;0.568;0.5828;0.5858;0.6746;0.6775;0.7071;0.7101;0.7189;0.7219;0.8343;0.8373;0.9024;0.9053;0.926;0.929;1" values="#fff;#fff;#fff;#fff;#0b1020;#0b1020;#fff;#fff;#0b1020;#0b1020;#eef4ff;#eef4ff;#fff;#fff;#0b1020;#0b1020;#fff;#fff;#eef4ff;#eef4ff;#fff;#fff;#0b1020;#0b1020;#fff;#fff;#eef4ff;#eef4ff;#0b1020;#0b1020;#fff;#fff;#fff;#fff" repeatCount="indefinite"/></stop><stop offset="1" stop-color="#fff" stop-opacity=".7"><animate attributeName="stop-color" dur="16.90s" keyTimes="0;0.0592;0.0621;0.0976;0.1006;0.2308;0.2337;0.2426;0.2456;0.2574;0.2604;0.3373;0.3402;0.3639;0.3669;0.5089;0.5118;0.5651;0.568;0.5828;0.5858;0.6746;0.6775;0.7071;0.7101;0.7189;0.7219;0.8343;0.8373;0.9024;0.9053;0.926;0.929;1" values="#fff;#fff;#fff;#fff;#0b1020;#0b1020;#fff;#fff;#0b1020;#0b1020;#eef4ff;#eef4ff;#fff;#fff;#0b1020;#0b1020;#fff;#fff;#eef4ff;#eef4ff;#fff;#fff;#0b1020;#0b1020;#fff;#fff;#eef4ff;#eef4ff;#0b1020;#0b1020;#fff;#fff;#fff;#fff" repeatCount="indefinite"/></stop></linearGradient><path fill="url(#Yp1l7K9iBwNMskNzQGNbdglow)" opacity="0" d="M24,4.866c-3.455,0-7,2.525-7,9.991c0,0.598,0,4.571,0,5.21C17,22.733,21.2,26,24,26 s7-3.266,7-5.933c0-0.639,0-4.612,0-5.21C31,7.391,27.455,4.866,24,4.866z"><animate attributeName="opacity" dur="16.90s" keyTimes="0;0.0592;0.0621;0.0976;0.1006;0.2308;0.2337;0.2426;0.2456;0.2574;0.2604;0.3373;0.3402;0.3639;0.3669;0.5089;0.5118;0.5651;0.568;0.5828;0.5858;0.6746;0.6775;0.7071;0.7101;0.7189;0.7219;0.8343;0.8373;0.9024;0.9053;0.926;0.929;1" values="0;0;0.45;0.45;0.22;0.22;0.4;0.4;0.22;0.22;0.3;0.3;0;0;0.3;0.3;0.5;0.5;0.25;0.25;0;0;0.25;0.25;0.35;0.35;0.3;0.3;0.2;0.2;0.45;0.45;0;0" repeatCount="indefinite"/></path><radialGradient id="Yp1l7K9iBwNMskNzQGNbdc" cx="17.431" cy="114.563" r="29.854" gradientTransform="matrix(1 0 0 -1 0 118)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c26715"/><stop offset=".508" stop-color="#b85515"/><stop offset="1" stop-color="#ad3f16"/></radialGradient><path fill="url(#Yp1l7K9iBwNMskNzQGNbdc)" d="M24,3c-4.789,0-5.538,2.769-5.538,2.769C16.515,6.056,12,6.692,12,21.903 c0,3.509,2.357,4.797,3,5.172C12.6,13.816,20,18,19,8c3.735,10.904,14.39-0.335,14,18.754c1.113-0.846,3-2.545,3-4.852 C36,9.159,30.83,3,24,3z"/><animateTransform attributeName="transform" type="rotate" dur="16.90s" calcMode="spline" keyTimes="0;0.2959;0.3314;0.497;0.5325;0.7101;0.7456;0.8876;0.9231;1" keySplines="0 0 1 1;.42 0 .58 1;0 0 1 1;.42 0 .58 1;0 0 1 1;.42 0 .58 1;0 0 1 1;.42 0 .58 1;0 0 1 1" values="0 24 26;0 24 26;-4 24 26;-4 24 26;0 24 26;0 24 26;6 24 26;6 24 26;0 24 26;0 24 26" repeatCount="indefinite"/></g><linearGradient id="Yp1l7K9iBwNMskNzQGNbdd" x1="23.451" x2="26.26" y1="96.713" y2="73.8" gradientTransform="matrix(1 0 0 -1 0 118)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#8795a1"/><stop offset="1" stop-color="#6d7882"/></linearGradient><path fill="url(#Yp1l7K9iBwNMskNzQGNbdd)" d="M40,21H8c-1.173,0-2.094,1.005-1.992,2.173l1.652,19C7.75,43.207,8.615,44,9.652,44h28.695 c1.037,0,1.903-0.793,1.992-1.827l1.652-19C42.094,22.005,41.173,21,40,21z"/><path fill="#a3b3c2" d="M26.476,36h-4.953c-0.129,0-0.236-0.099-0.246-0.227l-0.282-3.506C20.984,32.123,21.098,32,21.242,32 h5.516c0.144,0,0.258,0.123,0.246,0.267l-0.282,3.506C26.712,35.901,26.605,36,26.476,36z"/></svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><radialGradient id="~DsrNfjro9zLs0fMlYoo6a" cx="44.117" cy="32.782" r="21.667" gradientTransform="rotate(10.199 3.986 38.004)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#bd8af5"/><stop offset=".137" stop-color="#b88bf5"/><stop offset=".309" stop-color="#a88ff3"/><stop offset=".499" stop-color="#8f96f2"/><stop offset=".702" stop-color="#6b9eef"/><stop offset=".913" stop-color="#3eaaec"/><stop offset="1" stop-color="#29afea"/></radialGradient><path fill="url(#~DsrNfjro9zLs0fMlYoo6a)" d="M41.518,10.211L11.993,4.894c-1.087-0.196-2.127,0.527-2.323,1.614L4.353,36.033 C4.158,37.12,4.88,38.16,5.967,38.356l29.525,5.317c1.087,0.196,2.127-0.527,2.323-1.614l5.317-29.525 C43.328,11.447,42.605,10.407,41.518,10.211z"/><path fill="#436dcd" d="M32.105,25.234c-0.927-1.335-2.749-1.688-4.108-0.797l-10.615,6.957l-1.573,8.734l19.683,3.545 c1.087,0.196,2.127-0.527,2.323-1.614l1.231-6.835L32.105,25.234z"/><linearGradient id="~DsrNfjro9zLs0fMlYoo6b" x1="20.9" x2="20.9" y1="15.525" y2="38.391" gradientTransform="rotate(10.199 3.986 38.004)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#124787"/><stop offset=".923" stop-color="#173b75"/><stop offset="1" stop-color="#173a73"/></linearGradient><path fill="url(#~DsrNfjro9zLs0fMlYoo6b)" d="M37.467,42.867L21.004,19.171c-0.927-1.335-2.749-1.688-4.108-0.797L6.281,25.331 L4.353,36.033C4.158,37.12,4.88,38.16,5.967,38.356l29.525,5.317C36.276,43.814,37.031,43.473,37.467,42.867z"/><path d="M36,40c0.868,0,1.666-0.284,2.322-0.753L40,29.927V9.938L11.993,4.894 c-1.087-0.196-2.127,0.527-2.323,1.614L4.353,36.033C4.158,37.12,4.88,38.16,5.967,38.356L15.096,40H36z" opacity=".05"/><path d="M9.671,6.508l-5.187,28.8c-0.049,0.27-0.114,1.26,0.03,1.47c0.1,0.469,0.386,0.876,0.789,1.129 c0.179,0.167,1.182,0.408,1.446,0.408L14.261,39H34.81c0.434,0,2.023-0.142,2.351-0.377c0.552,0,1.277-1.986,1.277-2.538L39,29.789 V9.758L11.993,4.894C10.906,4.698,9.866,5.421,9.671,6.508z" opacity=".07"/><radialGradient id="~DsrNfjro9zLs0fMlYoo6c" cx="44.117" cy="32.782" r="21.667" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffcf7b"/><stop offset=".195" stop-color="#fecd76"/><stop offset=".439" stop-color="#fac666"/><stop offset=".71" stop-color="#f3bc4d"/><stop offset=".997" stop-color="#eaad29"/><stop offset="1" stop-color="#eaad29"/></radialGradient><path fill="url(#~DsrNfjro9zLs0fMlYoo6c)" d="M36,4H6C4.895,4,4,4.895,4,6v30c0,1.105,0.895,2,2,2h30c1.105,0,2-0.895,2-2V6 C38,4.895,37.105,4,36,4z"/><circle cx="28" cy="12" r="3" fill="#fff8de"/><path fill="#d6742d" d="M29.398,20.454c-1.149-1.149-3.005-1.174-4.185-0.057L16,29.126V38h20c1.105,0,2-0.895,2-2v-6.944 L29.398,20.454z"/><linearGradient id="~DsrNfjro9zLs0fMlYoo6d" x1="20.9" x2="20.9" y1="15.525" y2="38.391" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#c26715"/><stop offset=".508" stop-color="#b85515"/><stop offset="1" stop-color="#ad3f16"/></linearGradient><path fill="url(#~DsrNfjro9zLs0fMlYoo6d)" d="M37.801,36.856L17.398,16.454c-1.149-1.149-3.005-1.174-4.185-0.057L4,25.126V36 c0,1.105,0.895,2,2,2h30C36.797,38,37.479,37.531,37.801,36.856z"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+2 -1
View File
@@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
312 icons.
313 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-down": {"body":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@@ -86,6 +86,7 @@ export const BUNDLED_ICONS = {
"la:globe": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2a10 10 0 0 1 5.344 1.563l-.813.093l.188 2l-1.063-.469l-.875.75l.157 2.063l2.156-.688l2.687.876l-.687 1.25l-1.625-1l-1.75.25L18 13.967l-.969 3l1.938 1.595s1.988-.344 2.093-.344c.106 0 .844 1.812.844 1.812l-1.593 5c-1.301.621-2.77.969-4.313.969c-.316 0-.629-.035-.938-.063l-1.093-1.906l1.062-4L11 17H7.281l-.968-1.969L9 12.906L13 11l-.594-2.656l1.719-.375l.813 1.125l3-.563l-.532-2.312l-2.218-.157C15.453 6.043 15.726 6 16 6m-1.125.063l-1.563.656l-.75-.125a10 10 0 0 1 2.313-.532zM6.062 16.78l.97 1.125v2.063l1.874 2.062h1.156l2.844 3.5c-3.773-1.222-6.527-4.64-6.844-8.75\"/>","width":32,"height":32},
"la:grip-lines": {"body":"<path fill=\"currentColor\" d=\"M3 11v2h26v-2zm0 8v2h26v-2z\"/>","width":32,"height":32},
"la:hashtag": {"body":"<path fill=\"currentColor\" d=\"M11 5v6H5v2h6v6H5v2h6v6h2v-6h6v6h2v-6h6v-2h-6v-6h6v-2h-6V5h-2v6h-6V5zm2 8h6v6h-6z\"/>","width":32,"height":32},
"la:hdd": {"body":"<path fill=\"currentColor\" d=\"M6.219 6L3 18.875V26h26v-7.125L25.781 6zM7.78 8h16.44l2.5 10H5.28zM5 20h22v4H5zm19 1c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":32,"height":32},
"la:heading": {"body":"<path fill=\"currentColor\" d=\"M6 5v2h1v18H6v2h8v-2h-1v-6h6v6h-1v2h8v-2h-1V7h1V5h-8v2h1v6h-6V7h1V5zm3 2h2v8h10V7h2v18h-2v-8H11v8H9z\"/>","width":32,"height":32},
"la:heart": {"body":"<path fill=\"currentColor\" d=\"M9.5 5C5.363 5 2 8.402 2 12.5c0 1.43.648 2.668 1.25 3.563a9.3 9.3 0 0 0 1.219 1.468L15.28 28.375l.719.719l.719-.719L27.53 17.531S30 15.355 30 12.5C30 8.402 26.637 5 22.5 5c-3.434 0-5.645 2.066-6.5 2.938C15.145 7.066 12.934 5 9.5 5m0 2c2.988 0 5.75 2.906 5.75 2.906l.75.844l.75-.844S19.512 7 22.5 7c3.043 0 5.5 2.496 5.5 5.5c0 1.543-1.875 3.625-1.875 3.625L16 26.25L5.875 16.125s-.484-.465-.969-1.188C4.422 14.216 4 13.274 4 12.5C4 9.496 6.457 7 9.5 7\"/>","width":32,"height":32},
"la:highlighter": {"body":"<path fill=\"currentColor\" d=\"m23.625 3.063l-.719.624L7.563 17l-.5.469l.25.656s1.125 3-1.032 5.156v.032l-.031.03l-.156.188l-.125.125L2 27.531L7.375 29l2.063-2.063l.218-.187l.031-.031h.032c2.156-2.157 5.156-1.032 5.156-1.032l.656.25l.469-.5l13.313-15.343l.625-.719zm-.125 2.75L27.188 9.5l-8.75 10.063l-5-5zM11.937 15.874l5.188 5.188l-1.938 2.25l-5.5-5.5zM9.563 20.5l2.937 2.938c-1.242.046-2.746.437-4.156 1.812c-.02.02-.043.012-.063.031l-.25.219l-.531-.531l.219-.25l.031-.063c1.375-1.41 1.766-2.914 1.813-4.156\"/>","width":32,"height":32},
+226 -22
View File
@@ -145,7 +145,7 @@
<div class="col-span-12 sm:col-span-6 lg:col-span-3">
<w-card>
<w-card-section class="admin-dashboard-card">
<img src="/_assets/icons/fluent-female-working-with-a-laptop.svg" />
<img src="/_assets/icons/fluent-female-working-with-a-laptop-animated.svg" />
<div>
<strong>Logins</strong>
<small>{{ adminStore.info.loginsPastDay }} <i>/ past 24h</i></small>
@@ -154,18 +154,103 @@
<w-separator />
<w-card-actions align="right">
<!--
Guarded like every other action on this dashboard, and on the permission the Analytics
screen itself needs. Without this the one card action with no guard handed a live link
into a section `access:admin` alone cannot load -- every sibling button was already
disabled, so it read as the one thing such a user was meant to be able to open.
Guarded like every other action on this dashboard, and on the permission the
Authentication screen itself needs. Without this the one card action with no guard
handed a live link into a section `access:admin` alone cannot load -- every sibling
button was already disabled, so it read as the one thing such a user was meant to be
able to open.
-->
<w-btn
flat
:color="actionColor"
icon="la:chart-area"
:label="t(`admin.analytics.title`)"
icon="la:key"
:label="t(`common.actions.manage`)"
:disable="!userStore.can(`manage:system`)"
to="/_admin/auth" />
</w-card-actions>
</w-card>
</div>
<div class="col-span-12 sm:col-span-6 lg:col-span-3">
<w-card>
<w-card-section class="admin-dashboard-card">
<img src="/_assets/icons/fluent-document-in-folder.svg" />
<div>
<strong>{{ t('admin.dashboard.pages') }}</strong>
<span>{{ adminStore.info.pagesTotal }}</span>
</div>
</w-card-section>
<w-separator />
<w-card-actions align="right">
<w-btn
flat
:color="actionColor"
icon="la:history"
:label="t(`admin.audit.title`)"
:disable="!userStore.can(`read:audit`)"
to="/_admin/audit" />
</w-card-actions>
</w-card>
</div>
<div class="col-span-12 sm:col-span-6 lg:col-span-3">
<w-card>
<w-card-section class="admin-dashboard-card">
<img src="/_assets/icons/fluent-photo-gallery.svg" />
<div>
<strong>{{ t('admin.dashboard.assets') }}</strong>
<span>{{ adminStore.info.assetsTotal }}</span>
</div>
</w-card-section>
<w-separator />
<w-card-actions align="right">
<w-btn
flat
:color="actionColor"
icon="la:hdd"
:label="t(`admin.storage.title`)"
:disable="!userStore.can(`manage:storage`)"
:to="`/_admin/` + adminStore.currentSiteId + `/storage`" />
</w-card-actions>
</w-card>
</div>
<div class="col-span-12 sm:col-span-6 lg:col-span-3">
<w-card>
<w-card-section class="admin-dashboard-card">
<img src="/_assets/icons/fluent-database.svg" />
<div>
<strong>{{ t('admin.dashboard.assetsSize') }}</strong>
<span>{{ filesize(adminStore.info.assetsSize) }}</span>
</div>
</w-card-section>
<w-separator />
<w-card-actions align="right">
<w-btn
flat
:color="actionColor"
icon="la:hdd"
:label="t(`admin.storage.title`)"
:disable="!userStore.can(`manage:storage`)"
:to="`/_admin/` + adminStore.currentSiteId + `/storage`" />
</w-card-actions>
</w-card>
</div>
<div class="col-span-12 sm:col-span-6 lg:col-span-3">
<w-card>
<w-card-section class="admin-dashboard-card">
<img src="/_assets/icons/fluent-comments.svg" />
<div>
<strong>{{ t('admin.comments.title') }}</strong>
<span>{{ adminStore.info.commentsTotal }}</span>
</div>
</w-card-section>
<w-separator />
<w-card-actions align="right">
<w-btn
flat
:color="actionColor"
icon="la:comments"
:label="t(`common.actions.manage`)"
:disable="!userStore.can(`manage:sites`)"
:to="`/_admin/` + adminStore.currentSiteId + `/analytics`" />
:to="`/_admin/` + adminStore.currentSiteId + `/comments`" />
</w-card-actions>
</w-card>
</div>
@@ -270,7 +355,11 @@
</w-card-actions>
</w-card>
</div>
<div class="col-span-12 lg:col-span-6">
<!--
Two stacked columns rather than a grid row per pair, so each second panel sits right under
its first whatever the height of the panel across from it.
-->
<div class="col-span-12 lg:col-span-6 flex flex-col gap-2">
<w-card>
<w-card-section class="admin-dashboard-panel">
<img src="/_assets/icons/fluent-key-2.svg" />
@@ -279,10 +368,10 @@
<w-separator />
<w-list separator>
<!--
Rows link only where the user list is reachable, the same condition the Users card puts on
its Manage button: the panel itself is `access:admin`, and reading one account is
`read:users`, so for a reader without it a link would land on a refusal.
-->
Rows link only where the user list is reachable, the same condition the Users card puts on
its Manage button: the panel itself is `access:admin`, and reading one account is
`read:users`, so for a reader without it a link would land on a refusal.
-->
<w-item
v-for="lastLogin of state.lastLogins"
:key="lastLogin.id"
@@ -310,8 +399,43 @@
</w-item>
</w-list>
</w-card>
<w-card>
<w-card-section class="admin-dashboard-panel">
<img src="/_assets/icons/fluent-user-update.svg" />
<strong>{{ t('admin.dashboard.newestUsers') }}</strong>
</w-card-section>
<w-separator />
<w-list separator>
<!-- -> Linked on the same condition as the Last Logins rows above -->
<w-item
v-for="newestUser of state.newestUsers"
:key="newestUser.id"
:clickable="usersAreVisible"
:to="usersAreVisible ? `/_admin/users/` + newestUser.id : null">
<w-item-section side>
<w-icon name="la:user-plus" :color="actionColor" />
</w-item-section>
<w-item-section>
<w-item-label>{{ newestUser.name }}</w-item-label>
<w-item-label caption>{{ newestUser.email }}</w-item-label>
</w-item-section>
<w-item-section side>
<div class="text-caption">{{ relativeDate(newestUser.createdAt) }}</div>
<!-- -> The exact moment, in the reader's own pattern and zone, behind the rough one -->
<w-tooltip anchor="center left" self="center right">
{{ userStore.formatDateTime(t, newestUser.createdAt) }}
</w-tooltip>
</w-item-section>
</w-item>
<w-item v-if="state.newestUsers.length < 1">
<w-item-section>
<w-item-label caption>{{ t('admin.dashboard.newestUsersNone') }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
</w-card>
</div>
<div class="col-span-12 lg:col-span-6">
<div class="col-span-12 lg:col-span-6 flex flex-col gap-2">
<w-card>
<w-card-section class="admin-dashboard-panel">
<img src="/_assets/icons/fluent-copybook.svg" />
@@ -343,9 +467,13 @@
<!--
`url`, not the locale and path spelled out: the address is what a reader wants to
see under the title, and on a site that does not bracket its URLs by locale the
prefix is not part of it — printing one would name a path that 404s.
prefix is not part of it — printing one would name a path that 404s. The site's
name goes in front, since the same path can exist on every site of the instance.
-->
<w-item-label caption class="font-mono">{{ pg.url }}</w-item-label>
<w-item-label caption>
{{ siteTitleOf(pg) }} ·
<span class="font-mono">{{ pg.url }}</span>
</w-item-label>
</w-item-section>
<w-item-section side class="text-right">
<div class="text-caption">{{ relativeDate(pg.updatedAt) }}</div>
@@ -365,6 +493,48 @@
</w-item>
</w-list>
</w-card>
<w-card>
<w-card-section class="admin-dashboard-panel">
<img src="/_assets/icons/fluent-document-in-folder.svg" />
<strong>{{ t('admin.dashboard.newestPages') }}</strong>
</w-card-section>
<w-separator />
<w-list separator>
<!-- -> Linked the same two ways as the Recently Edited rows above -->
<w-item
v-for="pg of state.newestPages"
:key="pg.id"
clickable
:to="isCurrentSite(pg) ? pg.url : null"
:href="isCurrentSite(pg) ? null : externalPageUrl(pg)">
<w-item-section side>
<w-icon name="la:plus-circle" :color="actionColor" />
</w-item-section>
<w-item-section>
<w-item-label>{{ pg.title }}</w-item-label>
<w-item-label caption>
{{ siteTitleOf(pg) }} ·
<span class="font-mono">{{ pg.url }}</span>
</w-item-label>
</w-item-section>
<w-item-section side class="text-right">
<div class="text-caption">{{ relativeDate(pg.createdAt) }}</div>
<div class="text-caption text-grey">
{{ pg.creatorName || t('admin.dashboard.recentPagesAuthorGone') }}
</div>
<!-- -> The exact moment, in the reader's own pattern and zone, behind the rough one -->
<w-tooltip anchor="center left" self="center right">
{{ userStore.formatDateTime(t, pg.createdAt) }}
</w-tooltip>
</w-item-section>
</w-item>
<w-item v-if="state.newestPages.length < 1">
<w-item-section>
<w-item-label caption>{{ t('admin.dashboard.recentPagesNone') }}</w-item-label>
</w-item-section>
</w-item>
</w-list>
</w-card>
</div>
</div>
</w-page>
@@ -374,6 +544,7 @@
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { computed, onMounted, reactive } from 'vue'
import { filesize } from 'filesize'
import { useMeta } from '@/composables/meta'
import { dialog } from '@/composables/dialog'
@@ -429,6 +600,8 @@ const { t } = useI18n()
const state = reactive({
loading: 0,
lastLogins: [],
newestPages: [],
newestUsers: [],
recentPages: []
})
@@ -475,9 +648,9 @@ useMeta(() => ({
The counter cards read from the admin store, which `AdminLayout` fills once on mount -- `fetchInfo`
for the counters on `info`, `fetchSites` for the sites card, which counts the list itself.
The logins panel is fetched here instead, and kept on this page's own state: nothing else shows it,
and the store is filled by the layout that every admin screen mounts, so putting it there would ask
for these rows on every one of them.
The list panels are fetched here instead, and kept on this page's own state: nothing else shows
them, and the store is filled by the layout that every admin screen mounts, so putting them there
would ask for these rows on every one of them.
*/
// -> Reports its own failure rather than throwing on: one panel that could not be filled is not the
// whole dashboard failing to refresh
@@ -493,6 +666,19 @@ async function loadLastLogins() {
}
}
// -> Same bargain as the logins panel: its own state, its own failure
async function loadNewestUsers() {
try {
state.newestUsers = await API_CLIENT.get('users/newest').json()
} catch (err) {
notify({
type: 'negative',
message: 'Failed to load the newest users.',
caption: err.message
})
}
}
// -> Same bargain as the panel beside it: its own state, its own failure
async function loadRecentPages() {
try {
@@ -506,6 +692,19 @@ async function loadRecentPages() {
}
}
// -> Same bargain again
async function loadNewestPages() {
try {
state.newestPages = await API_CLIENT.get('pages/newest').json()
} catch (err) {
notify({
type: 'negative',
message: 'Failed to load the newest pages.',
caption: err.message
})
}
}
/**
* Whether a page belongs to the site this admin area is being browsed on.
*
@@ -516,14 +715,19 @@ function isCurrentSite(pg) {
return !pg.hostname || pg.siteId === siteStore.id
}
/** The name of the site a page belongs to, from the site list the admin layout has loaded. */
function siteTitleOf(pg) {
return adminStore.sites.find((site) => site.id === pg.siteId)?.title ?? pg.hostname ?? ''
}
/** A page on another site, as an absolute URL on that site's own host. */
function externalPageUrl(pg) {
return `${window.location.protocol}//${pg.hostname}${pg.url}`
}
/** The two panels this page fills itself, in parallel — neither waits on the other. */
/** The panels this page fills itself, in parallel — none waits on another. */
function loadPanels() {
return Promise.all([loadLastLogins(), loadRecentPages()])
return Promise.all([loadLastLogins(), loadNewestUsers(), loadRecentPages(), loadNewestPages()])
}
async function load() {
@@ -540,7 +744,7 @@ async function load() {
state.loading--
}
// -> The store is already filled by the layout; these two panels are what this page has to ask for
// -> The store is already filled by the layout; these panels are what this page has to ask for
onMounted(loadPanels)
function newSite() {
+7
View File
@@ -11,6 +11,9 @@ export const useAdminStore = defineStore('admin', {
currentVersion: 'n/a',
latestVersion: 'n/a',
activeWorkers: 0,
assetsSize: 0,
assetsTotal: 0,
commentsTotal: 0,
groupsTotal: 0,
instancesTotal: 0,
pagesTotal: 0,
@@ -71,7 +74,11 @@ export const useAdminStore = defineStore('admin', {
},
async fetchInfo() {
const resp = await API_CLIENT.get('system/info').json()
this.info.pagesTotal = resp?.pagesTotal ?? 0
this.info.activeWorkers = resp?.activeWorkers ?? 0
this.info.assetsSize = resp?.assetsSize ?? 0
this.info.assetsTotal = resp?.assetsTotal ?? 0
this.info.commentsTotal = resp?.commentsTotal ?? 0
this.info.groupsTotal = resp?.groupsTotal ?? 0
this.info.instancesTotal = resp?.instancesTotal ?? 0
this.info.tagsTotal = resp?.tagsTotal ?? 0