Merge branch 'develop' of gitlab.com:taobojlen/fediverse.space into develop

This commit is contained in:
Tao Bror Bojlén 2019-07-31 16:50:18 +03:00
commit b8264eb283
No known key found for this signature in database
GPG Key ID: C6EC7AAB905F9E6F
19 changed files with 313 additions and 51 deletions

View File

@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added ElasticSearch full-text search over instance domains and descriptions.
- Search results are now highlighted on the graph.
- When you hover a search result, it is now highlighted on the graph.
- Instance details now show activity rate (average number of statuses posted per day).
- It's now possible to color code by activity rate.
### Changed

View File

@ -74,6 +74,8 @@ config :backend, Backend.Scheduler,
{"15 0 * * *", {Backend.Scheduler, :generate_edges, []}},
# 00.30 every night
{"30 0 * * *", {Backend.Scheduler, :generate_insularity_scores, []}},
# 00.45 every night
{"45 0 * * *", {Backend.Scheduler, :generate_status_rate, []}},
# Every 3 hours
{"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}}
]

View File

@ -37,7 +37,7 @@ defmodule Backend.Api do
i.user_count >= ^user_threshold and not i.opt_out
)
|> maybe_filter_nodes_to_neighborhood(domain)
|> select([c], [:domain, :user_count, :x, :y, :type])
|> select([c], [:domain, :user_count, :x, :y, :type, :statuses_per_day])
|> Repo.all()
end

View File

@ -10,6 +10,7 @@ defmodule Backend.Instance do
field :version, :string
field :insularity, :float
field :type, :string
field :statuses_per_day, :float
field :base_domain, :string
field :opt_in, :boolean
field :opt_out, :boolean
@ -39,6 +40,7 @@ defmodule Backend.Instance do
:insularity,
:updated_at,
:type,
:statuses_per_day,
:base_domain,
:opt_in,
:opt_out

View File

