mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
feat: add more stats to admin dashboard
This commit is contained in:
14 files changed
+489
-40
No files matched your search
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ==================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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 |
@@ -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},
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user