Merge branch 'develop' of gitlab.com:taobojlen/fediverse.space into develop
This commit is contained in:
commit
b8264eb283
|
@ -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.
|
- Added ElasticSearch full-text search over instance domains and descriptions.
|
||||||
- Search results are now highlighted on the graph.
|
- Search results are now highlighted on the graph.
|
||||||
- When you hover a search result, it is 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
|
### Changed
|
||||||
|
|
||||||
|
|
|
@ -74,6 +74,8 @@ config :backend, Backend.Scheduler,
|
||||||
{"15 0 * * *", {Backend.Scheduler, :generate_edges, []}},
|
{"15 0 * * *", {Backend.Scheduler, :generate_edges, []}},
|
||||||
# 00.30 every night
|
# 00.30 every night
|
||||||
{"30 0 * * *", {Backend.Scheduler, :generate_insularity_scores, []}},
|
{"30 0 * * *", {Backend.Scheduler, :generate_insularity_scores, []}},
|
||||||
|
# 00.45 every night
|
||||||
|
{"45 0 * * *", {Backend.Scheduler, :generate_status_rate, []}},
|
||||||
# Every 3 hours
|
# Every 3 hours
|
||||||
{"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}}
|
{"0 */3 * * *", {Backend.Scheduler, :check_for_spam_instances, []}}
|
||||||
]
|
]
|
||||||
|
|
|
@ -37,7 +37,7 @@ defmodule Backend.Api do
|
||||||
i.user_count >= ^user_threshold and not i.opt_out
|
i.user_count >= ^user_threshold and not i.opt_out
|
||||||
)
|
)
|
||||||
|> maybe_filter_nodes_to_neighborhood(domain)
|
|> 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()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,7 @@ defmodule Backend.Instance do
|
||||||
field :version, :string
|
field :version, :string
|
||||||
field :insularity, :float
|
field :insularity, :float
|
||||||
field :type, :string
|
field :type, :string
|
||||||
|
field :statuses_per_day, :float
|
||||||
field :base_domain, :string
|
field :base_domain, :string
|
||||||
field :opt_in, :boolean
|
field :opt_in, :boolean
|
||||||
field :opt_out, :boolean
|
field :opt_out, :boolean
|
||||||
|
@ -39,6 +40,7 @@ defmodule Backend.Instance do
|
||||||
:insularity,
|
:insularity,
|
||||||
:updated_at,
|
:updated_at,
|
||||||
:type,
|
:type,
|
||||||
|
:statuses_per_day,
|
||||||
:base_domain,
|
:base_domain,
|
||||||
:opt_in,
|
:opt_in,
|
||||||
:opt_out
|
:opt_out
|
||||||
|
|
|
@ -76,6 +76,61 @@ defmodule Backend.Scheduler do
|
||||||
)
|
)
|
||||||
end
|
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 """
|
@doc """
|
||||||
This function aggregates statistics from the interactions in the database.
|
This function aggregates statistics from the interactions in the database.
|
||||||
It calculates the strength of edges between nodes. Self-edges are not generated.
|
It calculates the strength of edges between nodes. Self-edges are not generated.
|
||||||
|
|
|
@ -3,9 +3,24 @@ defmodule BackendWeb.GraphView do
|
||||||
alias BackendWeb.GraphView
|
alias BackendWeb.GraphView
|
||||||
|
|
||||||
def render("index.json", %{nodes: nodes, edges: edges}) do
|
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),
|
graph: %{
|
||||||
edges: render_many(edges, GraphView, "edge.json", as: :edge)
|
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
|
end
|
||||||
|
|
||||||
|
@ -22,7 +37,8 @@ defmodule BackendWeb.GraphView do
|
||||||
id: node.domain,
|
id: node.domain,
|
||||||
label: node.domain,
|
label: node.domain,
|
||||||
size: size,
|
size: size,
|
||||||
type: node.type
|
type: node.type,
|
||||||
|
statusesPerDay: node.statuses_per_day
|
||||||
},
|
},
|
||||||
position: %{
|
position: %{
|
||||||
x: node.x,
|
x: node.x,
|
||||||
|
|
|
@ -42,7 +42,8 @@ defmodule BackendWeb.InstanceView do
|
||||||
peers: render_many(filtered_peers, InstanceView, "instance.json"),
|
peers: render_many(filtered_peers, InstanceView, "instance.json"),
|
||||||
lastUpdated: last_updated,
|
lastUpdated: last_updated,
|
||||||
status: status,
|
status: status,
|
||||||
type: instance.type
|
type: instance.type,
|
||||||
|
statusesPerDay: instance.statuses_per_day
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -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
|
|
@ -4,6 +4,7 @@ import styled from "styled-components";
|
||||||
|
|
||||||
const FloatingCardRow = styled.div`
|
const FloatingCardRow = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
|
max-width: 250px;
|
||||||
`;
|
`;
|
||||||
const FloatingCardElement = styled(Card)`
|
const FloatingCardElement = styled(Card)`
|
||||||
margin: 0 0 10px 10px;
|
margin: 0 0 10px 10px;
|
||||||
|
|
|
@ -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 { IconNames } from "@blueprintjs/icons";
|
||||||
import { ItemRenderer, Select } from "@blueprintjs/select";
|
import { ItemRenderer, Select } from "@blueprintjs/select";
|
||||||
|
import * as numeral from "numeral";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { FloatingCard, InstanceType } from ".";
|
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`
|
const StyledLi = styled.li`
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
@ -14,16 +16,49 @@ const StyledLi = styled.li`
|
||||||
const StyledKeyContainer = styled.div`
|
const StyledKeyContainer = styled.div`
|
||||||
margin-top: 10px;
|
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 {
|
interface IGraphKeyProps {
|
||||||
current?: IColorSchemeType;
|
current?: IColorScheme;
|
||||||
colorSchemes: IColorSchemeType[];
|
colorSchemes: IColorScheme[];
|
||||||
onItemSelect: (colorScheme?: IColorSchemeType) => void;
|
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 = () => {
|
const unsetColorScheme = () => {
|
||||||
onItemSelect(undefined);
|
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 (
|
return (
|
||||||
<FloatingCard>
|
<FloatingCard>
|
||||||
<H5>Color coding</H5>
|
<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} />
|
<Button icon={IconNames.SMALL_CROSS} minimal={true} onClick={unsetColorScheme} disabled={!current} />
|
||||||
</ColorSchemeSelect>
|
</ColorSchemeSelect>
|
||||||
{current && (
|
<br />
|
||||||
<StyledKeyContainer>
|
{!!current && !!key && (
|
||||||
<H6>Key</H6>
|
<>
|
||||||
<ul className={Classes.LIST_UNSTYLED}>
|
{current.description && <span className={Classes.TEXT_MUTED}>{current.description}</span>}
|
||||||
{current.values.map(v => (
|
<StyledKeyContainer>{key}</StyledKeyContainer>
|
||||||
<StyledLi key={v}>
|
</>
|
||||||
<InstanceType type={v} />
|
|
||||||
</StyledLi>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</StyledKeyContainer>
|
|
||||||
)}
|
)}
|
||||||
</FloatingCard>
|
</FloatingCard>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderItem: ItemRenderer<IColorSchemeType> = (colorScheme, { handleClick, modifiers }) => {
|
const renderItem: ItemRenderer<IColorScheme> = (colorScheme, { handleClick, modifiers }) => {
|
||||||
if (!modifiers.matchesPredicate) {
|
if (!modifiers.matchesPredicate) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return <MenuItem active={modifiers.active} key={colorScheme.name} onClick={handleClick} text={colorScheme.name} />;
|
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;
|
export default GraphKey;
|
||||||
|
|
|
@ -8,10 +8,12 @@ import {
|
||||||
DEFAULT_NODE_COLOR,
|
DEFAULT_NODE_COLOR,
|
||||||
HOVERED_NODE_COLOR,
|
HOVERED_NODE_COLOR,
|
||||||
QUALITATIVE_COLOR_SCHEME,
|
QUALITATIVE_COLOR_SCHEME,
|
||||||
|
QUANTITATIVE_COLOR_SCHEME,
|
||||||
SEARCH_RESULT_COLOR,
|
SEARCH_RESULT_COLOR,
|
||||||
SELECTED_NODE_COLOR
|
SELECTED_NODE_COLOR
|
||||||
} from "../../constants";
|
} from "../../constants";
|
||||||
import { IColorSchemeType } from "../../types";
|
import { IColorScheme } from "../../types";
|
||||||
|
import { getBuckets } from "../../util";
|
||||||
|
|
||||||
const CytoscapeContainer = styled.div`
|
const CytoscapeContainer = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
@ -20,10 +22,11 @@ const CytoscapeContainer = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface ICytoscapeProps {
|
interface ICytoscapeProps {
|
||||||
colorScheme?: IColorSchemeType;
|
colorScheme?: IColorScheme;
|
||||||
currentNodeId: string | null;
|
currentNodeId: string | null;
|
||||||
elements: cytoscape.ElementsDefinition;
|
elements: cytoscape.ElementsDefinition;
|
||||||
hoveringOver?: string;
|
hoveringOver?: string;
|
||||||
|
ranges?: { [key: string]: [number, number] };
|
||||||
searchResultIds?: string[];
|
searchResultIds?: string[];
|
||||||
navigateToInstancePath?: (domain: string) => void;
|
navigateToInstancePath?: (domain: string) => void;
|
||||||
navigateToRoot?: () => void;
|
navigateToRoot?: () => void;
|
||||||
|
@ -251,15 +254,33 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
let style = this.cy.style() as any;
|
let style = this.cy.style() as any;
|
||||||
if (!colorScheme) {
|
if (!colorScheme) {
|
||||||
this.resetNodeColorScheme();
|
this.resetNodeColorScheme();
|
||||||
} else {
|
return;
|
||||||
|
} else if (colorScheme.type === "qualitative") {
|
||||||
colorScheme.values.forEach((v, idx) => {
|
colorScheme.values.forEach((v, idx) => {
|
||||||
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
|
style = style.selector(`node[${colorScheme.cytoscapeDataKey} = '${v}']`).style({
|
||||||
"background-color": QUALITATIVE_COLOR_SCHEME[idx]
|
"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);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { IColorSchemeType } from "../../types";
|
import { IColorScheme } from "../../types";
|
||||||
import { GraphKey, GraphResetButton } from "../atoms";
|
import { GraphKey, GraphResetButton } from "../atoms";
|
||||||
|
|
||||||
const GraphToolsContainer = styled.div`
|
const GraphToolsContainer = styled.div`
|
||||||
|
@ -12,21 +12,28 @@ const GraphToolsContainer = styled.div`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface IGraphToolsProps {
|
interface IGraphToolsProps {
|
||||||
currentColorScheme?: IColorSchemeType;
|
currentColorScheme?: IColorScheme;
|
||||||
colorSchemes: IColorSchemeType[];
|
colorSchemes: IColorScheme[];
|
||||||
onColorSchemeSelect: (colorScheme?: IColorSchemeType) => void;
|
ranges?: { [key: string]: [number, number] };
|
||||||
|
onColorSchemeSelect: (colorScheme?: IColorScheme) => void;
|
||||||
onResetButtonClick: () => void;
|
onResetButtonClick: () => void;
|
||||||
}
|
}
|
||||||
const GraphTools: React.FC<IGraphToolsProps> = ({
|
const GraphTools: React.FC<IGraphToolsProps> = ({
|
||||||
currentColorScheme,
|
currentColorScheme,
|
||||||
colorSchemes,
|
colorSchemes,
|
||||||
|
ranges,
|
||||||
onColorSchemeSelect,
|
onColorSchemeSelect,
|
||||||
onResetButtonClick
|
onResetButtonClick
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<GraphToolsContainer>
|
<GraphToolsContainer>
|
||||||
<GraphResetButton onClick={onResetButtonClick} />
|
<GraphResetButton onClick={onResetButtonClick} />
|
||||||
<GraphKey current={currentColorScheme} colorSchemes={colorSchemes} onItemSelect={onColorSchemeSelect} />
|
<GraphKey
|
||||||
|
current={currentColorScheme}
|
||||||
|
colorSchemes={colorSchemes}
|
||||||
|
onItemSelect={onColorSchemeSelect}
|
||||||
|
ranges={ranges}
|
||||||
|
/>
|
||||||
</GraphToolsContainer>
|
</GraphToolsContainer>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -6,8 +6,8 @@ import { push } from "connected-react-router";
|
||||||
import { Dispatch } from "redux";
|
import { Dispatch } from "redux";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { fetchGraph } from "../../redux/actions";
|
import { fetchGraph } from "../../redux/actions";
|
||||||
import { IAppState, IGraph } from "../../redux/types";
|
import { IAppState, IGraphResponse } from "../../redux/types";
|
||||||
import { colorSchemes, IColorSchemeType } from "../../types";
|
import { colorSchemes, IColorScheme } from "../../types";
|
||||||
import { domainMatchSelector } from "../../util";
|
import { domainMatchSelector } from "../../util";
|
||||||
import { Cytoscape, ErrorState, GraphTools } from "../molecules/";
|
import { Cytoscape, ErrorState, GraphTools } from "../molecules/";
|
||||||
|
|
||||||
|
@ -18,7 +18,7 @@ const GraphDiv = styled.div`
|
||||||
interface IGraphProps {
|
interface IGraphProps {
|
||||||
currentInstanceName: string | null;
|
currentInstanceName: string | null;
|
||||||
fetchGraph: () => void;
|
fetchGraph: () => void;
|
||||||
graph?: IGraph;
|
graphResponse?: IGraphResponse;
|
||||||
graphLoadError: boolean;
|
graphLoadError: boolean;
|
||||||
hoveringOverResult?: string;
|
hoveringOverResult?: string;
|
||||||
isLoadingGraph: boolean;
|
isLoadingGraph: boolean;
|
||||||
|
@ -26,7 +26,7 @@ interface IGraphProps {
|
||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}
|
}
|
||||||
interface IGraphState {
|
interface IGraphState {
|
||||||
colorScheme?: IColorSchemeType;
|
colorScheme?: IColorScheme;
|
||||||
}
|
}
|
||||||
class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
||||||
private cytoscapeComponent: React.RefObject<Cytoscape>;
|
private cytoscapeComponent: React.RefObject<Cytoscape>;
|
||||||
|
@ -45,7 +45,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
||||||
let content;
|
let content;
|
||||||
if (this.props.isLoadingGraph) {
|
if (this.props.isLoadingGraph) {
|
||||||
content = <NonIdealState icon={<Spinner />} title="Loading..." />;
|
content = <NonIdealState icon={<Spinner />} title="Loading..." />;
|
||||||
} else if (this.props.graphLoadError || !this.props.graph) {
|
} else if (this.props.graphLoadError || !this.props.graphResponse) {
|
||||||
content = <ErrorState />;
|
content = <ErrorState />;
|
||||||
} else {
|
} else {
|
||||||
content = (
|
content = (
|
||||||
|
@ -53,7 +53,8 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
||||||
<Cytoscape
|
<Cytoscape
|
||||||
colorScheme={this.state.colorScheme}
|
colorScheme={this.state.colorScheme}
|
||||||
currentNodeId={this.props.currentInstanceName}
|
currentNodeId={this.props.currentInstanceName}
|
||||||
elements={this.props.graph}
|
elements={this.props.graphResponse.graph}
|
||||||
|
ranges={this.props.graphResponse.metadata.ranges}
|
||||||
hoveringOver={this.props.hoveringOverResult}
|
hoveringOver={this.props.hoveringOverResult}
|
||||||
navigateToInstancePath={this.navigateToInstancePath}
|
navigateToInstancePath={this.navigateToInstancePath}
|
||||||
navigateToRoot={this.navigateToRoot}
|
navigateToRoot={this.navigateToRoot}
|
||||||
|
@ -65,6 +66,7 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
||||||
currentColorScheme={this.state.colorScheme}
|
currentColorScheme={this.state.colorScheme}
|
||||||
colorSchemes={colorSchemes}
|
colorSchemes={colorSchemes}
|
||||||
onColorSchemeSelect={this.setColorScheme}
|
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 });
|
this.setState({ colorScheme });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -101,8 +103,8 @@ const mapStateToProps = (state: IAppState) => {
|
||||||
const match = domainMatchSelector(state);
|
const match = domainMatchSelector(state);
|
||||||
return {
|
return {
|
||||||
currentInstanceName: match && match.params.domain,
|
currentInstanceName: match && match.params.domain,
|
||||||
graph: state.data.graph,
|
|
||||||
graphLoadError: state.data.error,
|
graphLoadError: state.data.error,
|
||||||
|
graphResponse: state.data.graphResponse,
|
||||||
hoveringOverResult: state.search.hoveringOverResult,
|
hoveringOverResult: state.search.hoveringOverResult,
|
||||||
isLoadingGraph: state.data.isLoadingGraph,
|
isLoadingGraph: state.data.isLoadingGraph,
|
||||||
searchResultDomains: state.search.results.map(r => r.name)
|
searchResultDomains: state.search.results.map(r => r.name)
|
||||||
|
|
|
@ -259,7 +259,16 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
if (!this.props.instanceDetails) {
|
if (!this.props.instanceDetails) {
|
||||||
throw new Error("Did not receive instance details as expected!");
|
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 (
|
return (
|
||||||
<StyledHTMLTable small={true} striped={true}>
|
<StyledHTMLTable small={true} striped={true}>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
@ -298,6 +307,25 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
</td>
|
</td>
|
||||||
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
|
<td>{(insularity && numeral.default(insularity).format("0.0%")) || "Unknown"}</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>Known peers</td>
|
<td>Known peers</td>
|
||||||
<td>{(domainCount && numeral.default(domainCount).format("0,0")) || "Unknown"}</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 mapStateToProps = (state: IAppState) => {
|
||||||
const match = domainMatchSelector(state);
|
const match = domainMatchSelector(state);
|
||||||
return {
|
return {
|
||||||
graph: state.data.graph,
|
graph: state.data.graphResponse && state.data.graphResponse.graph,
|
||||||
instanceDetails: state.currentInstance.currentInstanceDetails,
|
instanceDetails: state.currentInstance.currentInstanceDetails,
|
||||||
instanceLoadError: state.currentInstance.error,
|
instanceLoadError: state.currentInstance.error,
|
||||||
instanceName: match && match.params.domain,
|
instanceName: match && match.params.domain,
|
||||||
|
|
|
@ -20,6 +20,20 @@ export const QUALITATIVE_COLOR_SCHEME = [
|
||||||
"#AD99FF"
|
"#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 const INSTANCE_DOMAIN_PATH = "/instance/:domain";
|
||||||
export interface IInstanceDomainPath {
|
export interface IInstanceDomainPath {
|
||||||
domain: string;
|
domain: string;
|
||||||
|
|
|
@ -8,18 +8,18 @@ const initialDataState = {
|
||||||
error: false,
|
error: false,
|
||||||
isLoadingGraph: false
|
isLoadingGraph: false
|
||||||
};
|
};
|
||||||
const data = (state: IDataState = initialDataState, action: IAction) => {
|
const data = (state: IDataState = initialDataState, action: IAction): IDataState => {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case ActionType.REQUEST_GRAPH:
|
case ActionType.REQUEST_GRAPH:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
graph: undefined,
|
graphResponse: undefined,
|
||||||
isLoadingGraph: true
|
isLoadingGraph: true
|
||||||
};
|
};
|
||||||
case ActionType.RECEIVE_GRAPH:
|
case ActionType.RECEIVE_GRAPH:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
graph: action.payload,
|
graphResponse: action.payload,
|
||||||
isLoadingGraph: false
|
isLoadingGraph: false
|
||||||
};
|
};
|
||||||
case ActionType.GRAPH_LOAD_ERROR:
|
case ActionType.GRAPH_LOAD_ERROR:
|
||||||
|
|
|
@ -48,6 +48,7 @@ export interface IInstanceDetails {
|
||||||
lastUpdated?: string;
|
lastUpdated?: string;
|
||||||
status: string;
|
status: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
statusesPerDay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IGraphNode {
|
interface IGraphNode {
|
||||||
|
@ -71,11 +72,20 @@ interface IGraphEdge {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface IGraphMetadata {
|
||||||
|
ranges: { [key: string]: [number, number] };
|
||||||
|
}
|
||||||
|
|
||||||
export interface IGraph {
|
export interface IGraph {
|
||||||
nodes: IGraphNode[];
|
nodes: IGraphNode[];
|
||||||
edges: IGraphEdge[];
|
edges: IGraphEdge[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IGraphResponse {
|
||||||
|
graph: IGraph;
|
||||||
|
metadata: IGraphMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ISearchResponse {
|
export interface ISearchResponse {
|
||||||
results: ISearchResultInstance[];
|
results: ISearchResultInstance[];
|
||||||
next: string | null;
|
next: string | null;
|
||||||
|
@ -91,7 +101,7 @@ export interface ICurrentInstanceState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDataState {
|
export interface IDataState {
|
||||||
graph?: IGraph;
|
graphResponse?: IGraphResponse;
|
||||||
isLoadingGraph: boolean;
|
isLoadingGraph: boolean;
|
||||||
error: boolean;
|
error: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,37 @@
|
||||||
export interface IColorSchemeType {
|
interface IColorSchemeBase {
|
||||||
// The name of the coloring, e.g. "Instance type"
|
// The name of the coloring, e.g. "Instance type"
|
||||||
name: string;
|
name: string;
|
||||||
// The name of the key in a cytoscape node's `data` field to color by.
|
// 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.
|
// For example, use cytoscapeDataKey: "type" to color according to type.
|
||||||
cytoscapeDataKey: string;
|
cytoscapeDataKey: string;
|
||||||
|
description?: string;
|
||||||
|
type: "qualitative" | "quantitative";
|
||||||
|
}
|
||||||
|
interface IQualitativeColorScheme extends IColorSchemeBase {
|
||||||
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
|
// The values the color scheme is used for. E.g. ["mastodon", "pleroma", "misskey"].
|
||||||
values: string[];
|
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",
|
cytoscapeDataKey: "type",
|
||||||
name: "Instance type",
|
name: "Instance type",
|
||||||
|
type: "qualitative",
|
||||||
// We could also extract the values from the server response, but this would slow things down...
|
// We could also extract the values from the server response, but this would slow things down...
|
||||||
values: ["mastodon", "gab", "pleroma"]
|
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];
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { createMatchSelector } from "connected-react-router";
|
import { createMatchSelector } from "connected-react-router";
|
||||||
import fetch from "cross-fetch";
|
import fetch from "cross-fetch";
|
||||||
|
import { range } from "lodash";
|
||||||
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
|
import { DESKTOP_WIDTH_THRESHOLD, IInstanceDomainPath, INSTANCE_DOMAIN_PATH } from "./constants";
|
||||||
import { IAppState } from "./redux/types";
|
import { IAppState } from "./redux/types";
|
||||||
|
|
||||||
|
@ -50,5 +51,20 @@ export const unsetAuthToken = () => {
|
||||||
|
|
||||||
export const getAuthToken = () => {
|
export const getAuthToken = () => {
|
||||||
return sessionStorage.getItem("adminToken");
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
Loading…
Reference in a new issue