@ -76,6 +76,61 @@ defmodule Backend.Scheduler do
)
end
@doc """
This function calculates the average number of statuses per hour over the last month.
"""
def generate_status_rate() do
now = get_now()
# We want the earliest sucessful crawl so that we can exclude it from the statistics.
# This is because the first crawl goes up to one month into the past -- this would mess up the counts!
# The statistics from here assume that all statuses were written at exactly the crawl's inserted_at timestamp.
earliest_successful_crawl_subquery =
Crawl
|> group_by([c], c.instance_domain)
|> select([c], %{
instance_domain: c.instance_domain,
earliest_crawl: min(c.inserted_at)
})
instances =
Crawl
|> join(:inner, [c], c2 in subquery(earliest_successful_crawl_subquery),
on: c.instance_domain == c2.instance_domain
)
|> where(
[c, c2],
c.inserted_at > c2.earliest_crawl and not is_nil(c.statuses_seen) and is_nil(c.error)
)
|> select([c], %{
instance_domain: c.instance_domain,
status_count: sum(c.statuses_seen),
second_earliest_crawl: min(c.inserted_at)
})
|> group_by([c], c.instance_domain)
|> Repo.all()
|> Enum.map(fn %{
instance_domain: domain,
status_count: status_count,
second_earliest_crawl: oldest_timestamp
} ->
time_diff_days = NaiveDateTime.diff(now, oldest_timestamp, :second) / (3600 * 24)
# (we're actually only ever updating, not inserting, so inserted_at will always be ignored...)
%{
domain: domain,
statuses_per_day: status_count / time_diff_days,
updated_at: now,
inserted_at: now
}
end)
Instance
|> Repo.insert_all(instances,
on_conflict: {:replace, [:statuses_per_day, :updated_at]},
conflict_target: :domain
)
end
@doc """
This function aggregates statistics from the interactions in the database.
It calculates the strength of edges between nodes. Self-edges are not generated.

View File

@ -3,9 +3,24 @@ defmodule BackendWeb.GraphView do
alias BackendWeb.GraphView
def render("index.json", %{nodes: nodes, edges: edges}) do
statuses_per_day =
nodes
|> Enum.map(fn %{statuses_per_day: statuses_per_day} -> statuses_per_day end)
|> Enum.filter(fn s -> s != nil end)
%{
nodes: render_many(nodes, GraphView, "node.json", as: :node),
edges: render_many(edges, GraphView, "edge.json", as: :edge)
graph: %{
nodes: render_many(nodes, GraphView, "node.json", as: :node),
edges: render_many(edges, GraphView, "edge.json", as: :edge)
},
metadata: %{
ranges: %{
statusesPerDay: [
Enum.min(statuses_per_day),
Enum.max(statuses_per_day)
]
}
}
}
end
@ -22,7 +37,8 @@ defmodule BackendWeb.GraphView do
id: node.domain,
label: node.domain,
size: size,
type: node.type
type: node.type,
statusesPerDay: node.statuses_per_day
},
position: %{
x: node.x,

View File

@ -42,7 +42,8 @@ defmodule BackendWeb.InstanceView do
peers: render_many(filtered_peers, InstanceView, "instance.json"),
lastUpdated: last_updated,
status: status,
type: instance.type
type: instance.type,
statusesPerDay: instance.statuses_per_day
}
end
end

View File

@ -0,0 +1,9 @@
defmodule Backend.Repo.Migrations.AddStatusesPerHour do
use Ecto.Migration
def change do
alter table(:instances) do
add :statuses_per_day, :float
end
end
end

View File

@ -4,6 +4,7 @@ import styled from "styled-components";
const FloatingCardRow = styled.div`
display: flex;
max-width: 250px;
`;
const FloatingCardElement = styled(Card)`
margin: 0 0 10px 10px;

View File

@ -1,12 +1,14 @@
import { Button, Classes, H5, H6, MenuItem } from "@blueprintjs/core";
import { Button, Classes, H5, MenuItem } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons";
import { ItemRenderer, Select } from "@blueprintjs/select";
import * as numeral from "numeral";
import React from "react";
import styled from "styled-components";
import { FloatingCard, InstanceType } from ".";
import { IColorSchemeType } from "../../types";
import { QUANTITATIVE_COLOR_SCHEME } from "../../constants";
import { IColorScheme } from "../../types";
const ColorSchemeSelect = Select.ofType<IColorSchemeType>();
const ColorSchemeSelect = Select.ofType<IColorScheme>();
const StyledLi = styled.li`
margin-top: 2px;
@ -14,16 +16,49 @@ const StyledLi = styled.li`
const StyledKeyContainer = styled.div`
margin-top: 10px;
`;
const ColorKeyContainer = styled.div`
display: flex;
flex-direction: row;
height: 100px;
`;
const ColorBarContainer = styled.div`
width: 10px;
display: flex;
flex-direction: column;
margin-right: 10px;
`;
interface IColorBarProps {
color: string;
}
const ColorBar = styled.div<IColorBarProps>`
width: 10px;
background-color: ${props => props.color};
flex: 1;
`;
const TextContainer = styled.div`
display: flex;
flex-direction: column;
justify-content: space-between;
`;
interface IGraphKeyProps {
current?: IColorSchemeType;
colorSchemes: IColorSchemeType[];
onItemSelect: (colorScheme?: IColorSchemeType) => void;
current?: IColorScheme;
colorSchemes: IColorScheme[];
ranges?: { [key: string]: [number, number] };
onItemSelect: (colorScheme?: IColorScheme) => void;
}
const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, onItemSelect }) => {
const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, ranges, onItemSelect }) => {
const unsetColorScheme = () => {
onItemSelect(undefined);
};
let key;
if (current) {
if (current.type === "qualitative") {
key = renderQualitativeKey(current.values);
} else if (current.type === "quantitative") {
key = renderQuantitativeKey(ranges![current.cytoscapeDataKey]);
}
}
return (
<FloatingCard>
<H5>Color coding</H5>
@ -42,27 +77,49 @@ const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, onItemSelec
/>
<Button icon={IconNames.SMALL_CROSS} minimal={true} onClick={unsetColorScheme} disabled={!current} />
</ColorSchemeSelect>
{current && (
<StyledKeyContainer>
<H6>Key</H6>
<ul className={Classes.LIST_UNSTYLED}>
{current.values.map(v => (
<StyledLi key={v}>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
</StyledKeyContainer>
<br />
{!!current && !!key && (
<>
{current.description && <span className={Classes.TEXT_MUTED}>{current.description}</span>}
<StyledKeyContainer>{key}</StyledKeyContainer>
</>
)}
</FloatingCard>
);
};
const renderItem: ItemRenderer<IColorSchemeType> = (colorScheme, { handleClick, modifiers }) => {
const renderItem: ItemRenderer<IColorScheme> = (colorScheme, { handleClick, modifiers }) => {
if (!modifiers.matchesPredicate) {
return null;
}
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
};
const renderQualitativeKey = (values: string[]) => (
<ul className={Classes.LIST_UNSTYLED}>
{values.map(v => (
<StyledLi key={v}>
<InstanceType type={v} />
</StyledLi>
))}
</ul>
);
const renderQuantitativeKey = (range: number[]) => {
const [min, max] = range;
return (
<ColorKeyContainer>
<ColorBarContainer>
{QUANTITATIVE_COLOR_SCHEME.map((color, idx) => (
<ColorBar color={color} />
))}
</ColorBarContainer>
<TextContainer>
<span className={Classes.TEXT_SMALL}>{numeral.default(min).format("0")}</span>
<span className={Classes.TEXT_SMALL}>{numeral.default(max).format("0")}</span>
</TextContainer>
</ColorKeyContainer>
);
};
export default GraphKey;

View File

@ -8,10 +8,12 @@ import {
DEFAULT_NODE_COLOR,
HOVERED_NODE_COLOR,
QUALITATIVE_COLOR_SCHEME,
QUANTITATIVE_COLOR_SCHEME,
SEARCH_RESULT_COLOR,
SELECTED_NODE_COLOR
} from "../../constants";
import { IColorSchemeType } from "../../types";
import { IColorScheme } from "../../types";
import { getBuckets } from "../../util";
const CytoscapeContainer = styled.div`
width: 100%;
@ -20,10 +22,11 @@ const CytoscapeContainer = styled.div`
`;
interface ICytoscapeProps {
colorScheme?: IColorSchemeType;
colorScheme?: IColorScheme;
currentNodeId: string | null;
elements: cytoscape.ElementsDefinition;
hoveringOver?: string;
ranges?: { [key: string]: [number, number] };
searchResultIds?: string[];
navigateToInstancePath?: (domain: string) => void;
navigateToRoot?: () => void;
@ -251,15 +254,33 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
let style = this.cy.style() as any;
if (!colorScheme) {
this.resetNodeColorScheme();
} else {
return;
} else if (colorScheme.type === "qualitative") {
colorScheme.values.forEach((v, idx) => {
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
"background-color": QUALITATIVE_COLOR_SCHEME[idx]
});
});
} else if (colorScheme.type === "quantitative") {
const dataKey = colorScheme.cytoscapeDataKey;
if (!this.props.ranges || !this.props.ranges[dataKey]) {
throw new Error("Expected a range but did not receive one!");
}
// Create buckets for the range and corresponding classes
const [minVal, maxVal] = this.props.ranges[dataKey];
const buckets = getBuckets(minVal, maxVal, QUANTITATIVE_COLOR_SCHEME.length, colorScheme.exponential);
this.setNodeSearchColorScheme(style);
QUANTITATIVE_COLOR_SCHEME.forEach((color, idx) => {
const min = buckets[idx];
// Make sure the max value is also included in a bucket!
const max = idx === QUANTITATIVE_COLOR_SCHEME.length - 1 ? maxVal + 1 : buckets[idx + 1];
const selector = `node[${dataKey} >= ${min}][${dataKey} < ${max}]`;
style = style.selector(selector).style({
"background-color": color
});
});
}
this.setNodeSearchColorScheme(style);
};
/**

View File

@ -1,6 +1,6 @@
import React from "react";
import styled from "styled-components";
import { IColorSchemeType } from "../../types";
import { IColorScheme } from "../../types";
import { GraphKey, GraphResetButton } from "../atoms";
const GraphToolsContainer = styled.div`
@ -12,21 +12,28 @@ const GraphToolsContainer = styled.div`
`;
interface IGraphToolsProps {
currentColorScheme?: IColorSchemeType;
colorSchemes: IColorSchemeType[];
onColorSchemeSelect: (colorScheme?: IColorSchemeType) => void;
currentColorScheme?: IColorScheme;
colorSchemes: IColorScheme[];
ranges?: { [key: string]: [number, number] };
onColorSchemeSelect: (colorScheme?: IColorScheme) => void;
onResetButtonClick: () => void;
}
const GraphTools: React.FC<IGraphToolsProps> = ({
currentColorScheme,
colorSchemes,
ranges,
onColorSchemeSelect,
onResetButtonClick
}) => {
return (
<GraphToolsContainer>
<GraphResetButton onClick={onResetButtonClick} />
<GraphKey current={currentColorScheme} colorSchemes={colorSchemes} onItemSelect={onColorSchemeSelect} />
<GraphKey
current={currentColorScheme}
colorSchemes={colorSchemes}
onItemSelect={onColorSchemeSelect}
ranges={ranges}
/>
</GraphToolsContainer>
);
};

View File

@ -6,8 +6,8 @@ import { push } from "connected-react-router";
import { Dispatch } from "redux";
import styled from "styled-components";
import { fetchGraph } from "../../redux/actions";
import { IAppState, IGraph } from "../../redux/types";
import { colorSchemes, IColorSchemeType } from "../../types";
import { IAppState, IGraphResponse } from "../../redux/types";
import { colorSchemes, IColorScheme } from "../../types";
import { domainMatchSelector } from "../../util";
import { Cytoscape, ErrorState, GraphTools } from "../molecules/";
@ -18,7 +18,7 @@ const GraphDiv = styled.div`
interface IGraphProps {
currentInstanceName: string | null;
fetchGraph: () => void;
graph?: IGraph;
graphResponse?: IGraphResponse;
graphLoadError: boolean;
hoveringOverResult?: string;
isLoadingGraph: boolean;
@ -26,7 +26,7 @@ interface IGraphProps {
navigate: (path: string) => void;
}
interface IGraphState {
colorScheme?: IColorSchemeType;
colorScheme?: IColorScheme;
}
class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
private cytoscapeComponent: React.RefObject<Cytoscape>;
@ -45,7 +45,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
let content;
if (this.props.isLoadingGraph) {
content = <NonIdealState icon={<Spinner />} title="Loading..." />;
} else if (this.props.graphLoadError || !this.props.graph) {
} else if (this.props.graphLoadError || !this.props.graphResponse) {
content = <ErrorState />;
} else {
content = (
@ -53,7 +53,8 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
<Cytoscape
colorScheme={this.state.colorScheme}
currentNodeId={this.props.currentInstanceName}
elements={this.props.graph}
elements={this.props.graphResponse.graph}
ranges={this.props.graphResponse.metadata.ranges}
hoveringOver={this.props.hoveringOverResult}
navigateToInstancePath={this.navigateToInstancePath}
navigateToRoot={this.navigateToRoot}
@ -65,6 +66,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
currentColorScheme={this.state.colorScheme}
colorSchemes={colorSchemes}
onColorSchemeSelect={this.setColorScheme}
ranges={this.props.graphResponse.metadata.ranges}
/>
</>
);
@ -85,7 +87,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
}
};
private setColorScheme = (colorScheme?: IColorSchemeType) => {
private setColorScheme = (colorScheme?: IColorScheme) => {
this.setState({ colorScheme });
};
@ -101,8 +103,8 @@ const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
currentInstanceName: match && match.params.domain,
graph: state.data.graph,
graphLoadError: state.data.error,
graphResponse: state.data.graphResponse,
hoveringOverResult: state.search.hoveringOverResult,
isLoadingGraph: state.data.isLoadingGraph,
searchResultDomains: state.search.results.map(r => r.name)

View File

@ -259,7 +259,16 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
if (!this.props.instanceDetails) {
throw new Error("Did not receive instance details as expected!");
}
const { version, userCount, statusCount, domainCount, lastUpdated, insularity, type } = this.props.instanceDetails;
const {
version,
userCount,
statusCount,
domainCount,
lastUpdated,
insularity,
type,
statusesPerDay
} = this.props.instanceDetails;
return (
<StyledHTMLTable small={true} striped={true}>
<tbody>
@ -298,6 +307,25 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
</td>
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
</tr>
<tr>
<td>
Statuses / day{" "}
<Tooltip
content={
<span>
The average number of statuses per day
<br />
over the last month.
</span>
}
position={Position.TOP}
className={Classes.DARK}
>
<Icon icon={IconNames.HELP} iconSize={Icon.SIZE_STANDARD} />
</Tooltip>
</td>
<td>{(statusesPerDay && numeral.default(statusesPerDay).format("0.0")) || "Unknown"}</td>
</tr>
<tr>
<td>Known peers</td>
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</td>
@ -430,7 +458,7 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
const mapStateToProps = (state: IAppState) => {
const match = domainMatchSelector(state);
return {
graph: state.data.graph,
graph: state.data.graphResponse && state.data.graphResponse.graph,
instanceDetails: state.currentInstance.currentInstanceDetails,
instanceLoadError: state.currentInstance.error,
instanceName: match && match.params.domain,

View File

@ -20,6 +20,20 @@ export const QUALITATIVE_COLOR_SCHEME = [
"#AD99FF"
];
// From https://blueprintjs.com/docs/#core/colors.sequential-color-schemes
export const QUANTITATIVE_COLOR_SCHEME = [
"#FFB7A5",
"#F5A793",
"#EB9882",
"#E18970",
"#D77A60",
"#CC6A4F",
"#C15B3F",
"#B64C2F",
"#AA3C1F",
"#9E2B0E"
];
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
export interface IInstanceDomainPath {
domain: string;

View File

@ -8,18 +8,18 @@ const initialDataState = {
error: false,
isLoadingGraph: false
};
const data = (state: IDataState = initialDataState, action: IAction) => {
const data = (state: IDataState = initialDataState, action: IAction): IDataState => {
switch (action.type) {
case ActionType.REQUEST_GRAPH:
return {
...state,
graph: undefined,
graphResponse: undefined,
isLoadingGraph: true
};
case ActionType.RECEIVE_GRAPH:
return {
...state,
graph: action.payload,
graphResponse: action.payload,
isLoadingGraph: false
};
case ActionType.GRAPH_LOAD_ERROR:

View File

@ -48,6 +48,7 @@ export interface IInstanceDetails {
lastUpdated?: string;
status: string;
type?: string;
statusesPerDay?: number;
}
interface IGraphNode {
@ -71,11 +72,20 @@ interface IGraphEdge {
};
}
interface IGraphMetadata {
ranges: { [key: string]: [number, number] };
}
export interface IGraph {
nodes: IGraphNode[];
edges: IGraphEdge[];
}
export interface IGraphResponse {
graph: IGraph;
metadata: IGraphMetadata;
}
export interface ISearchResponse {
results: ISearchResultInstance[];
next: string | null;
@ -91,7 +101,7 @@ export interface ICurrentInstanceState {
}
export interface IDataState {
graph?: IGraph;
graphResponse?: IGraphResponse;
isLoadingGraph: boolean;
error: boolean;
}

View File

@ -1,18 +1,37 @@
export interface IColorSchemeType {
interface IColorSchemeBase {
// The name of the coloring, e.g. "Instance type"
name: string;
// The name of the key in a cytoscape node's `data` field to color by.
// For example, use cytoscapeDataKey: "type" to color according to type.
cytoscapeDataKey: string;
description?: string;
type: "qualitative" | "quantitative";
}
interface IQualitativeColorScheme extends IColorSchemeBase {
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
values: string[];
type: "qualitative";
}
interface IQuantitativeColorScheme extends IColorSchemeBase {
type: "quantitative";
exponential: boolean;
}
export const typeColorScheme: IColorSchemeType = {
export type IColorScheme = IQualitativeColorScheme | IQuantitativeColorScheme;
export const typeColorScheme: IQualitativeColorScheme = {
cytoscapeDataKey: "type",
name: "Instance type",
type: "qualitative",
// We could also extract the values from the server response, but this would slow things down...
values: ["mastodon", "gab", "pleroma"]
};
export const activityColorScheme: IQuantitativeColorScheme = {
cytoscapeDataKey: "statusesPerDay",
description: "The average number of statuses posted per day.",
exponential: true,
name: "Activity",
type: "quantitative"
};
export const colorSchemes: IColorSchemeType[] = [typeColorScheme];
export const colorSchemes: IColorScheme[] = [typeColorScheme, activityColorScheme];

View File

@ -1,5 +1,6 @@
import { createMatchSelector } from "connected-react-router";
import fetch from "cross-fetch";
import { range } from "lodash";
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
import { IAppState } from "./redux/types";
@ -50,5 +51,20 @@ export const unsetAuthToken = () => {
export const getAuthToken = () => {
return sessionStorage.getItem("adminToken");
// TODO: check if it's expired, and if so a) delete it and b) return null
};
export const getBuckets = (min: number, max: number, steps: number, exponential: boolean) => {
if (exponential) {
const logSpace = range(steps).map(i => Math.E ** i);
// Scale the log space to the linear range
const logRange = logSpace[logSpace.length - 1] - logSpace[0];
const linearRange = max - min;
const scalingFactor = linearRange / logRange;
const translation = min - logSpace[0];
return logSpace.map(i => (i + translation) * scalingFactor);
} else {
// Linear
const bucketSize = Math.ceil((max - min) / steps);
return range(min, max, bucketSize);
}
};