kopia lustrzana https://github.com/Stopka/fedisearch
Porównaj commity
3 Commity
75c9fd63e7
...
21e2ac6172
Autor | SHA1 | Data |
---|---|---|
Štěpán Škorpil | 21e2ac6172 | |
Štěpán Škorpil | 0336e2183c | |
Štěpán Škorpil | 59c1ee743d |
11
Dockerfile
11
Dockerfile
|
@ -1,21 +1,22 @@
|
|||
FROM node:16-bullseye AS build
|
||||
ENV POSTGRES_URL='postgresql://fedisearch:passwd@postgres:5432/fedisearch?schema=public' \
|
||||
FROM node:18-bullseye AS prebuild
|
||||
ENV ELASTIC_URL='http://elastic:9200' \
|
||||
ELASTIC_USER='elastic' \
|
||||
ELASTIC_PASSWORD='' \
|
||||
MATOMO_URL='' \
|
||||
MATOMO_SITE_ID='' \
|
||||
STATS_CACHE_MINUTES=60 \
|
||||
TZ='UTC'
|
||||
FROM prebuild AS build
|
||||
WORKDIR /srv
|
||||
COPY application/package*.json ./
|
||||
COPY application/prisma ./prisma/
|
||||
RUN npm install --frozen-lockfile
|
||||
RUN npm run prisma:generate
|
||||
COPY application/. .
|
||||
RUN npm run build
|
||||
|
||||
FROM build as dev
|
||||
CMD npm run dev
|
||||
|
||||
FROM node:16-bullseye AS prod
|
||||
FROM prebuild AS prod
|
||||
RUN groupadd -g 1001 nodejs
|
||||
RUN useradd -u 1001 -g 1001 nextjs
|
||||
USER nextjs
|
||||
|
|
14
README.md
14
README.md
|
@ -10,12 +10,14 @@ Only fulltext search is currently supported. More precise filtering is planned f
|
|||
|
||||
Configuration is done using environmental variables:
|
||||
|
||||
| Variable | Description | Value example |
|
||||
|-----------------------|--------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------|
|
||||
| `POSTGRES_URL` | Postgres database uri | `postgresql://fedisearch:passwd@postgres:5432/fedisearch?schema=public` |
|
||||
| `MATOMO_URL` | Optional url of Matomo server for collecting usage statistics. Leaving it empty disables collecting analytics. | `https://matomo.myserver.tld` |
|
||||
| `MATOMO_SITE_ID` | Optional Matomo site id parameter for collecting usage statistics. Leaving it empty disables collecting analytics. | `8` |
|
||||
| `STATS_CACHE_MINUTES` | Optional number of minutes to cache heavily calculated stats data | `60` |
|
||||
| Variable | Description | Value example |
|
||||
|-----------------------|--------------------------------------------------------------------------------------------------------------------|-------------------------------|
|
||||
| `ELASTIC_URL` | Url address of ElasticSearch server | `http://elastic:9200` |
|
||||
| `ELASTIC_USER` | Username for EalsticSearch server | `elastic` |
|
||||
| `ELASTIC_PASSWORD` | Username for EalsticSearch server | empty |
|
||||
| `MATOMO_URL` | Optional url of Matomo server for collecting usage statistics. Leaving it empty disables collecting analytics. | `https://matomo.myserver.tld` |
|
||||
| `MATOMO_SITE_ID` | Optional Matomo site id parameter for collecting usage statistics. Leaving it empty disables collecting analytics. | `8` |
|
||||
| `STATS_CACHE_MINUTES` | Optional number of minutes to cache heavily calculated stats data | `60` |
|
||||
|
||||
## Deploy
|
||||
|
||||
|
|
Plik diff jest za duży
Load Diff
|
@ -10,26 +10,30 @@
|
|||
"dev": "next dev ./src --hostname 0.0.0.0",
|
||||
"build": "next build ./src",
|
||||
"start": "next start ./src",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"prisma:generate": "npx prisma generate"
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.9",
|
||||
"@datapunt/matomo-tracker-js": "^0.5.1",
|
||||
"@elastic/elasticsearch": "^8.2.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.36",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.15.4",
|
||||
"@fortawesome/react-fontawesome": "^0.1.17",
|
||||
"@hookform/resolvers": "^2.8.5",
|
||||
"@prisma/client": "^3.6.0",
|
||||
"@svgr/webpack": "^6.2.1",
|
||||
"apollo-server-micro": "^3.10.1",
|
||||
"axios": "^0.21.1",
|
||||
"bootstrap": "^5.1.3",
|
||||
"next": "^12.0.7",
|
||||
"graphql": "^16.5.0",
|
||||
"micro-cors": "^0.1.1",
|
||||
"next": "^12.2.5",
|
||||
"nexus": "^1.3.0",
|
||||
"node-cache": "^5.1.2",
|
||||
"npmlog": "^6.0.0",
|
||||
"react": "17.0.2",
|
||||
"react-dom": "17.0.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"sass": "^1.45.1",
|
||||
"striptags": "^3.2.0",
|
||||
"typescript-collections": "^1.3.3",
|
||||
|
@ -38,7 +42,8 @@
|
|||
"devDependencies": {
|
||||
"@next/eslint-plugin-next": "^12.0.7",
|
||||
"@types/jest": "^27.0.2",
|
||||
"@types/node": "^16.11.10",
|
||||
"@types/micro-cors": "^0.1.2",
|
||||
"@types/node": "^18.7.18",
|
||||
"@types/npmlog": "^4.1.3",
|
||||
"@types/react": "^17.0.14",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.0",
|
||||
|
@ -52,7 +57,6 @@
|
|||
"eslint-plugin-react": "^7.27.1",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"jest": "^27.3.0",
|
||||
"prisma": "^3.6.0",
|
||||
"standard": "*",
|
||||
"ts-jest": "^27.0.7",
|
||||
"typescript": "^4.3.5"
|
||||
|
|
|
@ -1,115 +0,0 @@
|
|||
datasource db {
|
||||
url = env("POSTGRES_URL")
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["extendedIndexes","fullTextSearch","referentialActions"]
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String @unique
|
||||
feedToTag FeedToTag[]
|
||||
}
|
||||
|
||||
model Email {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
address String
|
||||
feed Feed @relation(fields: [feedId], references: [id], onDelete: Cascade)
|
||||
feedId String @db.Uuid
|
||||
|
||||
@@index([address])
|
||||
}
|
||||
|
||||
model FeedToTag {
|
||||
feed Feed @relation(fields: [feedId], references: [id], onDelete: Cascade)
|
||||
feedId String @db.Uuid
|
||||
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||
tagId String @db.Uuid
|
||||
|
||||
@@id([feedId, tagId])
|
||||
}
|
||||
|
||||
model Field {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
value String
|
||||
feed Feed @relation(fields: [feedId], references: [id], onDelete: Cascade)
|
||||
feedId String @db.Uuid
|
||||
|
||||
@@index([name])
|
||||
@@index([value])
|
||||
}
|
||||
|
||||
enum FeedType{
|
||||
account
|
||||
channel
|
||||
}
|
||||
|
||||
model Feed {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
node Node @relation(fields: [nodeId], references: [id], onDelete: Cascade)
|
||||
nodeId String @db.Uuid
|
||||
foundAt DateTime @default(now())
|
||||
refreshedAt DateTime @updatedAt
|
||||
name String
|
||||
displayName String
|
||||
description String
|
||||
feedToTags FeedToTag[]
|
||||
fields Field[]
|
||||
emails Email[]
|
||||
followersCount Int?
|
||||
followingCount Int?
|
||||
statusesCount Int?
|
||||
bot Boolean?
|
||||
url String
|
||||
avatar String?
|
||||
locked Boolean
|
||||
lastStatusAt DateTime?
|
||||
createdAt DateTime
|
||||
type FeedType @default(account)
|
||||
parentFeedName String?
|
||||
parentFeedDomain String?
|
||||
fulltext String @default("")
|
||||
|
||||
@@index([displayName])
|
||||
@@index([description])
|
||||
@@index([bot])
|
||||
@@index([locked])
|
||||
@@index([lastStatusAt])
|
||||
@@index([createdAt])
|
||||
@@index([refreshedAt])
|
||||
@@index([parentFeedName,parentFeedDomain])
|
||||
@@index([type])
|
||||
@@index([fulltext])
|
||||
@@unique([name, nodeId])
|
||||
}
|
||||
|
||||
model Node {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
softwareName String?
|
||||
softwareVersion String?
|
||||
totalUserCount Int?
|
||||
monthActiveUserCount Int?
|
||||
halfYearActiveUserCount Int?
|
||||
statusesCount Int?
|
||||
openRegistrations Boolean?
|
||||
foundAt DateTime @default(now())
|
||||
refreshedAt DateTime?
|
||||
refreshAttemptedAt DateTime?
|
||||
domain String @unique
|
||||
feeds Feed[]
|
||||
|
||||
@@index([softwareName])
|
||||
@@index([softwareVersion])
|
||||
@@index([totalUserCount])
|
||||
@@index([monthActiveUserCount])
|
||||
@@index([halfYearActiveUserCount])
|
||||
@@index([statusesCount])
|
||||
@@index([openRegistrations])
|
||||
@@index([refreshedAt])
|
||||
@@index([refreshAttemptedAt])
|
||||
@@index([foundAt])
|
||||
}
|
|
@ -1,18 +1,13 @@
|
|||
import React from 'react'
|
||||
import FallbackImage from './FallbackImage'
|
||||
|
||||
const Avatar:React.FC<{url:string|null|undefined}> = ({ url }) => {
|
||||
const fallbackImage = '/avatar.svg'
|
||||
|
||||
const handleAvatarImageError = (event) => {
|
||||
event.target.src = fallbackImage
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
<FallbackImage
|
||||
className={'avatar'}
|
||||
src={url ?? fallbackImage}
|
||||
src={url}
|
||||
fallbackSrc={'/avatar.svg'}
|
||||
alt={'Avatar'}
|
||||
onError={handleAvatarImageError}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import React, { ImgHTMLAttributes, useEffect, useState } from 'react'
|
||||
export default function FallbackImage ({ fallbackSrc, src, alt, ...props }: ImgHTMLAttributes<HTMLImageElement>&{fallbackSrc?:string}) {
|
||||
const [showFallback, setShowFallback] = useState<boolean>(false)
|
||||
useEffect(() => {
|
||||
setShowFallback(!src)
|
||||
}, [src])
|
||||
const handleError = (event): void => {
|
||||
if (props.onError) {
|
||||
props.onError(event)
|
||||
}
|
||||
if (!fallbackSrc) {
|
||||
return
|
||||
}
|
||||
setShowFallback(true)
|
||||
}
|
||||
if (showFallback) {
|
||||
return <img src={fallbackSrc} alt={alt} onError={handleError} {...props}/>
|
||||
}
|
||||
return <img src={src} alt={alt} onError={handleError} {...props}/>
|
||||
}
|
|
@ -6,13 +6,15 @@ import FeedTypeBadge from './badges/FeedTypeBadge'
|
|||
import CreatedAtBadge from './badges/CreatedAtBadge'
|
||||
import LastPostAtBadge from './badges/LastPostAtBadge'
|
||||
import BotBadge from './badges/BotBadge'
|
||||
import { FeedResponseField, FeedResponseItem } from '../types/FeedResponse'
|
||||
import ParentFeed from './ParentFeed'
|
||||
import StatusesCountBadge from './badges/StatusesCountBadge'
|
||||
import FollowersBadge from './badges/FollowersBadge'
|
||||
import FollowingBadge from './badges/FollowingBadge'
|
||||
import { FeedResultItem } from '../graphql/client/queries/ListFeedsQuery'
|
||||
|
||||
const FeedResult: React.FC<{ feed: FeedResponseItem }> = ({ feed }) => {
|
||||
const FeedResult = ({
|
||||
feed
|
||||
}:{ feed: FeedResultItem }) => {
|
||||
const fallbackEmojiImage = '/emoji.svg'
|
||||
|
||||
const handleEmojiImageError = (event) => {
|
||||
|
@ -39,14 +41,14 @@ const FeedResult: React.FC<{ feed: FeedResponseItem }> = ({ feed }) => {
|
|||
</h3>
|
||||
<Avatar url={feed.avatar}/>
|
||||
<div className={'address'}>
|
||||
<span>{feed.name}@{feed.node.domain}</span>
|
||||
<ParentFeed feed={feed.parentFeed}/>
|
||||
<span>{feed.id}</span>
|
||||
<ParentFeed feed={feed.parent}/>
|
||||
</div>
|
||||
<SoftwareBadge softwareName={feed.node.softwareName}/>
|
||||
<div className={'badges'}>
|
||||
<FeedTypeBadge type={feed.type}/>
|
||||
<FollowersBadge followers={feed.followersCount} />
|
||||
<FollowingBadge following={feed.followingCount} />
|
||||
<FollowersBadge followers={feed.followersCount}/>
|
||||
<FollowingBadge following={feed.followingCount}/>
|
||||
<StatusesCountBadge statusesCount={feed.statusesCount}/>
|
||||
<CreatedAtBadge createdAt={feed.createdAt}/>
|
||||
<LastPostAtBadge lastStatusAt={feed.lastStatusAt}/>
|
||||
|
@ -54,24 +56,24 @@ const FeedResult: React.FC<{ feed: FeedResponseItem }> = ({ feed }) => {
|
|||
</div>
|
||||
{feed.fields.length > 0
|
||||
? (
|
||||
<div className={'table-responsive fields'}>
|
||||
<table className={'table'}>
|
||||
<tbody>
|
||||
{
|
||||
feed.fields.map((field: FeedResponseField, index: number): React.ReactNode => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<th className={'with-emoji table-active'}
|
||||
dangerouslySetInnerHTML={{ __html: striptags(field.name, ['a', 'strong', 'em', 'img']) }}/>
|
||||
<td className={'with-emoji'}
|
||||
dangerouslySetInnerHTML={{ __html: striptags(field.value, ['a', 'strong', 'em', 'img']) }}/>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className={'table-responsive fields'}>
|
||||
<table className={'table'}>
|
||||
<tbody>
|
||||
{
|
||||
feed.fields.map((field, index: number): React.ReactNode => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<th className={'with-emoji table-active'}
|
||||
dangerouslySetInnerHTML={{ __html: striptags(field.name, ['a', 'strong', 'em', 'img']) }}/>
|
||||
<td className={'with-emoji'}
|
||||
dangerouslySetInnerHTML={{ __html: striptags(field.value, ['a', 'strong', 'em', 'img']) }}/>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
: ''}
|
||||
<div className={'description with-emoji'}
|
||||
|
|
|
@ -1,20 +1,22 @@
|
|||
import React from 'react'
|
||||
import FeedResult from './FeedResult'
|
||||
import { FeedResponseItem } from '../types/FeedResponse'
|
||||
import { FeedResultItem } from '../graphql/client/queries/ListFeedsQuery'
|
||||
|
||||
const FeedResults:React.FC<{feeds:FeedResponseItem[]}> = ({ feeds }) => {
|
||||
const FeedResults = ({
|
||||
feeds
|
||||
}:{ feeds: FeedResultItem[] }) => {
|
||||
if (feeds.length === 0) {
|
||||
return (
|
||||
<>
|
||||
<p className={'no-results'}>We have no results for your query.</p>
|
||||
</>
|
||||
<>
|
||||
<p className={'no-results'}>We have no results for your query.</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (<div className={'grid'}>
|
||||
{
|
||||
feeds.map((feed, index) => {
|
||||
console.info('feed', feed)
|
||||
return (<FeedResult key={index} feed={feed}/>)
|
||||
return (<FeedResult key={index} feed={feed} />)
|
||||
})
|
||||
}
|
||||
</div>)
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
import React from 'react'
|
||||
|
||||
export function getFlagEmoji (countryCode:string):string {
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0))
|
||||
return String.fromCodePoint(...codePoints)
|
||||
}
|
||||
|
||||
export interface GeoParams{
|
||||
countryCode?: string,
|
||||
countryName?:string,
|
||||
city?: string
|
||||
}
|
||||
|
||||
export default function Geo ({ countryCode, countryName, city }:GeoParams):React.ReactElement|null {
|
||||
if (!countryCode && !city) {
|
||||
return null
|
||||
}
|
||||
return <div className={'geo'}>
|
||||
{city ? <span className={'city'}>{city}</span> : ''}
|
||||
{countryCode ? <span className={'country'} title={`${countryName ?? countryCode}`}>{getFlagEmoji(countryCode)}</span> : ''}
|
||||
</div>
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useState } from 'react'
|
||||
import NavItem from './NavItem'
|
||||
import { faUser, faServer, faChartPie } from '@fortawesome/free-solid-svg-icons'
|
||||
import FallbackImage from './FallbackImage'
|
||||
|
||||
const NavBar:React.FC = () => {
|
||||
const [showMenu, setShowMenu] = useState<boolean>(false)
|
||||
|
@ -8,7 +9,7 @@ const NavBar:React.FC = () => {
|
|||
<nav className="navbar navbar-expand-lg navbar-dark bg-dark mb-4">
|
||||
<div className="container-fluid">
|
||||
<a className="navbar-brand" href={'/'}>
|
||||
<img
|
||||
<FallbackImage
|
||||
src="/fedisearch.svg"
|
||||
alt={'FediSearch logo'}
|
||||
className="d-inline-block align-text-top logo"
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import React from 'react'
|
||||
import Avatar from './Avatar'
|
||||
import { FeedResponseParent } from '../types/FeedResponse'
|
||||
import { ParentFeedItem } from '../graphql/client/queries/ListFeedsQuery'
|
||||
|
||||
const ParentFeed: React.FC<{feed:FeedResponseParent|null}> = ({ feed }) => {
|
||||
if (feed === null) {
|
||||
const ParentFeed: React.FC<{feed:ParentFeedItem|null}> = ({ feed }) => {
|
||||
if (!feed) {
|
||||
return (<></>)
|
||||
}
|
||||
return (
|
||||
|
|
|
@ -1,21 +1,18 @@
|
|||
import React from 'react'
|
||||
import FallbackImage from '../FallbackImage'
|
||||
|
||||
const SoftwareBadge:React.FC<{softwareName:string|null}> = ({ softwareName }) => {
|
||||
const SoftwareBadge: React.FC<{ softwareName: string | null }> = ({ softwareName }) => {
|
||||
const fallbackImage = '/software/fediverse.svg'
|
||||
|
||||
const handleSoftwareImageError = (event) => {
|
||||
event.target.src = fallbackImage
|
||||
}
|
||||
|
||||
return (<div className={'software-name'} title={'Software name'}>
|
||||
<img className={'icon'}
|
||||
src={softwareName !== null ? `/software/${softwareName}.svg` : fallbackImage}
|
||||
alt={softwareName}
|
||||
title={softwareName}
|
||||
onError={handleSoftwareImageError}
|
||||
/>
|
||||
<span className={'value'}>{softwareName}</span>
|
||||
</div>)
|
||||
<FallbackImage className={'icon'}
|
||||
src={softwareName !== null ? `/software/${softwareName}.svg` : fallbackImage}
|
||||
fallbackSrc={fallbackImage}
|
||||
alt={softwareName}
|
||||
title={softwareName}
|
||||
/>
|
||||
<span className={'value'}>{softwareName}</span>
|
||||
</div>)
|
||||
}
|
||||
|
||||
export default SoftwareBadge
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
import { ApolloClient, InMemoryCache } from '@apollo/client'
|
||||
|
||||
export default function createGraphqlClient () {
|
||||
return new ApolloClient({
|
||||
uri: '/api/graphql',
|
||||
cache: new InMemoryCache()
|
||||
})
|
||||
}
|
|
@ -0,0 +1,112 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { List } from '../types/List'
|
||||
|
||||
export const ListFeedsQuery = gql`
|
||||
query ListFeeds($paging: PagingInput, $query: FeedQueryInput) {
|
||||
listFeeds(paging: $paging,query: $query){
|
||||
paging {
|
||||
hasNext
|
||||
},
|
||||
items {
|
||||
id,
|
||||
avatar,
|
||||
displayName,
|
||||
foundAt,
|
||||
bot,
|
||||
createdAt,
|
||||
description,
|
||||
displayName,
|
||||
followersCount,
|
||||
followingCount,
|
||||
lastStatusAt,
|
||||
locked,
|
||||
name,
|
||||
refreshedAt,
|
||||
statusesCount,
|
||||
type,
|
||||
url,
|
||||
fields {
|
||||
name,value
|
||||
}
|
||||
node {
|
||||
domain,
|
||||
foundAt,
|
||||
geoip {
|
||||
city_name,
|
||||
country_iso_code,
|
||||
},
|
||||
halfYearActiveUserCount,
|
||||
id,
|
||||
monthActiveUserCount,
|
||||
name,
|
||||
openRegistrations,
|
||||
refreshAttemptedAt,
|
||||
refreshedAt,
|
||||
softwareName
|
||||
},
|
||||
parent {
|
||||
id,
|
||||
avatar,
|
||||
displayName
|
||||
name,
|
||||
domain,
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export type ParentFeedItem = {
|
||||
id: string,
|
||||
avatar: string,
|
||||
displayName: string
|
||||
name: string
|
||||
domain: string
|
||||
url:string
|
||||
}
|
||||
|
||||
export type FeedResultItem = {
|
||||
id: string,
|
||||
avatar: string,
|
||||
displayName: string,
|
||||
foundAt: string,
|
||||
bot: boolean,
|
||||
createdAt: string,
|
||||
description: string,
|
||||
followersCount: number,
|
||||
followingCount: number,
|
||||
lastStatusAt: string,
|
||||
locked: boolean,
|
||||
name: string,
|
||||
refreshedAt: string,
|
||||
statusesCount: number,
|
||||
type: 'account' | 'channel'
|
||||
url: string,
|
||||
fields: {
|
||||
name: string, value: string
|
||||
}[],
|
||||
node: {
|
||||
domain: string,
|
||||
foundAt: string,
|
||||
geoip: {
|
||||
// eslint-disable-next-line camelcase
|
||||
city_name: string,
|
||||
// eslint-disable-next-line camelcase
|
||||
country_iso_code: string,
|
||||
},
|
||||
halfYearActiveUserCount: number,
|
||||
id: string,
|
||||
monthActiveUserCount: number,
|
||||
name: string,
|
||||
openRegistrations: boolean,
|
||||
refreshAttemptedAt: string,
|
||||
refreshedAt: string,
|
||||
softwareName: string
|
||||
},
|
||||
parent: ParentFeedItem|null
|
||||
}
|
||||
|
||||
export type ListFeedsResult = {
|
||||
listFeeds: List<FeedResultItem>
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { List } from '../types/List'
|
||||
|
||||
export const ListNodesQuery = gql`
|
||||
query ListNodes($paging: PagingInput, $query: NodeQueryInput) {
|
||||
listNodes(paging: $paging,query: $query){
|
||||
paging {
|
||||
hasNext
|
||||
}
|
||||
items {
|
||||
domain,
|
||||
foundAt,
|
||||
geoip {
|
||||
city_name,country_iso_code
|
||||
},
|
||||
halfYearActiveUserCount,
|
||||
id,
|
||||
monthActiveUserCount,
|
||||
accountFeedCount,
|
||||
name,
|
||||
openRegistrations,
|
||||
refreshAttemptedAt,
|
||||
refreshedAt,
|
||||
serverIps,
|
||||
softwareName,
|
||||
softwareVersion,
|
||||
standardizedSoftwareVersion,
|
||||
statusesCount,
|
||||
totalUserCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export type NodeResultItem = {
|
||||
domain: string,
|
||||
foundAt: string,
|
||||
geoip: {
|
||||
// eslint-disable-next-line camelcase
|
||||
city_name: string,
|
||||
// eslint-disable-next-line camelcase
|
||||
country_iso_code: string,
|
||||
},
|
||||
halfYearActiveUserCount: number,
|
||||
id: string,
|
||||
monthActiveUserCount: number,
|
||||
name: string,
|
||||
openRegistrations: boolean,
|
||||
refreshAttemptedAt: string,
|
||||
refreshedAt: string,
|
||||
softwareName: string,
|
||||
softwareVersion: string,
|
||||
standardizedSoftwareVersion:string,
|
||||
totalUserCount: number,
|
||||
statusesCount: number,
|
||||
accountFeedCount: number
|
||||
}
|
||||
|
||||
export type ListNodesResult = {
|
||||
listNodes: List<NodeResultItem>;
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { gql } from '@apollo/client'
|
||||
import { List } from '../types/List'
|
||||
|
||||
export const ListStatsQuery = gql`
|
||||
query ListStats($query: StatsQueryInput) {
|
||||
listStats(query:$query) {
|
||||
items {
|
||||
softwareName
|
||||
nodeCount
|
||||
accountFeedCount
|
||||
channelFeedCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export type StatsResultItem = {
|
||||
softwareName: string,
|
||||
nodeCount: number,
|
||||
accountFeedCount: number,
|
||||
channelFeedCount: number
|
||||
}
|
||||
|
||||
export type ListStatsResult = {
|
||||
listStats: List<StatsResultItem>;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { PagingType } from '../../server/schema/types'
|
||||
|
||||
export type List<TItem> = {
|
||||
paging: PagingType,
|
||||
items: TItem[]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { FeedQueryInputType } from '../types/FeedQueryInput'
|
||||
import { PagingInputType } from '../types/PagingInput'
|
||||
|
||||
export type ListFeedsVariables = {
|
||||
paging: PagingInputType;
|
||||
query: FeedQueryInputType
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { PagingInputType } from '../types/PagingInput'
|
||||
import { NodeQueryInputType } from '../types/NodeQueryInput'
|
||||
|
||||
export type ListNodesVariables = {
|
||||
paging: PagingInputType;
|
||||
query: NodeQueryInputType
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { StatsQueryInputType } from '../types/StatsQueryInput'
|
||||
|
||||
export type ListStatsVariables = {
|
||||
query: StatsQueryInputType
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
import { z } from 'zod'
|
||||
import { stringTrimmed, transform } from '../../../lib/transform'
|
||||
|
||||
export const feedQueryInputSchema = z.object({
|
||||
search: transform(
|
||||
z.string().optional(),
|
||||
stringTrimmed,
|
||||
z.string()
|
||||
)
|
||||
})
|
||||
|
||||
export type FeedQueryInputType = z.infer<typeof feedQueryInputSchema>
|
|
@ -0,0 +1,17 @@
|
|||
import { stringTrimmed, transform } from '../../../lib/transform'
|
||||
import { z } from 'zod'
|
||||
import { createSortingInputSchema } from './SortingInput'
|
||||
import { nodeSortingBySchema } from './NodeSortingByEnum'
|
||||
|
||||
export const nodeQueryInputSchema = createSortingInputSchema(nodeSortingBySchema)
|
||||
.extend(
|
||||
{
|
||||
search: transform(
|
||||
z.string().optional(),
|
||||
stringTrimmed,
|
||||
z.string()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export type NodeQueryInputType = z.infer<typeof nodeQueryInputSchema>
|
|
@ -0,0 +1,17 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const NodeSortingByValues:readonly [string, ...string[]] = [
|
||||
'domain',
|
||||
'softwareName',
|
||||
'totalUserCount',
|
||||
'monthActiveUserCount',
|
||||
'halfYearActiveUserCount',
|
||||
'statusesCount',
|
||||
'accountFeedCount',
|
||||
'openRegistrations',
|
||||
'refreshedAt'
|
||||
]
|
||||
|
||||
export const nodeSortingBySchema = z.enum(NodeSortingByValues)
|
||||
|
||||
export type NodeSoringByEnumType = z.infer<typeof nodeSortingBySchema>;
|
|
@ -0,0 +1,3 @@
|
|||
export type PagingInputType = {
|
||||
page: number
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const createSortingInputSchema = (members:z.ZodEnum<[string, ...string[]]>) => {
|
||||
return z.object({
|
||||
sortBy: members,
|
||||
sortWay: z.enum(['asc', 'desc'])
|
||||
})
|
||||
}
|
||||
|
||||
export type SortingInputType<TMembers> = {
|
||||
sortBy: TMembers
|
||||
sortWay: 'asc'|'desc'
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { z } from 'zod'
|
||||
import { createSortingInputSchema } from './SortingInput'
|
||||
import { statsSortingBySchema } from './StatsSortingByEnum'
|
||||
|
||||
export const statsQueryInputSchema = createSortingInputSchema(statsSortingBySchema)
|
||||
|
||||
export type StatsQueryInputType = z.infer<typeof statsQueryInputSchema>
|
|
@ -0,0 +1,12 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const StatsSortingByValues:readonly [string, ...string[]] = [
|
||||
'softwareName',
|
||||
'nodeCount',
|
||||
'accountFeedCount',
|
||||
'channelFeedCount'
|
||||
]
|
||||
|
||||
export const statsSortingBySchema = z.enum(StatsSortingByValues)
|
||||
|
||||
export type StatsSoringByEnumType = z.infer<typeof statsSortingBySchema>;
|
|
@ -0,0 +1,10 @@
|
|||
import { ElasticClient } from '../../../lib/storage/ElasticClient'
|
||||
|
||||
type Context = {
|
||||
elasticClient: ElasticClient
|
||||
defaultPaging: {
|
||||
limit: 20
|
||||
}
|
||||
}
|
||||
|
||||
export default Context
|
|
@ -0,0 +1,11 @@
|
|||
import elasticClient from '../../../lib/storage/ElasticClient'
|
||||
import Context from './Context'
|
||||
|
||||
export default async function createContext (): Promise<Context> {
|
||||
return {
|
||||
elasticClient,
|
||||
defaultPaging: {
|
||||
limit: 20
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,2 @@
|
|||
export type { default as Context } from './Context'
|
||||
export { default as createContext } from './createContext'
|
|
@ -0,0 +1,12 @@
|
|||
import { ApolloServer } from 'apollo-server-micro'
|
||||
import resolvers from './resolvers'
|
||||
import schema from './schema'
|
||||
import { createContext } from './context'
|
||||
|
||||
export default function createGraphqlServer () {
|
||||
return new ApolloServer({
|
||||
schema,
|
||||
resolvers,
|
||||
context: createContext
|
||||
})
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
const resolvers = {
|
||||
Query: {
|
||||
links: () => {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default resolvers
|
|
@ -0,0 +1,29 @@
|
|||
import { makeSchema } from 'nexus'
|
||||
import { join } from 'path'
|
||||
import * as types from './types'
|
||||
import * as queries from './queries'
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import * as elastic from './sources/elastic'
|
||||
|
||||
const schema = makeSchema({
|
||||
types: {
|
||||
...types,
|
||||
...queries
|
||||
},
|
||||
sourceTypes: {
|
||||
modules: [{
|
||||
module: join(__dirname, 'sources', 'elastic.ts'),
|
||||
alias: 'elastic'
|
||||
}]
|
||||
},
|
||||
outputs: {
|
||||
typegen: join(__dirname, 'generated', 'nexus.ts'),
|
||||
schema: join(__dirname, 'schema.graphql')
|
||||
},
|
||||
contextType: {
|
||||
export: 'Context',
|
||||
module: join(__dirname, 'context', 'index.ts')
|
||||
}
|
||||
})
|
||||
|
||||
export default schema
|
|
@ -0,0 +1,3 @@
|
|||
export * from './listFeeds'
|
||||
export * from './listNodes'
|
||||
export * from './listStats'
|
|
@ -0,0 +1,101 @@
|
|||
import { arg, extendType, nonNull } from 'nexus'
|
||||
import { FeedQueryInput, PagingInput } from '../types'
|
||||
import { Context } from '../../context'
|
||||
import Feed from '../../../../lib/storage/Definitions/Feed'
|
||||
import feedIndex from '../../../../lib/storage/Definitions/feedIndex'
|
||||
import { ListFeedsVariables } from '../../../common/queries/listFeeds'
|
||||
import prepareSimpleQuery from '../../../../lib/prepareSimpleQuery'
|
||||
|
||||
export const listFeeds = extendType({
|
||||
type: 'Query',
|
||||
definition (t) {
|
||||
t.field('listFeeds', {
|
||||
type: 'FeedList',
|
||||
args: {
|
||||
paging: arg({
|
||||
type: nonNull(PagingInput),
|
||||
default: { page: 0 }
|
||||
}),
|
||||
query: arg({
|
||||
type: nonNull(FeedQueryInput),
|
||||
default: { search: '' }
|
||||
})
|
||||
},
|
||||
resolve: async (event, { paging, query }: ListFeedsVariables, { elasticClient, defaultPaging }: Context) => {
|
||||
console.info('Searching feeds', { paging, query })
|
||||
if (query.search === '') {
|
||||
return {
|
||||
paging: { hasNext: false },
|
||||
items: []
|
||||
}
|
||||
}
|
||||
const oneYearsAgo = new Date(new Date().setFullYear(new Date().getFullYear() - 1))
|
||||
const results = await elasticClient.search<Feed>({
|
||||
index: feedIndex,
|
||||
size: defaultPaging.limit + 1,
|
||||
from: paging.page * defaultPaging.limit,
|
||||
query: {
|
||||
function_score: {
|
||||
functions: [
|
||||
{
|
||||
filter: { term: { type: 'account' } },
|
||||
weight: 1.2
|
||||
},
|
||||
{
|
||||
filter: { range: { statusesCount: { lt: 3 } } },
|
||||
weight: 0.7
|
||||
},
|
||||
{
|
||||
filter: { range: { lastStatusAt: { lt: oneYearsAgo.getTime() } } },
|
||||
weight: 0.5
|
||||
},
|
||||
{
|
||||
filter: { term: { bot: true } },
|
||||
weight: 0.9
|
||||
},
|
||||
{
|
||||
script_score: {
|
||||
script: {
|
||||
source: "Math.max(1,Math.log(1 + doc['followersCount'].value) / 10 + 1)"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
filter: { range: { followingCount: { lt: 10 } } },
|
||||
weight: 0.8
|
||||
},
|
||||
{
|
||||
filter: { term: { locked: true } },
|
||||
weight: 0.1
|
||||
}
|
||||
],
|
||||
query: {
|
||||
simple_query_string: {
|
||||
query: prepareSimpleQuery(query.search),
|
||||
fields: [
|
||||
'name^4',
|
||||
'domain^4',
|
||||
'displayName^3',
|
||||
'description^2',
|
||||
'field.value^2',
|
||||
'field.name^1'
|
||||
],
|
||||
default_operator: 'AND'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
paging: {
|
||||
hasNext: typeof results.hits.hits[defaultPaging.limit] !== 'undefined'
|
||||
},
|
||||
items: results.hits.hits.slice(0, defaultPaging.limit).map(feed => {
|
||||
return feed._source
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
|
@ -0,0 +1,101 @@
|
|||
import { arg, extendType, nonNull } from 'nexus'
|
||||
import { NodeQueryInput, PagingInput, NodeList } from '../types'
|
||||
import { Context } from '../../context'
|
||||
import Node from '../../../../lib/storage/Definitions/Node'
|
||||
import nodeIndex from '../../../../lib/storage/Definitions/nodeIndex'
|
||||
import { ListNodesVariables } from '../../../common/queries/listNodes'
|
||||
import prepareSimpleQuery from '../../../../lib/prepareSimpleQuery'
|
||||
|
||||
export const listNodes = extendType({
|
||||
type: 'Query',
|
||||
definition (t) {
|
||||
t.field('listNodes', {
|
||||
type: NodeList,
|
||||
args: {
|
||||
paging: arg({
|
||||
type: nonNull(PagingInput),
|
||||
default: { page: 0 }
|
||||
}),
|
||||
query: arg({
|
||||
type: nonNull(NodeQueryInput),
|
||||
default: { default: '', sortBy: 'refreshedAt', sortWay: 'desc' }
|
||||
})
|
||||
},
|
||||
resolve: async (event, { paging, query }:ListNodesVariables, { elasticClient, defaultPaging }: Context) => {
|
||||
console.info('Searching nodes', { paging, query })
|
||||
|
||||
const results = await elasticClient.search<Node>({
|
||||
index: nodeIndex,
|
||||
query: {
|
||||
bool: {
|
||||
must: [
|
||||
{
|
||||
exists: {
|
||||
field: query.sortBy
|
||||
}
|
||||
},
|
||||
{
|
||||
exists: {
|
||||
field: 'softwareName'
|
||||
}
|
||||
}
|
||||
],
|
||||
should: query.search !== ''
|
||||
? [
|
||||
{
|
||||
wildcard: {
|
||||
softwareName: {
|
||||
value: `*${query.search}*`,
|
||||
boost: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
wildcard: {
|
||||
softwareVersion: {
|
||||
value: `*${query.search}*`,
|
||||
boost: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
wildcard: {
|
||||
domain: {
|
||||
value: `*${query.search}*`,
|
||||
boost: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
simple_query_string: {
|
||||
query: prepareSimpleQuery(query.search),
|
||||
fields: [
|
||||
'softwareName^1',
|
||||
'version^1',
|
||||
'domain^2'
|
||||
],
|
||||
default_operator: 'AND'
|
||||
}
|
||||
}
|
||||
]
|
||||
: [{ match_all: {} }],
|
||||
minimum_should_match: 1
|
||||
}
|
||||
},
|
||||
size: defaultPaging.limit + 1,
|
||||
from: paging.page * defaultPaging.limit,
|
||||
sort: `${query.sortBy}:${query.sortWay}`
|
||||
})
|
||||
|
||||
return {
|
||||
paging: {
|
||||
hasNext: typeof results.hits.hits[defaultPaging.limit] !== 'undefined'
|
||||
},
|
||||
items: results.hits.hits.slice(0, defaultPaging.limit).map(node => {
|
||||
return node._source
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
|
@ -0,0 +1,94 @@
|
|||
import { arg, extendType, nonNull } from 'nexus'
|
||||
import { StatsList, StatsQueryInput } from '../types'
|
||||
import { Context } from '../../context'
|
||||
import { ListStatsVariables } from '../../../common/queries/listStats'
|
||||
import nodeIndex from '../../../../lib/storage/Definitions/nodeIndex'
|
||||
import { StatsQueryInputType } from '../../../common/types/StatsQueryInput'
|
||||
|
||||
const getSort = (query: StatsQueryInputType) => {
|
||||
switch (query.sortBy) {
|
||||
case 'nodeCount':
|
||||
return { _count: { order: query.sortWay } }
|
||||
case 'accountFeedCount':
|
||||
return { accountFeedCount: { order: query.sortWay } }
|
||||
case 'channelFeedCount':
|
||||
return { channelFeedCount: { order: query.sortWay } }
|
||||
case 'softwareName':
|
||||
default:
|
||||
return { _key: { order: query.sortWay } }
|
||||
}
|
||||
}
|
||||
|
||||
export const listStats = extendType({
|
||||
type: 'Query',
|
||||
definition (t) {
|
||||
t.field('listStats', {
|
||||
type: StatsList,
|
||||
args: {
|
||||
query: arg({
|
||||
type: nonNull(StatsQueryInput),
|
||||
default: { sortBy: 'nodeCount', sortWay: 'desc' }
|
||||
})
|
||||
},
|
||||
resolve: async (event, { query }:ListStatsVariables, { elasticClient }: Context) => {
|
||||
console.info('Searching stats', { query })
|
||||
|
||||
const results = await elasticClient.search({
|
||||
index: nodeIndex,
|
||||
query: {
|
||||
match_all: {}
|
||||
},
|
||||
aggs: {
|
||||
software: {
|
||||
terms: {
|
||||
field: 'softwareName',
|
||||
size: 1000,
|
||||
min_doc_count: 2
|
||||
},
|
||||
aggs: {
|
||||
accountFeedCount: {
|
||||
sum: {
|
||||
field: 'accountFeedCount'
|
||||
}
|
||||
},
|
||||
channelFeedCount: {
|
||||
sum: {
|
||||
field: 'channelFeedCount'
|
||||
}
|
||||
},
|
||||
sort: {
|
||||
bucket_sort: {
|
||||
sort: [
|
||||
// @ts-ignore
|
||||
getSort(query)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
type Aggregation = {
|
||||
buckets:{
|
||||
key:string,
|
||||
// eslint-disable-next-line camelcase
|
||||
doc_count:number
|
||||
accountFeedCount: {value:number}
|
||||
channelFeedCount: {value:number}
|
||||
}[]
|
||||
}
|
||||
const software = results.aggregations.software as Aggregation
|
||||
return {
|
||||
items: software.buckets.map(bucket => {
|
||||
return {
|
||||
softwareName: bucket.key,
|
||||
nodeCount: bucket.doc_count,
|
||||
accountFeedCount: bucket.accountFeedCount.value,
|
||||
channelFeedCount: bucket.channelFeedCount.value
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
|
@ -0,0 +1,5 @@
|
|||
import Feed from '../../../../lib/storage/Definitions/Feed'
|
||||
import Field from '../../../../lib/storage/Definitions/Field'
|
||||
import Node from '../../../../lib/storage/Definitions/Node'
|
||||
|
||||
export type { Feed, Field, Node }
|
|
@ -0,0 +1,36 @@
|
|||
import { scalarType } from 'nexus'
|
||||
import { Kind } from 'graphql/language'
|
||||
|
||||
export const DateTime = scalarType({
|
||||
name: 'DateTime',
|
||||
asNexusMethod: 'dateTime',
|
||||
serialize: (value: unknown): string | null => {
|
||||
if (typeof value === 'number' || typeof value === 'string') {
|
||||
return (new Date(value)).toISOString()
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString()
|
||||
}
|
||||
throw new TypeError(
|
||||
'DateTime cannot be serialized from a non string, ' +
|
||||
'non numeric or non Date type ' + JSON.stringify(value)
|
||||
)
|
||||
},
|
||||
parseValue: (value: unknown): Date => {
|
||||
if (typeof value !== 'string') {
|
||||
throw new TypeError(
|
||||
`DateTime cannot represent non string type ${JSON.stringify(value)}`
|
||||
)
|
||||
}
|
||||
return new Date(value)
|
||||
},
|
||||
parseLiteral: (ast): Date => {
|
||||
if (ast.kind !== Kind.STRING) {
|
||||
throw new TypeError(
|
||||
`DateTime cannot represent non string type ${JSON.stringify(ast)}`
|
||||
)
|
||||
}
|
||||
const { value } = ast
|
||||
return new Date(value)
|
||||
}
|
||||
})
|
|
@ -0,0 +1,68 @@
|
|||
import { objectType } from 'nexus'
|
||||
import { FeedType } from './FeedType'
|
||||
import { Field } from './Field'
|
||||
import { Node } from './Node'
|
||||
import FeedSource from '../../../../lib/storage/Definitions/Feed'
|
||||
import NodeSource from '../../../../lib/storage/Definitions/Node'
|
||||
import { Context } from '../../context'
|
||||
import feedIndex from '../../../../lib/storage/Definitions/feedIndex'
|
||||
import getFeedId from '../../../../lib/getNodeId'
|
||||
import nodeIndex from '../../../../lib/storage/Definitions/nodeIndex'
|
||||
import { DateTime } from './DateTime'
|
||||
|
||||
export const Feed = objectType({
|
||||
name: 'Feed',
|
||||
definition: (t) => {
|
||||
t.nonNull.id('id', {
|
||||
resolve: async (source: FeedSource) => {
|
||||
return getFeedId(source.name, source.domain)
|
||||
}
|
||||
})
|
||||
t.nonNull.string('domain')
|
||||
// @ts-ignore
|
||||
t.nonNull.field('foundAt', { type: DateTime })
|
||||
t.nullable.field('refreshedAt', { type: DateTime })
|
||||
t.nonNull.string('name')
|
||||
t.nonNull.string('displayName')
|
||||
t.nonNull.string('description')
|
||||
t.nullable.int('followersCount')
|
||||
t.nullable.int('followingCount')
|
||||
t.nullable.int('statusesCount')
|
||||
t.nullable.int('statusesCount')
|
||||
t.nullable.field('lastStatusAt', { type: DateTime })
|
||||
t.nullable.field('createdAt', { type: DateTime })
|
||||
t.nullable.boolean('bot')
|
||||
t.nonNull.boolean('locked')
|
||||
t.nonNull.string('url')
|
||||
t.nullable.string('avatar')
|
||||
t.nonNull.field('type', {
|
||||
type: FeedType
|
||||
})
|
||||
t.nullable.field('parent', {
|
||||
type: Feed,
|
||||
resolve: async (source: FeedSource, args, { elasticClient }: Context) => {
|
||||
if (!source.parentFeedName || !source.parentFeedDomain) {
|
||||
return null
|
||||
}
|
||||
const parentFeedResult = await elasticClient.get<FeedSource>({
|
||||
index: feedIndex,
|
||||
id: getFeedId(source.parentFeedName, source.parentFeedDomain)
|
||||
})
|
||||
return parentFeedResult._source
|
||||
}
|
||||
})
|
||||
t.nonNull.list.nonNull.field('fields', {
|
||||
type: Field
|
||||
})
|
||||
t.nonNull.field('node', {
|
||||
type: Node,
|
||||
resolve: async (source: FeedSource, args, { elasticClient }:Context) => {
|
||||
const nodeResult = await elasticClient.get<NodeSource>({
|
||||
index: nodeIndex,
|
||||
id: source.domain
|
||||
})
|
||||
return nodeResult._source
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
|
@ -0,0 +1,11 @@
|
|||
import { objectType } from 'nexus'
|
||||
import { Paging } from './Paging'
|
||||
import { Feed } from './Feed'
|
||||
|
||||
export const FeedList = objectType({
|
||||
name: 'FeedList',
|
||||
definition: (t) => {
|
||||
t.nonNull.field('paging', { type: Paging })
|
||||
t.nonNull.list.nonNull.field('items', { type: Feed })
|
||||
}
|
||||
})
|
|
@ -0,0 +1,8 @@
|
|||
import { inputObjectType } from 'nexus'
|
||||
|
||||
export const FeedQueryInput = inputObjectType({
|
||||
name: 'FeedQueryInput',
|
||||
definition: (t) => {
|
||||
t.nonNull.string('search', { default: '' })
|
||||
}
|
||||
})
|
|
@ -0,0 +1,6 @@
|
|||
import { enumType } from 'nexus'
|
||||
|
||||
export const FeedType = enumType({
|
||||
name: 'FeedType',
|
||||
members: ['account', 'channel']
|
||||
})
|
|
@ -0,0 +1,9 @@
|
|||
import { objectType } from 'nexus'
|
||||
|
||||
export const Field = objectType({
|
||||
name: 'Field',
|
||||
definition: (t) => {
|
||||
t.nonNull.string('name')
|
||||
t.nonNull.string('value')
|
||||
}
|
||||
})
|
|
@ -0,0 +1,14 @@
|
|||
import { objectType } from 'nexus'
|
||||
|
||||
export const GeoIp = objectType({
|
||||
name: 'GeoIp',
|
||||
definition: (t) => {
|
||||
t.nullable.string('city_name')
|
||||
t.nullable.string('continent_name')
|
||||
t.nullable.string('country_iso_code')
|
||||
t.nullable.string('country_name')
|
||||
t.nullable.string('location')
|
||||
t.nullable.string('region_iso_code')
|
||||
t.nullable.string('region_name')
|
||||
}
|
||||
})
|
|
@ -0,0 +1,35 @@
|
|||
import { objectType } from 'nexus'
|
||||
import NodeSource from '../../../../lib/storage/Definitions/Node'
|
||||
import { GeoIp } from './GeoIp'
|
||||
import { DateTime } from './DateTime'
|
||||
|
||||
export const Node = objectType({
|
||||
name: 'Node',
|
||||
definition: (t) => {
|
||||
t.nonNull.id('id', {
|
||||
resolve: async (source: NodeSource) => {
|
||||
return source.domain
|
||||
}
|
||||
})
|
||||
t.nullable.string('name')
|
||||
t.nonNull.field('foundAt', { type: DateTime })
|
||||
t.nullable.field('refreshAttemptedAt', { type: DateTime })
|
||||
t.nullable.field('refreshedAt', { type: DateTime })
|
||||
t.nullable.boolean('openRegistrations')
|
||||
t.nonNull.string('domain')
|
||||
t.nonNull.string('domain')
|
||||
t.nullable.list.nonNull.string('serverIps')
|
||||
t.nullable.field('geoip', {
|
||||
type: GeoIp
|
||||
})
|
||||
t.nullable.string('softwareName')
|
||||
t.nullable.int('accountFeedCount')
|
||||
t.nullable.int('channelFeedCount')
|
||||
t.nullable.string('softwareVersion')
|
||||
t.nullable.string('standardizedSoftwareVersion')
|
||||
t.nullable.int('halfYearActiveUserCount')
|
||||
t.nullable.int('monthActiveUserCount')
|
||||
t.nullable.int('statusesCount')
|
||||
t.nullable.int('totalUserCount')
|
||||
}
|
||||
})
|
|
@ -0,0 +1,11 @@
|
|||
import { Paging } from './Paging'
|
||||
import { Node } from './Node'
|
||||
import { objectType } from 'nexus'
|
||||
|
||||
export const NodeList = objectType({
|
||||
name: 'NodeList',
|
||||
definition: (t) => {
|
||||
t.nonNull.field('paging', { type: Paging })
|
||||
t.nonNull.list.nonNull.field('items', { type: Node })
|
||||
}
|
||||
})
|
|
@ -0,0 +1,6 @@
|
|||
import { createSortingInput } from './SortingInput'
|
||||
import { NodeSortingByEnum } from './NodeSortingByEnum'
|
||||
|
||||
export const NodeQueryInput = createSortingInput('NodeQueryInput', NodeSortingByEnum, (t) => {
|
||||
t.nonNull.string('search', { default: '' })
|
||||
}, 'refreshedAt', 'desc')
|
|
@ -0,0 +1,4 @@
|
|||
import { createSortingByEnum } from './SortingByEnum'
|
||||
import { NodeSortingByValues } from '../../../common/types/NodeSortingByEnum'
|
||||
|
||||
export const NodeSortingByEnum = createSortingByEnum('NodeSortingByEnum', NodeSortingByValues)
|
|
@ -0,0 +1,12 @@
|
|||
import { objectType } from 'nexus'
|
||||
|
||||
export const Paging = objectType({
|
||||
name: 'Paging',
|
||||
definition: (t) => {
|
||||
t.nonNull.boolean('hasNext')
|
||||
}
|
||||
})
|
||||
|
||||
export type PagingType = {
|
||||
hasNext: boolean
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
import { inputObjectType } from 'nexus'
|
||||
|
||||
export const PagingInput = inputObjectType({
|
||||
name: 'PagingInput',
|
||||
definition: (t) => {
|
||||
t.nonNull.int('page', { default: 0 })
|
||||
}
|
||||
})
|
|
@ -0,0 +1,12 @@
|
|||
import { objectType } from 'nexus'
|
||||
import { SortingWay } from './SortingWay'
|
||||
|
||||
export const Sorting = objectType({
|
||||
name: 'Sorting',
|
||||
definition: (t) => {
|
||||
t.nonNull.string('by')
|
||||
t.nonNull.field('way', {
|
||||
type: SortingWay
|
||||
})
|
||||
}
|
||||
})
|
|
@ -0,0 +1,9 @@
|
|||
import { NexusEnumTypeDef } from 'nexus/dist/definitions/enumType'
|
||||
import { enumType } from 'nexus'
|
||||
|
||||
export const createSortingByEnum = (name:string, members:readonly [string, ...string[]]):NexusEnumTypeDef<string> => {
|
||||
return enumType({
|
||||
name: name,
|
||||
members: members
|
||||
})
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { inputObjectType } from 'nexus'
|
||||
import { SortingWay } from './SortingWay'
|
||||
import { NexusEnumTypeDef } from 'nexus/dist/definitions/enumType'
|
||||
import { InputDefinitionBlock } from 'nexus/dist/definitions/definitionBlocks'
|
||||
|
||||
export const createSortingInput = (name:string, sortingByEnum:NexusEnumTypeDef<string>, definition:(t:InputDefinitionBlock<string>)=>void, defaultBy:string, defaultWay:'asc'|'desc') => {
|
||||
return inputObjectType({
|
||||
name: name,
|
||||
definition: (t) => {
|
||||
t.nullable.field('sortBy', {
|
||||
type: sortingByEnum,
|
||||
default: defaultBy
|
||||
})
|
||||
t.nullable.field('sortWay', {
|
||||
type: SortingWay,
|
||||
default: defaultWay
|
||||
})
|
||||
definition(t)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
import { inputObjectType } from 'nexus'
|
||||
import { NexusInputObjectTypeDef } from 'nexus/dist/definitions/inputObjectType'
|
||||
import { z, ZodRawShape } from 'zod'
|
||||
|
||||
export const createSortingQueryInput = (name: string, sortingInput, definition: (t) => void): NexusInputObjectTypeDef<string> => {
|
||||
return inputObjectType({
|
||||
name,
|
||||
definition: (t) => {
|
||||
t.nonNull.field('sorting', {
|
||||
type: sortingInput
|
||||
})
|
||||
definition(t)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const createSortingQueryInputSchema = <T extends ZodRawShape, U extends ZodRawShape>(querySchema:z.ZodObject<T>, sortingInputSchema:z.ZodObject<U>) => z.object({
|
||||
sorting: sortingInputSchema
|
||||
}).merge(querySchema)
|
||||
|
||||
export type SortingQueryInputType<TQuery, TSortingInputType> = TQuery & { sorting: TSortingInputType }
|
|
@ -0,0 +1,6 @@
|
|||
import { enumType } from 'nexus'
|
||||
|
||||
export const SortingWay = enumType({
|
||||
name: 'SortingWay',
|
||||
members: ['asc', 'desc']
|
||||
})
|
|
@ -0,0 +1,11 @@
|
|||
import { objectType } from 'nexus'
|
||||
|
||||
export const Stats = objectType({
|
||||
name: 'Stats',
|
||||
definition: (t) => {
|
||||
t.nonNull.string('softwareName')
|
||||
t.nonNull.int('nodeCount')
|
||||
t.nonNull.int('accountFeedCount')
|
||||
t.nonNull.int('channelFeedCount')
|
||||
}
|
||||
})
|
|
@ -0,0 +1,9 @@
|
|||
import { objectType } from 'nexus'
|
||||
import { Stats } from './Stats'
|
||||
|
||||
export const StatsList = objectType({
|
||||
name: 'StatsList',
|
||||
definition: (t) => {
|
||||
t.nonNull.list.nonNull.field('items', { type: Stats })
|
||||
}
|
||||
})
|
|
@ -0,0 +1,10 @@
|
|||
import { createSortingInput } from './SortingInput'
|
||||
import { StatsSortingByEnum } from './StatsSortingByEnum'
|
||||
|
||||
export const StatsQueryInput = createSortingInput(
|
||||
'StatsQueryInput',
|
||||
StatsSortingByEnum,
|
||||
() => {},
|
||||
'nodeCount',
|
||||
'desc'
|
||||
)
|
|
@ -0,0 +1,4 @@
|
|||
import { createSortingByEnum } from './SortingByEnum'
|
||||
import { StatsSortingByValues } from '../../../common/types/StatsSortingByEnum'
|
||||
|
||||
export const StatsSortingByEnum = createSortingByEnum('StatsSortingByEnum', StatsSortingByValues)
|
|
@ -0,0 +1,20 @@
|
|||
export * from './DateTime'
|
||||
export * from './Paging'
|
||||
export * from './PagingInput'
|
||||
|
||||
export * from './FeedQueryInput'
|
||||
export * from './Feed'
|
||||
export * from './FeedList'
|
||||
export * from './FeedType'
|
||||
export * from './Field'
|
||||
|
||||
export * from './NodeSortingByEnum'
|
||||
export * from './NodeQueryInput'
|
||||
export * from './NodeList'
|
||||
export * from './Node'
|
||||
export * from './GeoIp'
|
||||
|
||||
export * from './Stats'
|
||||
export * from './StatsQueryInput'
|
||||
export * from './StatsSortingByEnum'
|
||||
export * from './StatsList'
|
|
@ -0,0 +1,7 @@
|
|||
export default function getFlagEmoji (countryCode:string):string {
|
||||
const codePoints = countryCode
|
||||
.toUpperCase()
|
||||
.split('')
|
||||
.map(char => 127397 + char.charCodeAt(0))
|
||||
return String.fromCodePoint(...codePoints)
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export default function getNodeId (name:string, domain:string):string {
|
||||
return `${name}@${domain}`
|
||||
}
|
|
@ -1,2 +0,0 @@
|
|||
|
||||
export const pageLimit = 20
|
|
@ -0,0 +1,5 @@
|
|||
export default function prepareSimpleQuery (search:string):string {
|
||||
const tokens = search.split(/\s+/)
|
||||
const searchContainsWildcard = tokens.filter(token => token.length > 0 && token.slice(-1) === '*').length > 0
|
||||
return tokens.map(token => searchContainsWildcard ? token : token + '*').join(' ')
|
||||
}
|
|
@ -1,19 +0,0 @@
|
|||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
// PrismaClient is attached to the `global` object in development to prevent
|
||||
// exhausting your database connection pageLimit.
|
||||
//
|
||||
// Learn more:
|
||||
// https://pris.ly/d/help/next-js-best-practices
|
||||
|
||||
let prisma: PrismaClient
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
prisma = new PrismaClient()
|
||||
} else {
|
||||
if (!global.prisma) {
|
||||
global.prisma = new PrismaClient()
|
||||
}
|
||||
prisma = global.prisma
|
||||
}
|
||||
export default prisma
|
|
@ -0,0 +1,29 @@
|
|||
import Field from './Field'
|
||||
|
||||
interface Feed {
|
||||
domain: string
|
||||
foundAt: number,
|
||||
refreshedAt?: number,
|
||||
name: string,
|
||||
fullName: string,
|
||||
displayName: string,
|
||||
description: string,
|
||||
strippedDescription?: string,
|
||||
followersCount?: number,
|
||||
followingCount?: number,
|
||||
statusesCount?: number,
|
||||
lastStatusAt?: number,
|
||||
createdAt?: number,
|
||||
bot?: boolean,
|
||||
locked: boolean,
|
||||
url: string,
|
||||
avatar?: string,
|
||||
type: 'account' | 'channel',
|
||||
parentFeedName?: string,
|
||||
parentFeedDomain?: string
|
||||
fields: Field[],
|
||||
extractedEmails: string[],
|
||||
extractedTags: string[]
|
||||
}
|
||||
|
||||
export default Feed
|
|
@ -0,0 +1,8 @@
|
|||
interface Field {
|
||||
name: string,
|
||||
value: string
|
||||
strippedName?: string
|
||||
strippedValue?: string
|
||||
}
|
||||
|
||||
export default Field
|
|
@ -0,0 +1,13 @@
|
|||
interface Geo {
|
||||
cityName?: string,
|
||||
continentName?: string,
|
||||
countryIsoCode?: string,
|
||||
countryName?: string,
|
||||
latitude: number
|
||||
longitude: number
|
||||
location?: string,
|
||||
regionIsoCode?: string,
|
||||
regionName?: string
|
||||
}
|
||||
|
||||
export default Geo
|
|
@ -0,0 +1,25 @@
|
|||
import Geo from './Geo'
|
||||
|
||||
interface Node {
|
||||
name?:string,
|
||||
strippedName?:string,
|
||||
foundAt: number,
|
||||
refreshAttemptedAt?: number
|
||||
refreshedAt?: number
|
||||
openRegistrations?: boolean
|
||||
domain: string,
|
||||
serverIps?: string[],
|
||||
geoip?: Geo[],
|
||||
softwareName?: string;
|
||||
softwareVersion?: string
|
||||
standardizedSoftwareVersion?: string
|
||||
halfYearActiveUserCount?: number,
|
||||
monthActiveUserCount?: number,
|
||||
statusesCount?: number,
|
||||
totalUserCount?: number,
|
||||
discoveredByDomain?:string,
|
||||
accountFeedCount?: number,
|
||||
channelFeedCount?: number,
|
||||
}
|
||||
|
||||
export default Node
|
|
@ -0,0 +1,3 @@
|
|||
const feedIndex = 'feed'
|
||||
|
||||
export default feedIndex
|
|
@ -0,0 +1,3 @@
|
|||
const nodeIndex = 'node'
|
||||
|
||||
export default nodeIndex
|
|
@ -0,0 +1,15 @@
|
|||
import { Client } from '@elastic/elasticsearch'
|
||||
|
||||
const elasticClient = new Client({
|
||||
node: {
|
||||
url: new URL(process.env.ELASTIC_URL ?? 'http://elastic:9200')
|
||||
},
|
||||
auth: {
|
||||
username: process.env.ELASTIC_USER ?? 'elastic',
|
||||
password: process.env.ELASTIC_PASSWORD
|
||||
}
|
||||
})
|
||||
|
||||
export type ElasticClient = typeof elasticClient
|
||||
|
||||
export default elasticClient
|
|
@ -1,9 +1,15 @@
|
|||
import '../styles/global.scss'
|
||||
import { AppProps } from 'next/app'
|
||||
import React from 'react'
|
||||
import { ApolloProvider } from '@apollo/client'
|
||||
import createGraphqlClient from '../graphql/client/createGraphqlClient'
|
||||
|
||||
const App:React.FC<AppProps> = ({ Component, pageProps }) => {
|
||||
return <Component {...pageProps} />
|
||||
const graphqlClient = createGraphqlClient()
|
||||
|
||||
const App = ({ Component, pageProps }: AppProps) => {
|
||||
return <ApolloProvider client={graphqlClient}>
|
||||
<Component {...pageProps} />
|
||||
</ApolloProvider>
|
||||
}
|
||||
|
||||
export default App
|
||||
|
|
|
@ -1,76 +0,0 @@
|
|||
import prisma from '../../lib/prisma'
|
||||
import { pageLimit } from '../../lib/pageLimit'
|
||||
import { feedRequestSchema } from '../../types/FeedRequest'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { FeedResponse } from '../../types/FeedResponse'
|
||||
|
||||
const handleFeedSearch = async (req: NextApiRequest, res: NextApiResponse<FeedResponse>): Promise<void> => {
|
||||
console.info('Searching feeds', { query: req.query })
|
||||
|
||||
const feedRequest = feedRequestSchema.parse(req.query)
|
||||
const phrases = feedRequest.search.split(/[\s+]+/)
|
||||
const feeds = await prisma.feed.findMany({
|
||||
where: {
|
||||
AND: phrases.map(phrase => {
|
||||
return {
|
||||
fulltext: {
|
||||
contains: phrase,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
take: pageLimit + 1,
|
||||
skip: (feedRequest.page ?? 0) * pageLimit,
|
||||
include: {
|
||||
emails: true,
|
||||
fields: true,
|
||||
node: true
|
||||
},
|
||||
orderBy: [
|
||||
{
|
||||
lastStatusAt: 'desc'
|
||||
},
|
||||
{
|
||||
followersCount: 'desc'
|
||||
},
|
||||
{
|
||||
statusesCount: 'desc'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
res.status(200)
|
||||
.json({
|
||||
hasMore: typeof feeds[pageLimit] !== 'undefined',
|
||||
feeds: feeds.slice(0, pageLimit).map(feed => {
|
||||
return {
|
||||
avatar: feed.avatar,
|
||||
bot: feed.bot,
|
||||
createdAt: feed.createdAt.toISOString(),
|
||||
description: feed.description,
|
||||
displayName: feed.displayName,
|
||||
fields: feed.fields.map(field => {
|
||||
return {
|
||||
name: field.name,
|
||||
value: field.value
|
||||
}
|
||||
}),
|
||||
followersCount: feed.followersCount,
|
||||
followingCount: feed.followingCount,
|
||||
statusesCount: feed.statusesCount,
|
||||
lastStatusAt: feed.lastStatusAt?.toISOString() ?? null,
|
||||
name: feed.name,
|
||||
node: {
|
||||
domain: feed.node.domain,
|
||||
softwareName: feed.node.softwareName
|
||||
},
|
||||
type: feed.type,
|
||||
url: feed.url,
|
||||
parentFeed: null // TODO find parent data
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default handleFeedSearch
|
|
@ -0,0 +1,31 @@
|
|||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import createCorsHandlerAdapter from 'micro-cors'
|
||||
import createGraphqlServer from '../../graphql/server/createGraphqlServer'
|
||||
|
||||
const handleCors = createCorsHandlerAdapter()
|
||||
|
||||
const graphqlServer = createGraphqlServer()
|
||||
|
||||
const startedServer = graphqlServer.start()
|
||||
|
||||
const handler = async (req: NextApiRequest, res: NextApiResponse) :Promise<void> => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
await startedServer
|
||||
|
||||
const handleGraphql = await graphqlServer.createHandler({
|
||||
path: '/api/graphql'
|
||||
})
|
||||
|
||||
await handleGraphql(req, res)
|
||||
}
|
||||
|
||||
export default handleCors(handler)
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false
|
||||
}
|
||||
}
|
|
@ -1,62 +0,0 @@
|
|||
import prisma from '../../lib/prisma'
|
||||
import { pageLimit } from '../../lib/pageLimit'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { nodeRequestSchema } from '../../types/NodeRequest'
|
||||
import { NodeResponse } from '../../types/NodeResponse'
|
||||
|
||||
const handleFeedSearch = async (req: NextApiRequest, res: NextApiResponse<NodeResponse>): Promise<void> => {
|
||||
console.info('Searching nodes', { query: req.query })
|
||||
|
||||
const nodeRequest = nodeRequestSchema.parse(req.query)
|
||||
const phrases = nodeRequest.search.split(/[\s+]+/)
|
||||
const order = {}
|
||||
order[nodeRequest.sortBy] = nodeRequest.sortWay
|
||||
const nodes = await prisma.node.findMany({
|
||||
where: {
|
||||
AND: phrases.map(phrase => {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
domain: {
|
||||
contains: phrase,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
},
|
||||
{
|
||||
softwareName: {
|
||||
contains: phrase,
|
||||
mode: 'insensitive'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}),
|
||||
NOT: {
|
||||
softwareName: null
|
||||
}
|
||||
},
|
||||
take: pageLimit + 1,
|
||||
skip: (nodeRequest.page ?? 0) * pageLimit,
|
||||
orderBy: [order]
|
||||
})
|
||||
|
||||
res.status(200)
|
||||
.json({
|
||||
hasMore: typeof nodes[pageLimit] !== 'undefined',
|
||||
nodes: nodes.slice(0, pageLimit).map(node => {
|
||||
return {
|
||||
softwareName: node.softwareName,
|
||||
softwareVersion: node.softwareVersion,
|
||||
totalUserCount: node.totalUserCount,
|
||||
monthActiveUserCount: node.monthActiveUserCount,
|
||||
halfYearActiveUserCount: node.halfYearActiveUserCount,
|
||||
statusesCount: node.statusesCount,
|
||||
openRegistrations: node.openRegistrations,
|
||||
refreshedAt: node.refreshedAt ? node.refreshedAt.toISOString() : null,
|
||||
domain: node.domain
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default handleFeedSearch
|
|
@ -1,67 +0,0 @@
|
|||
import prisma from '../../lib/prisma'
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { StatsResponse, StatsResponseSoftware } from '../../types/StatsResponse'
|
||||
import { cache } from '../../lib/cache'
|
||||
import { statsRequestSchema } from '../../types/StatsRequest'
|
||||
|
||||
interface StatsItem {
|
||||
softwarename: string | null,
|
||||
nodecount: number,
|
||||
accountcount: number,
|
||||
channelcount: number,
|
||||
newnodescount: number
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'stats'
|
||||
|
||||
const handleGetStats = async (req: NextApiRequest, res: NextApiResponse<StatsResponse>): Promise<void> => {
|
||||
const query = await statsRequestSchema.parseAsync(req.query)
|
||||
const cacheKey = `${CACHE_KEY}_${query.sortWay}_${query.sortBy}`
|
||||
if (!cache.has(cacheKey)) {
|
||||
console.info('Retrieving new stats', { cacheKey, query })
|
||||
const data = await prisma.$queryRawUnsafe<StatsItem[]>(`
|
||||
select n."softwareName" as softwarename,
|
||||
count(n.id) as nodecount,
|
||||
(
|
||||
select count("Feed".id)
|
||||
from "Feed"
|
||||
join "Node" on "Feed"."nodeId" = "Node".id and "Node"."softwareName" = n."softwareName"
|
||||
where "Feed".type = 'account'
|
||||
) as accountcount,
|
||||
(
|
||||
select count("Feed".id)
|
||||
from "Feed"
|
||||
join "Node" on "Feed"."nodeId" = "Node".id and "Node"."softwareName" = n."softwareName"
|
||||
where "Feed".type = 'channel'
|
||||
) as channelcount,
|
||||
(
|
||||
select count("Node".id)
|
||||
from "Node"
|
||||
where "Node"."refreshedAt" IS Null
|
||||
and "Node"."softwareName" = n."softwareName"
|
||||
) as newnodescount
|
||||
from "Node" n
|
||||
group by n."softwareName"
|
||||
having count(n.id) > 1
|
||||
order by ${query.sortBy.toLowerCase() + ' ' + query.sortWay.toUpperCase()};
|
||||
`)
|
||||
cache.set<StatsResponse>(cacheKey, {
|
||||
softwares: data.map(
|
||||
(item: StatsItem): StatsResponseSoftware => {
|
||||
return {
|
||||
name: item.softwarename,
|
||||
nodeCount: item.nodecount,
|
||||
accountCount: item.accountcount,
|
||||
channelCount: item.channelcount,
|
||||
newNodeCount: item.newnodescount
|
||||
}
|
||||
}
|
||||
)
|
||||
}, parseInt(process.env.STATS_CACHE_MINUTES ?? '60') * 60 * 1000)
|
||||
}
|
||||
console.info('Returning stats from cache', { cacheKey, query })
|
||||
res.status(200)
|
||||
.json(cache.get<StatsResponse>(cacheKey))
|
||||
}
|
||||
|
||||
export default handleGetStats
|
|
@ -1,84 +1,41 @@
|
|||
import Head from 'next/head'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import { FeedResponseItem, feedResponseSchema } from '../types/FeedResponse'
|
||||
import Loader from '../components/Loader'
|
||||
import FeedResults from '../components/FeedResults'
|
||||
import Layout, { siteTitle } from '../components/Layout'
|
||||
import { matomoConfig } from '../lib/matomoConfig'
|
||||
import getMatomo from '../lib/getMatomo'
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faSearch, faAngleDoubleDown } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSearch, faAngleDoubleDown, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useRouter } from 'next/router'
|
||||
import { FeedRequestQuery, feedRequestQuerySchema } from '../types/FeedRequest'
|
||||
|
||||
let source = axios.CancelToken.source()
|
||||
import { useQuery } from '@apollo/client'
|
||||
import {
|
||||
ListFeedsQuery, ListFeedsResult
|
||||
} from '../graphql/client/queries/ListFeedsQuery'
|
||||
import getMatomo from '../lib/getMatomo'
|
||||
import { feedQueryInputSchema, FeedQueryInputType } from '../graphql/common/types/FeedQueryInput'
|
||||
import { ListFeedsVariables } from '../graphql/common/queries/listFeeds'
|
||||
|
||||
const Feeds: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({ matomoConfig }) => {
|
||||
const router = useRouter()
|
||||
const routerQuery = feedRequestQuerySchema.parse(router.query)
|
||||
console.log('Router query', routerQuery)
|
||||
const [query, setQuery] = useState<FeedRequestQuery>(routerQuery)
|
||||
const [submitted, setSubmitted] = useState<Date | null>(null)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [results, setResults] = useState<FeedResponseItem[]>([])
|
||||
const routerQuery = feedQueryInputSchema.parse(router.query)
|
||||
const [page, setPage] = useState<number>(0)
|
||||
const [hasMore, setHasMore] = useState<boolean>(false)
|
||||
const [loaded, setLoaded] = useState<boolean>(false)
|
||||
|
||||
const search = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
console.info('Retrieving results', { query, page })
|
||||
source = axios.CancelToken.source()
|
||||
const response = await axios.get('/api/feed', {
|
||||
params: { ...query, page },
|
||||
cancelToken: source.token
|
||||
})
|
||||
const responseData = await feedResponseSchema.parseAsync(response.data)
|
||||
setHasMore(responseData.hasMore)
|
||||
setResults([
|
||||
...(page > 0 ? results : []),
|
||||
...responseData.feeds
|
||||
])
|
||||
setLoaded(true)
|
||||
} catch (e) {
|
||||
console.warn('Search failed', e)
|
||||
setLoaded(true)
|
||||
const [query, setQuery] = useState<FeedQueryInputType>(routerQuery)
|
||||
const [pageLoading, setPageLoading] = useState<boolean>(false)
|
||||
const { loading, data, error, fetchMore, refetch } = useQuery<ListFeedsResult, ListFeedsVariables>(ListFeedsQuery, {
|
||||
variables: {
|
||||
paging: { page: 0 },
|
||||
query
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const loadNewQueryResults = () => {
|
||||
console.info('Cancelling searches')
|
||||
source.cancel('New query on the way')
|
||||
setResults([])
|
||||
setHasMore(false)
|
||||
setLoaded(false)
|
||||
|
||||
})
|
||||
useEffect(() => {
|
||||
router.push({ query })
|
||||
|
||||
if ((query.search ?? '').length < 1) {
|
||||
console.info('Query too short.')
|
||||
return
|
||||
}
|
||||
console.info('Loading new query search', { query, page })
|
||||
setLoading(true)
|
||||
setTimeout(search)
|
||||
getMatomo(matomoConfig).trackEvent({
|
||||
category: 'feeds',
|
||||
action: 'new-search'
|
||||
})
|
||||
}
|
||||
|
||||
const loadNextPageResults = () => {
|
||||
setHasMore(false)
|
||||
if (page === 0) {
|
||||
return
|
||||
}
|
||||
console.info('Loading next page', { query, page })
|
||||
setTimeout(search)
|
||||
}, [query])
|
||||
useEffect(() => {
|
||||
getMatomo(matomoConfig).trackEvent({
|
||||
category: 'feeds',
|
||||
action: 'next-page',
|
||||
|
@ -89,7 +46,7 @@ const Feeds: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}, [page])
|
||||
|
||||
const handleQueryChange = (event) => {
|
||||
const inputElement = event.target
|
||||
|
@ -99,26 +56,37 @@ const Feeds: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
...query
|
||||
}
|
||||
newQuery[name] = value
|
||||
console.info('Query changed', { name, value })
|
||||
setQuery(newQuery)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const handleSearchSubmit = event => {
|
||||
const handleSearchSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
setQuery(query)
|
||||
setSubmitted(new Date())
|
||||
setPageLoading(true)
|
||||
setPage(0)
|
||||
await refetch({ paging: { page: 0 } })
|
||||
setPageLoading(false)
|
||||
}
|
||||
|
||||
const handleLoadMore = event => {
|
||||
const handleLoadMore = async (event) => {
|
||||
event.preventDefault()
|
||||
setPageLoading(true)
|
||||
await fetchMore({
|
||||
variables: {
|
||||
paging: { page: page + 1 }
|
||||
},
|
||||
updateQuery: (previousData, { fetchMoreResult }) => {
|
||||
fetchMoreResult.listFeeds.items = [
|
||||
...previousData.listFeeds.items,
|
||||
...fetchMoreResult.listFeeds.items
|
||||
]
|
||||
return fetchMoreResult
|
||||
}
|
||||
})
|
||||
setPageLoading(false)
|
||||
setPage(page + 1)
|
||||
}
|
||||
|
||||
useEffect(loadNewQueryResults, [query, submitted])
|
||||
useEffect(loadNextPageResults, [page])
|
||||
|
||||
return (
|
||||
<Layout matomoConfig={matomoConfig}>
|
||||
<Head>
|
||||
|
@ -147,14 +115,14 @@ const Feeds: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
</div>
|
||||
</form>
|
||||
|
||||
<Loader loading={loading} showBottom={true}>
|
||||
<Loader loading={loading || pageLoading} showBottom={true}>
|
||||
{
|
||||
loaded
|
||||
? <FeedResults feeds={results}/>
|
||||
: ''
|
||||
data && query.search
|
||||
? <FeedResults feeds={data.listFeeds.items} />
|
||||
: ''
|
||||
}
|
||||
</Loader>
|
||||
{hasMore && !loading
|
||||
{!loading && !pageLoading && data?.listFeeds?.paging?.hasNext
|
||||
? (
|
||||
<div className={'d-flex justify-content-center'}>
|
||||
<button className={'btn btn-secondary'} onClick={handleLoadMore}>
|
||||
|
@ -164,12 +132,17 @@ const Feeds: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
</div>
|
||||
)
|
||||
: ''}
|
||||
{error
|
||||
? (<div className={'d-flex justify-content-center'}>
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className={'margin-right'}/>
|
||||
<span>{error.message}</span>
|
||||
</div>)
|
||||
: ''}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async () => {
|
||||
console.info('Loading matomo config', matomoConfig)
|
||||
return {
|
||||
props: {
|
||||
matomoConfig
|
||||
|
|
|
@ -1,79 +1,50 @@
|
|||
import Head from 'next/head'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import axios from 'axios'
|
||||
import Loader from '../components/Loader'
|
||||
import Layout, { siteTitle } from '../components/Layout'
|
||||
import { matomoConfig } from '../lib/matomoConfig'
|
||||
import getMatomo from '../lib/getMatomo'
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
|
||||
import { NodeResponseItem, nodeResponseSchema } from '../types/NodeResponse'
|
||||
import SoftwareBadge from '../components/badges/SoftwareBadge'
|
||||
import SortToggle from '../components/SortToggle'
|
||||
import { faSearch, faAngleDoubleDown } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faSearch, faAngleDoubleDown, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { useRouter } from 'next/router'
|
||||
import { NodeRequestQuery, nodeRequestQuerySchema, NodeRequestSortBy } from '../types/NodeRequest'
|
||||
|
||||
let source = axios.CancelToken.source()
|
||||
import { useQuery } from '@apollo/client'
|
||||
import {
|
||||
ListNodesQuery,
|
||||
ListNodesResult
|
||||
} from '../graphql/client/queries/ListNodesQuery'
|
||||
import { nodeQueryInputSchema, NodeQueryInputType } from '../graphql/common/types/NodeQueryInput'
|
||||
import { ListNodesVariables } from '../graphql/common/queries/listNodes'
|
||||
import { NodeSoringByEnumType } from '../graphql/common/types/NodeSortingByEnum'
|
||||
|
||||
const Nodes: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({ matomoConfig }) => {
|
||||
const router = useRouter()
|
||||
const routerQuery = nodeRequestQuerySchema.parse(router.query)
|
||||
let routerQuery:NodeQueryInputType
|
||||
try {
|
||||
routerQuery = nodeQueryInputSchema.parse(router.query)
|
||||
} catch (e) {
|
||||
routerQuery = {
|
||||
search: '',
|
||||
sortBy: 'refreshedAt',
|
||||
sortWay: 'desc'
|
||||
}
|
||||
}
|
||||
console.log('Router query', routerQuery)
|
||||
const [query, setQuery] = useState<NodeRequestQuery>(routerQuery)
|
||||
const [submitted, setSubmitted] = useState<Date|null>(null)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [results, setResults] = useState<NodeResponseItem[]>([])
|
||||
const [query, setQuery] = useState<NodeQueryInputType>(routerQuery)
|
||||
const [page, setPage] = useState<number>(0)
|
||||
const [hasMore, setHasMore] = useState<boolean>(false)
|
||||
const [loaded, setLoaded] = useState<boolean>(false)
|
||||
|
||||
const search = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
console.info('Retrieving results', { query, page })
|
||||
source = axios.CancelToken.source()
|
||||
const response = await axios.get('/api/node', {
|
||||
params: { ...query, page },
|
||||
cancelToken: source.token
|
||||
})
|
||||
const responseData = await nodeResponseSchema.parseAsync(response.data)
|
||||
setHasMore(responseData.hasMore)
|
||||
setResults([
|
||||
...(page > 0 ? results : []),
|
||||
...responseData.nodes
|
||||
])
|
||||
setLoaded(true)
|
||||
} catch (e) {
|
||||
console.warn('Search failed', e)
|
||||
setLoaded(true)
|
||||
const [pageLoading, setPageLoading] = useState<boolean>(false)
|
||||
const { loading, error, data, fetchMore, refetch } = useQuery<ListNodesResult, ListNodesVariables>(ListNodesQuery, {
|
||||
variables: {
|
||||
query,
|
||||
paging: {
|
||||
page: 0
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
const loadNewQueryResults = () => {
|
||||
console.info('Cancelling searches')
|
||||
source.cancel('New query on the way')
|
||||
router.push({ query })
|
||||
setResults([])
|
||||
setHasMore(false)
|
||||
setLoaded(false)
|
||||
console.info('Loading new query search', { query, page })
|
||||
setLoading(true)
|
||||
setTimeout(search)
|
||||
getMatomo(matomoConfig).trackEvent({
|
||||
category: 'nodes',
|
||||
action: 'new-search'
|
||||
})
|
||||
}
|
||||
|
||||
const loadNextPageResults = () => {
|
||||
setHasMore(false)
|
||||
if (page === 0) {
|
||||
return
|
||||
}
|
||||
console.info('Loading next page', { query, page })
|
||||
setTimeout(search)
|
||||
useEffect(() => {
|
||||
getMatomo(matomoConfig).trackEvent({
|
||||
category: 'nodes',
|
||||
action: 'next-page',
|
||||
|
@ -84,32 +55,59 @@ const Nodes: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}, [page])
|
||||
useEffect(() => {
|
||||
router.push({ query })
|
||||
getMatomo(matomoConfig).trackEvent({
|
||||
category: 'nodes',
|
||||
action: 'new-search'
|
||||
})
|
||||
}, [query])
|
||||
|
||||
const handleQueryChange = (event) => {
|
||||
const targetInput = event.target
|
||||
const value = targetInput.value
|
||||
const name = targetInput.name
|
||||
const newQuery:NodeRequestQuery = { ...query }
|
||||
const newQuery:NodeQueryInputType = { ...query }
|
||||
newQuery[name] = value
|
||||
console.info('Query changed', { name, value })
|
||||
setQuery(newQuery)
|
||||
setPage(0)
|
||||
}
|
||||
|
||||
const handleSearchSubmit = event => {
|
||||
const handleSearchSubmit = async (event) => {
|
||||
setPageLoading(true)
|
||||
event.preventDefault()
|
||||
setQuery(query)
|
||||
setSubmitted(new Date())
|
||||
setPage(0)
|
||||
await refetch({ paging: { page: 0 } })
|
||||
setPageLoading(false)
|
||||
}
|
||||
|
||||
const handleLoadMore = event => {
|
||||
const handleLoadMore = async (event) => {
|
||||
event.preventDefault()
|
||||
setPage(page + 1)
|
||||
console.info('Loading next page', { query, page })
|
||||
setPageLoading(true)
|
||||
await fetchMore({
|
||||
variables: {
|
||||
paging: { page: page + 1 }
|
||||
},
|
||||
updateQuery: (previousData, { fetchMoreResult }) => {
|
||||
console.log('more', {
|
||||
previousData, fetchMoreResult
|
||||
})
|
||||
fetchMoreResult.listNodes.items = [
|
||||
...previousData.listNodes.items,
|
||||
...fetchMoreResult.listNodes.items
|
||||
]
|
||||
return fetchMoreResult
|
||||
}
|
||||
})
|
||||
setPageLoading(false)
|
||||
}
|
||||
|
||||
const toggleSort = (sortBy: NodeRequestSortBy) => {
|
||||
const toggleSort = (sortBy: NodeSoringByEnumType) => {
|
||||
const sortWay = query.sortBy === sortBy && query.sortWay === 'asc' ? 'desc' : 'asc'
|
||||
getMatomo(matomoConfig).trackEvent({
|
||||
category: 'nodes',
|
||||
|
@ -121,15 +119,13 @@ const Nodes: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
}
|
||||
]
|
||||
})
|
||||
const newQuery:NodeRequestQuery = { ...query }
|
||||
newQuery.sortBy = sortBy
|
||||
newQuery.sortWay = sortWay
|
||||
setQuery(newQuery)
|
||||
setQuery({
|
||||
...query,
|
||||
sortBy,
|
||||
sortWay
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(loadNewQueryResults, [query, submitted])
|
||||
useEffect(loadNextPageResults, [page])
|
||||
|
||||
return (
|
||||
<Layout matomoConfig={matomoConfig}>
|
||||
<Head>
|
||||
|
@ -157,9 +153,9 @@ const Nodes: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<Loader loading={loading} showBottom={true}>
|
||||
<Loader loading={loading || pageLoading} showBottom={true}>
|
||||
{
|
||||
loaded
|
||||
data
|
||||
? (
|
||||
<div className="table-responsive">
|
||||
<table className={'table table-dark table-striped table-bordered nodes'}>
|
||||
|
@ -175,7 +171,7 @@ const Nodes: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
Software
|
||||
</SortToggle>
|
||||
</th>
|
||||
<th colSpan={3}>User count</th>
|
||||
<th colSpan={4}>User count</th>
|
||||
<th rowSpan={2} className={'number-cell'}>
|
||||
<SortToggle onToggle={toggleSort} field={'statusesCount'} sort={query}>
|
||||
Statuses
|
||||
|
@ -198,6 +194,11 @@ const Nodes: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
Total
|
||||
</SortToggle>
|
||||
</th>
|
||||
<th className={'text-end'}>
|
||||
<SortToggle onToggle={toggleSort} field={'accountFeedCount'} sort={query}>
|
||||
Indexed
|
||||
</SortToggle>
|
||||
</th>
|
||||
<th className={'text-end'}>
|
||||
<SortToggle onToggle={toggleSort} field={'monthActiveUserCount'} sort={query}>
|
||||
Month active
|
||||
|
@ -211,17 +212,18 @@ const Nodes: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{results.length
|
||||
? results.map((node, index) => {
|
||||
{data.listNodes.items.length
|
||||
? data.listNodes.items.map((node, index) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td>{node.domain}</td>
|
||||
<td>
|
||||
<div title={'Name'}><SoftwareBadge
|
||||
softwareName={node.softwareName}/></div>
|
||||
<div title={'Version'}>{node.softwareVersion ?? ''}</div>
|
||||
<div title={'Version: ' + node.softwareVersion ?? '?'}>{node.standardizedSoftwareVersion ?? ''}</div>
|
||||
</td>
|
||||
<td className={'text-end'}>{node.totalUserCount ?? '?'}</td>
|
||||
<td className={'text-end'}>{node.accountFeedCount ?? '0'}</td>
|
||||
<td className={'text-end'}>{node.monthActiveUserCount ?? '?'}</td>
|
||||
<td className={'text-end'}>{node.halfYearActiveUserCount ?? '?'}</td>
|
||||
<td className={'text-end'}>{node.statusesCount ?? '?'}</td>
|
||||
|
@ -242,7 +244,7 @@ const Nodes: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
: ''
|
||||
}
|
||||
</Loader>
|
||||
{hasMore && !loading
|
||||
{data?.listNodes?.paging?.hasNext && !loading && !pageLoading
|
||||
? (
|
||||
<div className={'d-flex justify-content-center'}>
|
||||
<button className={'btn btn-secondary'} onClick={handleLoadMore}>
|
||||
|
@ -252,11 +254,17 @@ const Nodes: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
</div>
|
||||
)
|
||||
: ''}
|
||||
{error
|
||||
? (<div className={'d-flex justify-content-center'}>
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className={'margin-right'}/>
|
||||
<span>{error.message}</span>
|
||||
</div>)
|
||||
: ''}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
export const getServerSideProps: GetServerSideProps = async () => {
|
||||
console.info('Loading matomo config', matomoConfig)
|
||||
return {
|
||||
props: {
|
||||
|
|
|
@ -4,27 +4,47 @@ import { matomoConfig } from '../lib/matomoConfig'
|
|||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import Loader from '../components/Loader'
|
||||
import axios from 'axios'
|
||||
import { StatsResponse, statsResponseSchema } from '../types/StatsResponse'
|
||||
import SoftwareBadge from '../components/badges/SoftwareBadge'
|
||||
import ProgressBar from '../components/ProgressBar'
|
||||
import { StatsRequest, statsRequestSchema, StatsRequestSortBy } from '../types/StatsRequest'
|
||||
import SortToggle from '../components/SortToggle'
|
||||
import getMatomo from '../lib/getMatomo'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
let source = axios.CancelToken.source()
|
||||
import { statsQueryInputSchema, StatsQueryInputType } from '../graphql/common/types/StatsQueryInput'
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { ListStatsQuery, ListStatsResult } from '../graphql/client/queries/ListStatsQuery'
|
||||
import { ListStatsVariables } from '../graphql/common/queries/listStats'
|
||||
import { StatsSoringByEnumType } from '../graphql/common/types/StatsSortingByEnum'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faExclamationTriangle } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> = ({ matomoConfig }) => {
|
||||
const router = useRouter()
|
||||
const routerQuery = statsRequestSchema.parse(router.query)
|
||||
let routerQuery
|
||||
try {
|
||||
routerQuery = statsQueryInputSchema.parse(router.query)
|
||||
} catch (e) {
|
||||
routerQuery = {
|
||||
sortBy: 'nodeCount',
|
||||
sortWay: 'desc'
|
||||
}
|
||||
}
|
||||
console.log('Router query', routerQuery)
|
||||
const [query, setQuery] = useState<StatsRequest>(routerQuery)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [loaded, setLoaded] = useState<boolean>(false)
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null)
|
||||
const [query, setQuery] = useState<StatsQueryInputType>(routerQuery)
|
||||
const { loading, error, data } = useQuery<ListStatsResult, ListStatsVariables>(ListStatsQuery, {
|
||||
variables: {
|
||||
query
|
||||
}
|
||||
})
|
||||
|
||||
const toggleSort = (sortBy: StatsRequestSortBy) => {
|
||||
useEffect(() => {
|
||||
router.push({ query })
|
||||
getMatomo(matomoConfig).trackEvent({
|
||||
category: 'stats',
|
||||
action: 'new-search'
|
||||
})
|
||||
}, [query])
|
||||
|
||||
const toggleSort = (sortBy: StatsSoringByEnumType) => {
|
||||
const sortWay = query.sortBy === sortBy && query.sortWay === 'asc' ? 'desc' : 'asc'
|
||||
getMatomo(matomoConfig).trackEvent({
|
||||
category: 'stats',
|
||||
|
@ -36,62 +56,36 @@ const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
}
|
||||
]
|
||||
})
|
||||
const newQuery:StatsRequest = { ...query }
|
||||
newQuery.sortBy = sortBy
|
||||
newQuery.sortWay = sortWay
|
||||
setQuery(newQuery)
|
||||
setQuery({
|
||||
...query,
|
||||
sortBy,
|
||||
sortWay
|
||||
})
|
||||
}
|
||||
|
||||
const retrieveStats = async () => {
|
||||
console.info('Retrieving stats', { query })
|
||||
source = axios.CancelToken.source()
|
||||
setLoading(true)
|
||||
await router.push({ query })
|
||||
try {
|
||||
const response = await axios.get('/api/stats', {
|
||||
params: query,
|
||||
cancelToken: source.token
|
||||
})
|
||||
const stats = await statsResponseSchema.parseAsync(response.data)
|
||||
setStats(stats)
|
||||
} catch (err) {
|
||||
setStats(null)
|
||||
console.log(err)
|
||||
}
|
||||
setLoaded(true)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
console.info('Cancelling retrivals')
|
||||
source.cancel('New query on the way')
|
||||
setTimeout(retrieveStats)
|
||||
}
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [query])
|
||||
const sum = {
|
||||
nodeCount: 0,
|
||||
accountCount: 0,
|
||||
channelCount: 0
|
||||
accountFeedCount: 0,
|
||||
channelFeedCount: 0
|
||||
}
|
||||
const max = {
|
||||
nodeCount: 0,
|
||||
accountCount: 0,
|
||||
channelCount: 0
|
||||
accountFeedCount: 0,
|
||||
channelFeedCount: 0
|
||||
}
|
||||
if (data) {
|
||||
data.listStats.items.forEach(item => {
|
||||
if (item.softwareName === null) {
|
||||
return
|
||||
}
|
||||
sum.nodeCount += item.nodeCount
|
||||
sum.accountFeedCount += item.accountFeedCount
|
||||
sum.channelFeedCount += item.channelFeedCount
|
||||
max.nodeCount = Math.max(item.nodeCount, max.nodeCount)
|
||||
max.accountFeedCount = Math.max(item.accountFeedCount, max.accountFeedCount)
|
||||
max.channelFeedCount = Math.max(item.channelFeedCount, max.channelFeedCount)
|
||||
})
|
||||
}
|
||||
const softwares = stats === null ? [] : stats.softwares
|
||||
softwares.forEach(software => {
|
||||
if (software.name === null) {
|
||||
return
|
||||
}
|
||||
sum.nodeCount += software.nodeCount
|
||||
sum.accountCount += software.accountCount
|
||||
sum.channelCount += software.channelCount
|
||||
max.nodeCount = Math.max(software.nodeCount, max.nodeCount)
|
||||
max.accountCount = Math.max(software.accountCount, max.accountCount)
|
||||
max.channelCount = Math.max(software.channelCount, max.channelCount)
|
||||
})
|
||||
return (
|
||||
<Layout matomoConfig={matomoConfig}>
|
||||
<Head>
|
||||
|
@ -113,32 +107,35 @@ const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
</SortToggle>
|
||||
</th>
|
||||
<th className={'text-end'}>
|
||||
<SortToggle onToggle={toggleSort} field={'accountCount'} sort={query}>
|
||||
<SortToggle onToggle={toggleSort} field={'accountFeedCount'} sort={query}>
|
||||
Account count
|
||||
</SortToggle>
|
||||
</th>
|
||||
<th className={'text-end'}>
|
||||
<SortToggle onToggle={toggleSort} field={'channelCount'} sort={query}>
|
||||
<SortToggle onToggle={toggleSort} field={'channelFeedCount'} sort={query}>
|
||||
Channel count
|
||||
</SortToggle>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<Loader loading={loading} hideContent={!loaded} table={4} showTop={true}>
|
||||
{stats === null
|
||||
? (<tr>
|
||||
<td colSpan={4}><em>Failed to load stats data!</em></td>
|
||||
</tr>)
|
||||
<Loader loading={loading} table={4} showTop={true} hideContent={true}>
|
||||
{!data
|
||||
? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={4}><em>There are no stats so far!</em></td>
|
||||
</tr>
|
||||
</tbody>)
|
||||
: (
|
||||
<>
|
||||
<tbody>
|
||||
{
|
||||
stats.softwares.map((software, index) => {
|
||||
return software.name !== null
|
||||
data.listStats.items.map((software, index) => {
|
||||
return software.softwareName !== null
|
||||
? (
|
||||
<tr key={index}>
|
||||
<td>
|
||||
<SoftwareBadge softwareName={software.name}/>
|
||||
<SoftwareBadge softwareName={software.softwareName}/>
|
||||
</td>
|
||||
<td className={'text-end'}>
|
||||
<span>{software.nodeCount}</span>
|
||||
|
@ -146,14 +143,14 @@ const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
percents={100 * software.nodeCount / max.nodeCount}/>
|
||||
</td>
|
||||
<td className={'text-end'}>
|
||||
<span>{software.accountCount}</span>
|
||||
<span>{software.accountFeedCount}</span>
|
||||
<ProgressBar way={'left'}
|
||||
percents={100 * software.accountCount / max.accountCount}/>
|
||||
percents={100 * software.accountFeedCount / max.accountFeedCount}/>
|
||||
</td>
|
||||
<td className={'text-end'}>
|
||||
<span>{software.channelCount}</span>
|
||||
<span>{software.channelFeedCount}</span>
|
||||
<ProgressBar way={'left'}
|
||||
percents={100 * software.channelCount / max.channelCount}/>
|
||||
percents={100 * software.channelFeedCount / max.channelFeedCount}/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
@ -163,10 +160,10 @@ const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
<td className={'text-end'}><span>{software.nodeCount}</span>
|
||||
</td>
|
||||
<td className={'text-end'}>
|
||||
<span>{software.accountCount}</span>
|
||||
<span>{software.accountFeedCount}</span>
|
||||
</td>
|
||||
<td className={'text-end'}>
|
||||
<span>{software.channelCount}</span>
|
||||
<span>{software.channelFeedCount}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
@ -177,8 +174,8 @@ const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
<tr>
|
||||
<th>Summary</th>
|
||||
<th className={'text-end'}>{sum.nodeCount}</th>
|
||||
<th className={'text-end'}>{sum.accountCount}</th>
|
||||
<th className={'text-end'}>{sum.channelCount}</th>
|
||||
<th className={'text-end'}>{sum.accountFeedCount}</th>
|
||||
<th className={'text-end'}>{sum.channelFeedCount}</th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</>
|
||||
|
@ -186,12 +183,18 @@ const Stats: React.FC<InferGetServerSidePropsType<typeof getServerSideProps>> =
|
|||
}
|
||||
</Loader>
|
||||
</table>
|
||||
{error
|
||||
? (<div className={'d-flex justify-content-center'}>
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className={'margin-right'}/>
|
||||
<span>{error.message}</span>
|
||||
</div>)
|
||||
: ''}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||
export const getServerSideProps: GetServerSideProps = async () => {
|
||||
console.info('Loading matomo config', matomoConfig)
|
||||
return {
|
||||
props: {
|
||||
|
|
|
@ -76,6 +76,15 @@ table.stats {
|
|||
grid-row: address;
|
||||
}
|
||||
|
||||
.parent-feed{
|
||||
position: relative;
|
||||
.avatar{
|
||||
max-width: 3em;
|
||||
float: left;
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.software-name{
|
||||
grid-column: main /end;
|
||||
grid-row: software;
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
import { z } from 'zod'
|
||||
import { preserveUndefined, stringToInt, stringTrimmed, transform } from '../lib/transform'
|
||||
|
||||
export const feedRequestQuerySchema = z.object({
|
||||
search: transform(
|
||||
z.string().optional(),
|
||||
stringTrimmed,
|
||||
z.string()
|
||||
)
|
||||
/*
|
||||
softwareName: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
displayName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
followersCount: transform(
|
||||
z.string().optional(),
|
||||
preserveUndefined(stringToInt),
|
||||
z.number().gte(0).optional()
|
||||
),
|
||||
followingCount: transform(
|
||||
z.string().optional(),
|
||||
preserveUndefined(stringToInt),
|
||||
z.number().gte(0).optional()
|
||||
),
|
||||
statusesCount: transform(
|
||||
z.string().optional(),
|
||||
preserveUndefined(stringToInt),
|
||||
z.number().gte(0).optional()
|
||||
),
|
||||
bot: transform(
|
||||
z.string().optional(),
|
||||
preserveUndefined(stringToBool),
|
||||
z.boolean().optional()
|
||||
),
|
||||
lastStatusAt: z.string().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
type: z.enum(['account', 'channel']).optional()
|
||||
*/
|
||||
})
|
||||
|
||||
export const feedRequestSchema = feedRequestQuerySchema.extend({
|
||||
page: transform(
|
||||
z.string().optional(),
|
||||
preserveUndefined(stringToInt),
|
||||
z.number().gte(0).optional()
|
||||
)
|
||||
})
|
||||
|
||||
export type FeedRequest = z.infer<typeof feedRequestSchema>
|
||||
export type FeedRequestQuery = z.infer<typeof feedRequestQuerySchema>
|
|
@ -1,46 +0,0 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const feedResponseFieldSchema = z.object({
|
||||
name: z.string(),
|
||||
value: z.string()
|
||||
})
|
||||
|
||||
export const feedResponseParentSchema = z.object({
|
||||
name: z.string(),
|
||||
domain: z.string(),
|
||||
displayName: z.string().nullable(),
|
||||
avatar: z.string().nullable(),
|
||||
url: z.string()
|
||||
})
|
||||
|
||||
export const feedResponseItemSchema = z.object({
|
||||
avatar: z.string().url().nullable(),
|
||||
bot: z.boolean().nullable(),
|
||||
createdAt: z.string(),
|
||||
description: z.string(),
|
||||
|
||||
displayName: z.string(),
|
||||
fields: z.array(feedResponseFieldSchema).nullable(),
|
||||
followersCount: z.number().nullable(),
|
||||
followingCount: z.number().nullable(),
|
||||
statusesCount: z.number().nullable(),
|
||||
lastStatusAt: z.string().nullable(),
|
||||
name: z.string(),
|
||||
node: z.object({
|
||||
domain: z.string(),
|
||||
softwareName: z.string()
|
||||
}),
|
||||
type: z.enum(['account', 'channel']),
|
||||
url: z.string().url(),
|
||||
parentFeed: z.nullable(feedResponseParentSchema)
|
||||
})
|
||||
|
||||
export const feedResponseSchema = z.object({
|
||||
hasMore: z.boolean(),
|
||||
feeds: z.array(feedResponseItemSchema)
|
||||
})
|
||||
|
||||
export type FeedResponse = z.infer<typeof feedResponseSchema>
|
||||
export type FeedResponseItem = z.infer<typeof feedResponseItemSchema>
|
||||
export type FeedResponseField = z.infer<typeof feedResponseFieldSchema>
|
||||
export type FeedResponseParent = z.infer<typeof feedResponseParentSchema>
|
|
@ -1,50 +0,0 @@
|
|||
import { z } from 'zod'
|
||||
import { preserveUndefined, stringToInt, stringTrimmed, transform, undefinedToDefault } from '../lib/transform'
|
||||
|
||||
export const nodeRequestSortBySchema = z.enum([
|
||||
'softwareName',
|
||||
'softwareVersion',
|
||||
'totalUserCount',
|
||||
'monthActiveUserCount',
|
||||
'halfYearActiveUserCount',
|
||||
'statusesCount',
|
||||
'openRegistrations',
|
||||
'refreshedAt',
|
||||
'domain'
|
||||
])
|
||||
|
||||
export const nodeRequestSortWaySchema = z.enum([
|
||||
'asc',
|
||||
'desc'
|
||||
])
|
||||
|
||||
export const nodeRequestQuerySchema = z.object({
|
||||
sortBy: transform(
|
||||
z.optional(nodeRequestSortBySchema),
|
||||
undefinedToDefault<NodeRequestSortBy>('refreshedAt'),
|
||||
nodeRequestSortBySchema
|
||||
),
|
||||
sortWay: transform(
|
||||
z.optional(nodeRequestSortWaySchema),
|
||||
undefinedToDefault<NodeRequestSortWay>('desc'),
|
||||
nodeRequestSortWaySchema
|
||||
),
|
||||
search: transform(
|
||||
z.string().optional(),
|
||||
stringTrimmed,
|
||||
z.string()
|
||||
)
|
||||
})
|
||||
|
||||
export const nodeRequestSchema = nodeRequestQuerySchema.extend({
|
||||
page: transform(
|
||||
z.string().optional(),
|
||||
preserveUndefined(stringToInt),
|
||||
z.number().gte(0).optional()
|
||||
)
|
||||
})
|
||||
|
||||
export type NodeRequestQuery = z.infer<typeof nodeRequestQuerySchema>
|
||||
export type NodeRequest = z.infer<typeof nodeRequestSchema>
|
||||
export type NodeRequestSortWay = z.infer<typeof nodeRequestSortWaySchema>
|
||||
export type NodeRequestSortBy = z.infer<typeof nodeRequestSortBySchema>
|
|
@ -1,21 +0,0 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const nodeResponseItemSchema = z.object({
|
||||
softwareName: z.string().nullable(),
|
||||
softwareVersion: z.string().nullable(),
|
||||
totalUserCount: z.number().nullable(),
|
||||
monthActiveUserCount: z.number().nullable(),
|
||||
halfYearActiveUserCount: z.number().nullable(),
|
||||
statusesCount: z.number().nullable(),
|
||||
openRegistrations: z.boolean().nullable(),
|
||||
refreshedAt: z.string().nullable(),
|
||||
domain: z.string()
|
||||
})
|
||||
|
||||
export const nodeResponseSchema = z.object({
|
||||
hasMore: z.boolean(),
|
||||
nodes: z.array(nodeResponseItemSchema)
|
||||
})
|
||||
|
||||
export type NodeResponse = z.infer<typeof nodeResponseSchema>
|
||||
export type NodeResponseItem = z.infer<typeof nodeResponseItemSchema>
|
|
@ -1,20 +0,0 @@
|
|||
import { z } from 'zod'
|
||||
import { transform, undefinedToDefault } from '../lib/transform'
|
||||
export const statsRequestSortBySchema = z.enum(['nodeCount', 'accountCount', 'channelCount', 'softwareName'])
|
||||
export const statsRequestSortWaySchema = z.enum(['asc', 'desc'])
|
||||
export const statsRequestSchema = z.object({
|
||||
sortBy: transform(
|
||||
z.optional(statsRequestSortBySchema),
|
||||
undefinedToDefault<StatsRequestSortBy>('accountCount'),
|
||||
z.optional(statsRequestSortBySchema)
|
||||
),
|
||||
sortWay: transform(
|
||||
z.optional(statsRequestSortWaySchema),
|
||||
undefinedToDefault<StatsRequestSortWay>('desc'),
|
||||
statsRequestSortWaySchema
|
||||
)
|
||||
})
|
||||
|
||||
export type StatsRequest = z.infer<typeof statsRequestSchema>
|
||||
export type StatsRequestSortWay = z.infer<typeof statsRequestSortWaySchema>
|
||||
export type StatsRequestSortBy = z.infer<typeof statsRequestSortBySchema>
|
|
@ -1,16 +0,0 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
export const statsResponseSoftwareSchema = z.object({
|
||||
name: z.string().nullable(),
|
||||
nodeCount: z.number().int().min(0),
|
||||
accountCount: z.number().int().min(0),
|
||||
channelCount: z.number().int().min(0),
|
||||
newNodeCount: z.number().int().min(0)
|
||||
})
|
||||
|
||||
export const statsResponseSchema = z.object({
|
||||
softwares: z.array(statsResponseSoftwareSchema)
|
||||
})
|
||||
|
||||
export type StatsResponse = z.infer<typeof statsResponseSchema>
|
||||
export type StatsResponseSoftware = z.infer<typeof statsResponseSoftwareSchema>
|
Ładowanie…
Reference in New Issue