diff --git a/backend/api/pages.ts b/backend/api/pages.ts index 8ec24903d..53d097a72 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -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 */ diff --git a/backend/api/system.ts b/backend/api/system.ts index aaf0e8bb7..687e8833c 100644 --- a/backend/api/system.ts +++ b/backend/api/system.ts @@ -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, diff --git a/backend/api/users.ts b/backend/api/users.ts index b7639e4da..54848cc25 100644 --- a/backend/api/users.ts +++ b/backend/api/users.ts @@ -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', { diff --git a/backend/locales/en.json b/backend/locales/en.json index 0b8afd121..0552f46d7 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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", diff --git a/backend/models/assets.ts b/backend/models/assets.ts index d58b096e3..4d8fde195 100644 --- a/backend/models/assets.ts +++ b/backend/models/assets.ts @@ -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`count(*)::int`, + bytes: sql`coalesce(sum(${assetsTable.fileSize}), 0)::bigint`.mapWith(Number) + }) + .from(assetsTable) + return { count: results[0]?.count ?? 0, bytes: results[0]?.bytes ?? 0 } + } + // == SERVING CACHE ================== /** diff --git a/backend/models/comments.ts b/backend/models/comments.ts index 92317be17..8de57fc76 100644 --- a/backend/models/comments.ts +++ b/backend/models/comments.ts @@ -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 { + 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. * diff --git a/backend/models/metrics.ts b/backend/models/metrics.ts index 400d2296d..650cad555 100644 --- a/backend/models/metrics.ts +++ b/backend/models/metrics.ts @@ -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`count(*)::int`, - bytes: sql`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( diff --git a/backend/models/pages.ts b/backend/models/pages.ts index a1cf15d56..64077c4d8 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -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 { + 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. * diff --git a/backend/models/users.ts b/backend/models/users.ts index 1f4b7acdd..fd6cf1fce 100644 --- a/backend/models/users.ts +++ b/backend/models/users.ts @@ -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 { + 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 * diff --git a/frontend/public/_assets/icons/fluent-female-working-with-a-laptop-animated.svg b/frontend/public/_assets/icons/fluent-female-working-with-a-laptop-animated.svg new file mode 100644 index 000000000..eb954a733 --- /dev/null +++ b/frontend/public/_assets/icons/fluent-female-working-with-a-laptop-animated.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/fluent-photo-gallery.svg b/frontend/public/_assets/icons/fluent-photo-gallery.svg new file mode 100644 index 000000000..a283d12cf --- /dev/null +++ b/frontend/public/_assets/icons/fluent-photo-gallery.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 9802249cb..452a74acc 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -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":"","width":32,"height":32}, @@ -86,6 +86,7 @@ export const BUNDLED_ICONS = { "la:globe": {"body":"","width":32,"height":32}, "la:grip-lines": {"body":"","width":32,"height":32}, "la:hashtag": {"body":"","width":32,"height":32}, + "la:hdd": {"body":"","width":32,"height":32}, "la:heading": {"body":"","width":32,"height":32}, "la:heart": {"body":"","width":32,"height":32}, "la:highlighter": {"body":"","width":32,"height":32}, diff --git a/frontend/src/pages/AdminDashboard.vue b/frontend/src/pages/AdminDashboard.vue index 174a259ae..2b0a14d8a 100644 --- a/frontend/src/pages/AdminDashboard.vue +++ b/frontend/src/pages/AdminDashboard.vue @@ -145,7 +145,7 @@
- +
Logins {{ adminStore.info.loginsPastDay }} / past 24h @@ -154,18 +154,103 @@ + + +
+
+ + + +
+ {{ t('admin.dashboard.pages') }} + {{ adminStore.info.pagesTotal }} +
+
+ + + + +
+
+
+ + + +
+ {{ t('admin.dashboard.assets') }} + {{ adminStore.info.assetsTotal }} +
+
+ + + + +
+
+
+ + + +
+ {{ t('admin.dashboard.assetsSize') }} + {{ filesize(adminStore.info.assetsSize) }} +
+
+ + + + +
+
+
+ + + +
+ {{ t('admin.comments.title') }} + {{ adminStore.info.commentsTotal }} +
+
+ + + + :to="`/_admin/` + adminStore.currentSiteId + `/comments`" />
@@ -270,7 +355,11 @@
-
+ +
@@ -279,10 +368,10 @@ + 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. + --> + + + + {{ t('admin.dashboard.newestUsers') }} + + + + + + + + + + {{ newestUser.name }} + {{ newestUser.email }} + + +
{{ relativeDate(newestUser.createdAt) }}
+ + + {{ userStore.formatDateTime(t, newestUser.createdAt) }} + +
+
+ + + {{ t('admin.dashboard.newestUsersNone') }} + + +
+
-
+
@@ -343,9 +467,13 @@ - {{ pg.url }} + + {{ siteTitleOf(pg) }} · + {{ pg.url }} +
{{ relativeDate(pg.updatedAt) }}
@@ -365,6 +493,48 @@
+ + + + {{ t('admin.dashboard.newestPages') }} + + + + + + + + + + {{ pg.title }} + + {{ siteTitleOf(pg) }} · + {{ pg.url }} + + + +
{{ relativeDate(pg.createdAt) }}
+
+ {{ pg.creatorName || t('admin.dashboard.recentPagesAuthorGone') }} +
+ + + {{ userStore.formatDateTime(t, pg.createdAt) }} + +
+
+ + + {{ t('admin.dashboard.recentPagesNone') }} + + +
+
@@ -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() { diff --git a/frontend/src/stores/admin.js b/frontend/src/stores/admin.js index a800131c9..9b73285fd 100644 --- a/frontend/src/stores/admin.js +++ b/frontend/src/stores/admin.js @@ -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