Porównaj commity

...

3 Commity

Autor SHA1 Wiadomość Data
Štěpán Škorpil 21e2ac6172 Dockerfile: Fixed node version mismatch 2022-09-14 22:05:06 +02:00
Štěpán Škorpil 0336e2183c Deploy fixes 2022-09-14 21:47:09 +02:00
Štěpán Škorpil 59c1ee743d Replaced postgresql by elastic search 2022-09-14 21:16:00 +02:00
89 zmienionych plików z 10948 dodań i 5720 usunięć

Wyświetl plik

@ -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

Wyświetl plik

@ -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

14270
application/package-lock.json wygenerowano

Plik diff jest za duży Load Diff

Wyświetl plik

@ -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"

Wyświetl plik

@ -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])
}

Wyświetl plik

@ -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}
/>
)
}

Wyświetl plik

@ -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}/>
}

Wyświetl plik

@ -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'}

Wyświetl plik

@ -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>)

Wyświetl plik

@ -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>
}

Wyświetl plik

@ -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"

Wyświetl plik

@ -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 (

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1,8 @@
import { ApolloClient, InMemoryCache } from '@apollo/client'
export default function createGraphqlClient () {
return new ApolloClient({
uri: '/api/graphql',
cache: new InMemoryCache()
})
}

Wyświetl plik

@ -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>
}

Wyświetl plik

@ -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>;
}

Wyświetl plik

@ -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>;
}

Wyświetl plik

@ -0,0 +1,6 @@
import { PagingType } from '../../server/schema/types'
export type List<TItem> = {
paging: PagingType,
items: TItem[]
}

Wyświetl plik

@ -0,0 +1,7 @@
import { FeedQueryInputType } from '../types/FeedQueryInput'
import { PagingInputType } from '../types/PagingInput'
export type ListFeedsVariables = {
paging: PagingInputType;
query: FeedQueryInputType
}

Wyświetl plik

@ -0,0 +1,7 @@
import { PagingInputType } from '../types/PagingInput'
import { NodeQueryInputType } from '../types/NodeQueryInput'
export type ListNodesVariables = {
paging: PagingInputType;
query: NodeQueryInputType
}

Wyświetl plik

@ -0,0 +1,5 @@
import { StatsQueryInputType } from '../types/StatsQueryInput'
export type ListStatsVariables = {
query: StatsQueryInputType
}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>;

Wyświetl plik

@ -0,0 +1,3 @@
export type PagingInputType = {
page: number
}

Wyświetl plik

@ -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'
}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>;

Wyświetl plik

@ -0,0 +1,10 @@
import { ElasticClient } from '../../../lib/storage/ElasticClient'
type Context = {
elasticClient: ElasticClient
defaultPaging: {
limit: 20
}
}
export default Context

Wyświetl plik

@ -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
}
}
}

Wyświetl plik

@ -0,0 +1,2 @@
export type { default as Context } from './Context'
export { default as createContext } from './createContext'

Wyświetl plik

@ -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
})
}

Wyświetl plik

@ -0,0 +1,9 @@
const resolvers = {
Query: {
links: () => {
return []
}
}
}
export default resolvers

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1,3 @@
export * from './listFeeds'
export * from './listNodes'
export * from './listStats'

Wyświetl plik

@ -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
})
}
}
})
}
})

Wyświetl plik

@ -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
})
}
}
})
}
})

Wyświetl plik

@ -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
}
})
}
}
})
}
})

Wyświetl plik

@ -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 }

Wyświetl plik

@ -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)
}
})

Wyświetl plik

@ -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
}
})
}
})

Wyświetl plik

@ -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 })
}
})

Wyświetl plik

@ -0,0 +1,8 @@
import { inputObjectType } from 'nexus'
export const FeedQueryInput = inputObjectType({
name: 'FeedQueryInput',
definition: (t) => {
t.nonNull.string('search', { default: '' })
}
})

Wyświetl plik

@ -0,0 +1,6 @@
import { enumType } from 'nexus'
export const FeedType = enumType({
name: 'FeedType',
members: ['account', 'channel']
})

Wyświetl plik

@ -0,0 +1,9 @@
import { objectType } from 'nexus'
export const Field = objectType({
name: 'Field',
definition: (t) => {
t.nonNull.string('name')
t.nonNull.string('value')
}
})

Wyświetl plik

@ -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')
}
})

Wyświetl plik

@ -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')
}
})

Wyświetl plik

@ -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 })
}
})

Wyświetl plik

@ -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')

Wyświetl plik

@ -0,0 +1,4 @@
import { createSortingByEnum } from './SortingByEnum'
import { NodeSortingByValues } from '../../../common/types/NodeSortingByEnum'
export const NodeSortingByEnum = createSortingByEnum('NodeSortingByEnum', NodeSortingByValues)

Wyświetl plik

@ -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
}

Wyświetl plik

@ -0,0 +1,8 @@
import { inputObjectType } from 'nexus'
export const PagingInput = inputObjectType({
name: 'PagingInput',
definition: (t) => {
t.nonNull.int('page', { default: 0 })
}
})

Wyświetl plik

@ -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
})
}
})

Wyświetl plik

@ -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
})
}

Wyświetl plik

@ -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)
}
})
}

Wyświetl plik

@ -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 }

Wyświetl plik

@ -0,0 +1,6 @@
import { enumType } from 'nexus'
export const SortingWay = enumType({
name: 'SortingWay',
members: ['asc', 'desc']
})

Wyświetl plik

@ -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')
}
})

Wyświetl plik

@ -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 })
}
})

Wyświetl plik

@ -0,0 +1,10 @@
import { createSortingInput } from './SortingInput'
import { StatsSortingByEnum } from './StatsSortingByEnum'
export const StatsQueryInput = createSortingInput(
'StatsQueryInput',
StatsSortingByEnum,
() => {},
'nodeCount',
'desc'
)

Wyświetl plik

@ -0,0 +1,4 @@
import { createSortingByEnum } from './SortingByEnum'
import { StatsSortingByValues } from '../../../common/types/StatsSortingByEnum'
export const StatsSortingByEnum = createSortingByEnum('StatsSortingByEnum', StatsSortingByValues)

Wyświetl plik

@ -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'

Wyświetl plik

@ -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)
}

Wyświetl plik

@ -0,0 +1,3 @@
export default function getNodeId (name:string, domain:string):string {
return `${name}@${domain}`
}

Wyświetl plik

@ -1,2 +0,0 @@
export const pageLimit = 20

Wyświetl plik

@ -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(' ')
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1,8 @@
interface Field {
name: string,
value: string
strippedName?: string
strippedValue?: string
}
export default Field

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1,3 @@
const feedIndex = 'feed'
export default feedIndex

Wyświetl plik

@ -0,0 +1,3 @@
const nodeIndex = 'node'
export default nodeIndex

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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
}
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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: {

Wyświetl plik

@ -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: {

Wyświetl plik

@ -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;

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>

Wyświetl plik

@ -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>