add full-text search
This commit is contained in:
parent
5e9b498db0
commit
5ca8de5dbe
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
|
@ -1,3 +1,4 @@
|
||||||
{
|
{
|
||||||
"elixirLS.projectDir": "backend/"
|
"elixirLS.projectDir": "backend/",
|
||||||
|
"elixirLS.fetchDeps": false
|
||||||
}
|
}
|
|
@ -10,10 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Instance administrators can now log in to opt in or out of crawling.
|
- Instance administrators can now log in to opt in or out of crawling.
|
||||||
|
- 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.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Instances are now crawled hourly instead of every 30 minutes.
|
- Instances are now crawled hourly instead of every 30 minutes.
|
||||||
|
- The colors for color coding have been made brighter (more visible against the dark background.
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
web: /app/bin/backend start
|
web: /app/bin/backend start
|
||||||
release: /app/bin/backend eval "Backend.Release.migrate"
|
release: /app/bin/backend eval "Backend.Release.run_all"
|
|
@ -19,6 +19,20 @@ config :backend, BackendWeb.Endpoint,
|
||||||
|
|
||||||
config :backend, Backend.Repo, queue_target: 5000
|
config :backend, Backend.Repo, queue_target: 5000
|
||||||
|
|
||||||
|
config :backend, Backend.Elasticsearch.Cluster,
|
||||||
|
url: "http://localhost:9200",
|
||||||
|
api: Elasticsearch.API.HTTP,
|
||||||
|
json_library: Jason,
|
||||||
|
indexes: %{
|
||||||
|
instances: %{
|
||||||
|
settings: "priv/elasticsearch/instances.json",
|
||||||
|
store: Backend.Elasticsearch.Store,
|
||||||
|
sources: [Backend.Instance],
|
||||||
|
bulk_page_size: 1000,
|
||||||
|
bulk_wait_interval: 1_000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Configures Elixir's Logger
|
# Configures Elixir's Logger
|
||||||
config :logger, :console,
|
config :logger, :console,
|
||||||
format: "$time $metadata[$level] $message\n",
|
format: "$time $metadata[$level] $message\n",
|
||||||
|
|
|
@ -14,7 +14,8 @@ config :backend, Backend.Repo,
|
||||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
|
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
|
||||||
ssl: ssl
|
ssl: ssl
|
||||||
|
|
||||||
# show_sensitive_data_on_connection_error: true
|
config :backend, Backend.Elasticsearch.Cluster,
|
||||||
|
url: System.get_env("ELASTICSEARCH_URL") || "http://localhost:9200"
|
||||||
|
|
||||||
port = String.to_integer(System.get_env("PORT") || "4000")
|
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||||
|
|
||||||
|
|
|
@ -101,15 +101,67 @@ defmodule Backend.Api do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def search_instances(query, cursor_after \\ nil) do
|
def search_instances(query, from \\ 0) do
|
||||||
ilike_query = "%#{query}%"
|
page_size = 50
|
||||||
|
|
||||||
%{entries: instances, metadata: metadata} =
|
search_response =
|
||||||
Instance
|
Elasticsearch.post(Backend.Elasticsearch.Cluster, "/instances/_search", %{
|
||||||
|> where([i], ilike(i.domain, ^ilike_query) and not i.opt_out)
|
"sort" => "_score",
|
||||||
|> order_by(asc: :id)
|
"from" => from,
|
||||||
|> Repo.paginate(after: cursor_after, cursor_fields: [:id], limit: 50)
|
"size" => page_size,
|
||||||
|
"query" => %{
|
||||||
|
"bool" => %{
|
||||||
|
"should" => [
|
||||||
|
%{
|
||||||
|
"multi_match" => %{
|
||||||
|
"query" => query,
|
||||||
|
"fields" => [
|
||||||
|
"description.english"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"wildcard" => %{
|
||||||
|
"domain.keyword" => %{
|
||||||
|
"value" => query,
|
||||||
|
"boost" => 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"wildcard" => %{
|
||||||
|
"domain.keyword" => %{
|
||||||
|
"value" => "*#{query}*",
|
||||||
|
"boost" => 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"match" => %{
|
||||||
|
"domain.ngram^0.5" => query
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
%{instances: instances, next: metadata.after}
|
with {:ok, result} <- search_response do
|
||||||
|
hits =
|
||||||
|
get_in(result, ["hits", "hits"])
|
||||||
|
|> Enum.map(fn h -> h |> Map.get("_source") |> convert_keys_to_atoms() end)
|
||||||
|
|
||||||
|
next =
|
||||||
|
if length(hits) < page_size do
|
||||||
|
nil
|
||||||
|
else
|
||||||
|
from + page_size
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
hits: hits,
|
||||||
|
next: next
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -21,7 +21,8 @@ defmodule Backend.Application do
|
||||||
Honeydew.start_queue(:crawl_queue, failure_mode: Honeydew.FailureMode.Abandon)
|
Honeydew.start_queue(:crawl_queue, failure_mode: Honeydew.FailureMode.Abandon)
|
||||||
Honeydew.start_workers(:crawl_queue, Backend.Crawler, num: crawl_worker_count)
|
Honeydew.start_workers(:crawl_queue, Backend.Crawler, num: crawl_worker_count)
|
||||||
end},
|
end},
|
||||||
Backend.Scheduler
|
Backend.Scheduler,
|
||||||
|
Backend.Elasticsearch.Cluster
|
||||||
]
|
]
|
||||||
|
|
||||||
children =
|
children =
|
||||||
|
|
|
@ -32,7 +32,6 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl ApiCrawler
|
@impl ApiCrawler
|
||||||
# sobelow_skip ["DOS.StringToAtom"]
|
|
||||||
def crawl(domain) do
|
def crawl(domain) do
|
||||||
instance = Jason.decode!(get!("https://#{domain}/api/v1/instance").body)
|
instance = Jason.decode!(get!("https://#{domain}/api/v1/instance").body)
|
||||||
|
|
||||||
|
@ -48,7 +47,7 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
else
|
else
|
||||||
Map.merge(
|
Map.merge(
|
||||||
Map.take(instance["stats"], ["user_count"])
|
Map.take(instance["stats"], ["user_count"])
|
||||||
|> Map.new(fn {k, v} -> {String.to_atom(k), v} end),
|
|> convert_keys_to_atoms(),
|
||||||
%{
|
%{
|
||||||
peers: [],
|
peers: [],
|
||||||
interactions: %{},
|
interactions: %{},
|
||||||
|
@ -63,7 +62,6 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec crawl_large_instance(String.t(), any()) :: ApiCrawler.t()
|
@spec crawl_large_instance(String.t(), any()) :: ApiCrawler.t()
|
||||||
# sobelow_skip ["DOS.StringToAtom"]
|
|
||||||
defp crawl_large_instance(domain, instance) do
|
defp crawl_large_instance(domain, instance) do
|
||||||
# servers may not publish peers
|
# servers may not publish peers
|
||||||
peers =
|
peers =
|
||||||
|
@ -94,7 +92,7 @@ defmodule Backend.Crawler.Crawlers.Mastodon do
|
||||||
Map.take(instance, ["version", "description"]),
|
Map.take(instance, ["version", "description"]),
|
||||||
Map.take(instance["stats"], ["user_count", "status_count"])
|
Map.take(instance["stats"], ["user_count", "status_count"])
|
||||||
)
|
)
|
||||||
|> Map.new(fn {k, v} -> {String.to_atom(k), v} end),
|
|> convert_keys_to_atoms(),
|
||||||
%{
|
%{
|
||||||
peers: peers,
|
peers: peers,
|
||||||
interactions: interactions,
|
interactions: interactions,
|
||||||
|
|
3
backend/lib/backend/elasticsearch/cluster.ex
Normal file
3
backend/lib/backend/elasticsearch/cluster.ex
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
defmodule Backend.Elasticsearch.Cluster do
|
||||||
|
use Elasticsearch.Cluster, otp_app: :backend
|
||||||
|
end
|
16
backend/lib/backend/elasticsearch/store.ex
Normal file
16
backend/lib/backend/elasticsearch/store.ex
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
defmodule Backend.Elasticsearch.Store do
|
||||||
|
@behaviour Elasticsearch.Store
|
||||||
|
|
||||||
|
alias Backend.Repo
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def stream(schema) do
|
||||||
|
Repo.stream(schema)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def transaction(fun) do
|
||||||
|
{:ok, result} = Repo.transaction(fun, timeout: :infinity)
|
||||||
|
result
|
||||||
|
end
|
||||||
|
end
|
|
@ -46,4 +46,19 @@ defmodule Backend.Instance do
|
||||||
|> validate_required([:domain])
|
|> validate_required([:domain])
|
||||||
|> put_assoc(:peers, attrs.peers)
|
|> put_assoc(:peers, attrs.peers)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defimpl Elasticsearch.Document, for: Backend.Instance do
|
||||||
|
def id(instance), do: instance.id
|
||||||
|
def routing(_), do: false
|
||||||
|
|
||||||
|
def encode(instance) do
|
||||||
|
# Make sure this corresponds with priv/elasticseach/instances.json
|
||||||
|
%{
|
||||||
|
domain: instance.domain,
|
||||||
|
description: instance.description,
|
||||||
|
type: instance.type,
|
||||||
|
user_count: instance.user_count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,12 +1,24 @@
|
||||||
defmodule Backend.Release do
|
defmodule Backend.Release do
|
||||||
@app :backend
|
@app :backend
|
||||||
|
|
||||||
|
alias Elasticsearch.Index
|
||||||
|
alias Backend.Elasticsearch.Cluster
|
||||||
|
|
||||||
|
def run_all do
|
||||||
|
migrate()
|
||||||
|
index()
|
||||||
|
end
|
||||||
|
|
||||||
def migrate do
|
def migrate do
|
||||||
for repo <- repos() do
|
for repo <- repos() do
|
||||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def index do
|
||||||
|
Index.hot_swap(Cluster, "instances")
|
||||||
|
end
|
||||||
|
|
||||||
def rollback(repo, version) do
|
def rollback(repo, version) do
|
||||||
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
|
||||||
end
|
end
|
||||||
|
|
|
@ -157,4 +157,14 @@ defmodule Backend.Util do
|
||||||
"#{String.downcase(username)}@#{clean_domain(domain)}"
|
"#{String.downcase(username)}@#{clean_domain(domain)}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Converts a map with string keys to a map with atom keys.
|
||||||
|
Be very careful with this -- only use it on maps where you know the keys! Never run it if the keys can be supplied
|
||||||
|
by the user.
|
||||||
|
"""
|
||||||
|
# sobelow_skip ["DOS.StringToAtom"]
|
||||||
|
def convert_keys_to_atoms(map) do
|
||||||
|
map |> Map.new(fn {k, v} -> {String.to_atom(k), v} end)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,8 +6,8 @@ defmodule BackendWeb.SearchController do
|
||||||
|
|
||||||
def index(conn, params) do
|
def index(conn, params) do
|
||||||
query = Map.get(params, "query")
|
query = Map.get(params, "query")
|
||||||
cursor_after = Map.get(params, "after", nil)
|
from = Map.get(params, "after", "0") |> String.to_integer()
|
||||||
%{instances: instances, next: next} = Api.search_instances(query, cursor_after)
|
%{hits: hits, next: next} = Api.search_instances(query, from)
|
||||||
render(conn, "index.json", instances: instances, next: next)
|
render(conn, "index.json", hits: hits, next: next)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
defmodule BackendWeb.AdminView do
|
defmodule BackendWeb.AdminView do
|
||||||
use BackendWeb, :view
|
use BackendWeb, :view
|
||||||
import Backend.Util
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
def render("show.json", %{instance: instance}) do
|
def render("show.json", %{instance: instance}) do
|
||||||
Logger.info(inspect(instance))
|
|
||||||
|
|
||||||
%{
|
%{
|
||||||
domain: domain,
|
domain: domain,
|
||||||
opt_in: opt_in,
|
opt_in: opt_in,
|
||||||
|
|
|
@ -20,7 +20,7 @@ defmodule BackendWeb.InstanceView do
|
||||||
end
|
end
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
instance.user_count < user_threshold ->
|
instance.user_count < user_threshold and not instance.opt_in ->
|
||||||
%{
|
%{
|
||||||
name: instance.domain,
|
name: instance.domain,
|
||||||
status: "personal instance"
|
status: "personal instance"
|
||||||
|
|
|
@ -3,28 +3,28 @@ defmodule BackendWeb.SearchView do
|
||||||
alias BackendWeb.SearchView
|
alias BackendWeb.SearchView
|
||||||
import Backend.Util
|
import Backend.Util
|
||||||
|
|
||||||
def render("index.json", %{instances: instances, next: next}) do
|
def render("index.json", %{hits: hits, next: next}) do
|
||||||
%{
|
%{
|
||||||
results: render_many(instances, SearchView, "instance.json", as: :instance),
|
results: render_many(hits, SearchView, "instance.json", as: :hit),
|
||||||
next: next
|
next: next
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def render("instance.json", %{instance: instance}) do
|
def render("instance.json", %{hit: hit}) do
|
||||||
threshold = get_config(:personal_instance_threshold)
|
threshold = get_config(:personal_instance_threshold)
|
||||||
|
|
||||||
description =
|
description =
|
||||||
if instance.user_count != nil and instance.user_count < threshold do
|
if hit.user_count != nil and hit.user_count < threshold do
|
||||||
nil
|
nil
|
||||||
else
|
else
|
||||||
instance.description
|
hit.description
|
||||||
end
|
end
|
||||||
|
|
||||||
%{
|
%{
|
||||||
name: instance.domain,
|
name: hit.domain,
|
||||||
description: description,
|
description: description,
|
||||||
userCount: instance.user_count,
|
userCount: hit.user_count,
|
||||||
type: instance.type
|
type: hit.type
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -52,7 +52,8 @@ defmodule Backend.MixProject do
|
||||||
{:public_suffix, "~> 0.6.0"},
|
{:public_suffix, "~> 0.6.0"},
|
||||||
{:idna, "~> 5.1.2", override: true},
|
{:idna, "~> 5.1.2", override: true},
|
||||||
{:swoosh, "~> 0.23.3"},
|
{:swoosh, "~> 0.23.3"},
|
||||||
{:ex_twilio, "~> 0.7.0"}
|
{:ex_twilio, "~> 0.7.0"},
|
||||||
|
{:elasticsearch, "~> 1.0"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@
|
||||||
"distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"},
|
"distillery": {:hex, :distillery, "2.1.1", "f9332afc2eec8a1a2b86f22429e068ef35f84a93ea1718265e740d90dd367814", [:mix], [{:artificery, "~> 0.2", [hex: :artificery, repo: "hexpm", optional: false]}], "hexpm"},
|
||||||
"ecto": {:hex, :ecto, "3.1.7", "fa21d06ef56cdc2fdaa62574e8c3ba34a2751d44ea34c30bc65f0728421043e5", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
"ecto": {:hex, :ecto, "3.1.7", "fa21d06ef56cdc2fdaa62574e8c3ba34a2751d44ea34c30bc65f0728421043e5", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
|
||||||
"ecto_sql": {:hex, :ecto_sql, "3.1.6", "1e80e30d16138a729c717f73dcb938590bcdb3a4502f3012414d0cbb261045d8", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0 or ~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
|
"ecto_sql": {:hex, :ecto_sql, "3.1.6", "1e80e30d16138a729c717f73dcb938590bcdb3a4502f3012414d0cbb261045d8", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.1.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:myxql, "~> 0.2.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0 or ~> 0.15.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
|
||||||
|
"elasticsearch": {:hex, :elasticsearch, "1.0.0", "626d3fb8e7554d9c93eb18817ae2a3d22c2a4191cc903c4644b1334469b15374", [:mix], [{:httpoison, ">= 0.0.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: true]}, {:sigaws, "~> 0.7", [hex: :sigaws, repo: "hexpm", optional: true]}, {:vex, "~> 0.6.0", [hex: :vex, repo: "hexpm", optional: false]}], "hexpm"},
|
||||||
"ex_twilio": {:hex, :ex_twilio, "0.7.0", "d7ce624ef4661311ae28c3e3aa060ecb66a9f4843184d7400c29072f7d3f5a4a", [:mix], [{:httpoison, ">= 0.9.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:inflex, "~> 1.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.0", [hex: :joken, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
|
"ex_twilio": {:hex, :ex_twilio, "0.7.0", "d7ce624ef4661311ae28c3e3aa060ecb66a9f4843184d7400c29072f7d3f5a4a", [:mix], [{:httpoison, ">= 0.9.0", [hex: :httpoison, repo: "hexpm", optional: false]}, {:inflex, "~> 1.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.0", [hex: :joken, repo: "hexpm", optional: false]}, {:poison, "~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
|
||||||
"gen_stage": {:hex, :gen_stage, "0.14.2", "6a2a578a510c5bfca8a45e6b27552f613b41cf584b58210f017088d3d17d0b14", [:mix], [], "hexpm"},
|
"gen_stage": {:hex, :gen_stage, "0.14.2", "6a2a578a510c5bfca8a45e6b27552f613b41cf584b58210f017088d3d17d0b14", [:mix], [], "hexpm"},
|
||||||
"gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"},
|
"gen_state_machine": {:hex, :gen_state_machine, "2.0.5", "9ac15ec6e66acac994cc442dcc2c6f9796cf380ec4b08267223014be1c728a95", [:mix], [], "hexpm"},
|
||||||
|
@ -51,4 +52,5 @@
|
||||||
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
|
"timex": {:hex, :timex, "3.6.1", "efdf56d0e67a6b956cc57774353b0329c8ab7726766a11547e529357ffdc1d56", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.10", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 0.1.8 or ~> 0.5 or ~> 1.0.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm"},
|
||||||
"tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
|
"tzdata": {:hex, :tzdata, "1.0.1", "f6027a331af7d837471248e62733c6ebee86a72e57c613aa071ebb1f750fc71a", [:mix], [{:hackney, "~> 1.0", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm"},
|
||||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"},
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [:rebar3], [], "hexpm"},
|
||||||
|
"vex": {:hex, :vex, "0.6.0", "4e79b396b2ec18cd909eed0450b19108d9631842598d46552dc05031100b7a56", [:mix], [], "hexpm"},
|
||||||
}
|
}
|
||||||
|
|
53
backend/priv/elasticsearch/instances.json
Normal file
53
backend/priv/elasticsearch/instances.json
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"settings": {
|
||||||
|
"number_of_shards": 1,
|
||||||
|
"number_of_replicas": 0,
|
||||||
|
"analysis": {
|
||||||
|
"analyzer": {
|
||||||
|
"ngramAnalyzer": {
|
||||||
|
"tokenizer": "ngramTokenizer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tokenizer": {
|
||||||
|
"ngramTokenizer": {
|
||||||
|
"type": "ngram",
|
||||||
|
"min_gram": 5,
|
||||||
|
"max_gram": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mappings": {
|
||||||
|
"_doc": {
|
||||||
|
"properties": {
|
||||||
|
"domain": {
|
||||||
|
"type": "text",
|
||||||
|
"fields": {
|
||||||
|
"ngram": {
|
||||||
|
"type": "text",
|
||||||
|
"analyzer": "ngramAnalyzer"
|
||||||
|
},
|
||||||
|
"keyword": {
|
||||||
|
"type": "keyword"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "text",
|
||||||
|
"fields": {
|
||||||
|
"english": {
|
||||||
|
"type": "text",
|
||||||
|
"analyzer": "english"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "keyword"
|
||||||
|
},
|
||||||
|
"user_count": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,6 +10,22 @@ services:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- database_network
|
- database_network
|
||||||
|
elasticsearch:
|
||||||
|
image: elasticsearch:6.8.1
|
||||||
|
ports:
|
||||||
|
- "9200:9200"
|
||||||
|
volumes:
|
||||||
|
- esdata:/usr/share/elasticsearch/data
|
||||||
|
networks:
|
||||||
|
- phoenix_network
|
||||||
|
- es_network
|
||||||
|
# Kibana is just for development, really
|
||||||
|
kibana:
|
||||||
|
image: kibana:6.8.1
|
||||||
|
networks:
|
||||||
|
- es_network
|
||||||
|
ports:
|
||||||
|
- "5601:5601"
|
||||||
# This is for running the occasional graph layout task. It's in docker-compose.yml so that it's built at the same time
|
# This is for running the occasional graph layout task. It's in docker-compose.yml so that it's built at the same time
|
||||||
# as everything else, but it should be run regularly with a cron job or similar.
|
# as everything else, but it should be run regularly with a cron job or similar.
|
||||||
gephi:
|
gephi:
|
||||||
|
@ -26,6 +42,7 @@ services:
|
||||||
build: ./backend
|
build: ./backend
|
||||||
networks:
|
networks:
|
||||||
- database_network
|
- database_network
|
||||||
|
- phoenix_network
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
ports:
|
ports:
|
||||||
|
@ -37,7 +54,12 @@ services:
|
||||||
- BACKEND_HOSTNAME
|
- BACKEND_HOSTNAME
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
|
esdata:
|
||||||
gradle-cache:
|
gradle-cache:
|
||||||
networks:
|
networks:
|
||||||
database_network:
|
database_network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
phoenix_network:
|
||||||
|
driver: bridge
|
||||||
|
es_network:
|
||||||
|
driver: bridge
|
||||||
|
|
|
@ -47,7 +47,7 @@ const GraphKey: React.FC<IGraphKeyProps> = ({ current, colorSchemes, onItemSelec
|
||||||
<H6>Key</H6>
|
<H6>Key</H6>
|
||||||
<ul className={Classes.LIST_UNSTYLED}>
|
<ul className={Classes.LIST_UNSTYLED}>
|
||||||
{current.values.map(v => (
|
{current.values.map(v => (
|
||||||
<StyledLi>
|
<StyledLi key={v}>
|
||||||
<InstanceType type={v} />
|
<InstanceType type={v} />
|
||||||
</StyledLi>
|
</StyledLi>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -1,9 +1,16 @@
|
||||||
import cytoscape from "cytoscape";
|
import cytoscape from "cytoscape";
|
||||||
|
import { isEqual } from "lodash";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import tippy, { Instance } from "tippy.js";
|
import tippy, { Instance } from "tippy.js";
|
||||||
import { DEFAULT_NODE_COLOR, QUALITATIVE_COLOR_SCHEME, SELECTED_NODE_COLOR } from "../../constants";
|
import {
|
||||||
|
DEFAULT_NODE_COLOR,
|
||||||
|
HOVERED_NODE_COLOR,
|
||||||
|
QUALITATIVE_COLOR_SCHEME,
|
||||||
|
SEARCH_RESULT_COLOR,
|
||||||
|
SELECTED_NODE_COLOR
|
||||||
|
} from "../../constants";
|
||||||
import { IColorSchemeType } from "../../types";
|
import { IColorSchemeType } from "../../types";
|
||||||
|
|
||||||
const CytoscapeContainer = styled.div`
|
const CytoscapeContainer = styled.div`
|
||||||
|
@ -16,6 +23,8 @@ interface ICytoscapeProps {
|
||||||
colorScheme?: IColorSchemeType;
|
colorScheme?: IColorSchemeType;
|
||||||
currentNodeId: string | null;
|
currentNodeId: string | null;
|
||||||
elements: cytoscape.ElementsDefinition;
|
elements: cytoscape.ElementsDefinition;
|
||||||
|
hoveringOver?: string;
|
||||||
|
searchResultIds?: string[];
|
||||||
navigateToInstancePath?: (domain: string) => void;
|
navigateToInstancePath?: (domain: string) => void;
|
||||||
navigateToRoot?: () => void;
|
navigateToRoot?: () => void;
|
||||||
}
|
}
|
||||||
|
@ -128,6 +137,12 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
if (prevProps.colorScheme !== this.props.colorScheme) {
|
if (prevProps.colorScheme !== this.props.colorScheme) {
|
||||||
this.updateColorScheme();
|
this.updateColorScheme();
|
||||||
}
|
}
|
||||||
|
if (prevProps.hoveringOver !== this.props.hoveringOver) {
|
||||||
|
this.updateHoveredNodeClass(prevProps.hoveringOver);
|
||||||
|
}
|
||||||
|
if (!isEqual(prevProps.searchResultIds, this.props.searchResultIds)) {
|
||||||
|
this.updateSearchResultNodeClass();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount() {
|
public componentWillUnmount() {
|
||||||
|
@ -184,20 +199,42 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
* This is used during initilization to avoid having to do multiple renderings of the graph.
|
* This is used during initilization to avoid having to do multiple renderings of the graph.
|
||||||
*/
|
*/
|
||||||
private resetNodeColorScheme = (style?: any) => {
|
private resetNodeColorScheme = (style?: any) => {
|
||||||
|
if (!style) {
|
||||||
|
style = this.cy!.style() as any;
|
||||||
|
}
|
||||||
|
style = style.selector("node").style({
|
||||||
|
"background-color": DEFAULT_NODE_COLOR,
|
||||||
|
// The size from the backend is log_10(userCount), which from 10 <= userCount <= 1,000,000 gives us the range
|
||||||
|
// 1-6. We map this to the range of sizes we want.
|
||||||
|
// TODO: I should probably check that that the backend is actually using log_10 and not log_e, but it look
|
||||||
|
// quite good as it is, so...
|
||||||
|
height: "mapData(size, 1, 6, 20, 200)",
|
||||||
|
label: "data(id)",
|
||||||
|
width: "mapData(size, 1, 6, 20, 200)"
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setNodeSearchColorScheme(style);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We always want to set node search/hover styles at the end of a style change to make sure they don't get overwritten.
|
||||||
|
*/
|
||||||
|
private setNodeSearchColorScheme = (style?: any) => {
|
||||||
if (!style) {
|
if (!style) {
|
||||||
style = this.cy!.style() as any;
|
style = this.cy!.style() as any;
|
||||||
}
|
}
|
||||||
style
|
style
|
||||||
.selector("node")
|
.selector("node.searchResult")
|
||||||
.style({
|
.style({
|
||||||
"background-color": DEFAULT_NODE_COLOR,
|
"background-color": SEARCH_RESULT_COLOR,
|
||||||
// The size from the backend is log_10(userCount), which from 10 <= userCount <= 1,000,000 gives us the range
|
"border-color": SEARCH_RESULT_COLOR,
|
||||||
// 1-6. We map this to the range of sizes we want.
|
"border-opacity": 0.7,
|
||||||
// TODO: I should probably check that that the backend is actually using log_10 and not log_e, but it look
|
"border-width": 250
|
||||||
// quite good as it is, so...
|
})
|
||||||
height: "mapData(size, 1, 6, 20, 200)",
|
.selector("node.hovered")
|
||||||
label: "data(id)",
|
.style({
|
||||||
width: "mapData(size, 1, 6, 20, 200)"
|
"border-color": HOVERED_NODE_COLOR,
|
||||||
|
"border-width": 1000
|
||||||
})
|
})
|
||||||
.selector("node:selected")
|
.selector("node:selected")
|
||||||
.style({
|
.style({
|
||||||
|
@ -220,12 +257,47 @@ class Cytoscape extends React.PureComponent<ICytoscapeProps> {
|
||||||
"background-color": QUALITATIVE_COLOR_SCHEME[idx]
|
"background-color": QUALITATIVE_COLOR_SCHEME[idx]
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
style
|
|
||||||
.selector("node:selected")
|
this.setNodeSearchColorScheme(style);
|
||||||
.style({ "background-color": SELECTED_NODE_COLOR })
|
|
||||||
.update();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function sets the hover class on the node that's currently being hovered over in the search results
|
||||||
|
* (and removes it from the previous one if there was one).
|
||||||
|
*
|
||||||
|
* We explicitly pass the ID of the previously hovered node, rather than just using a class selector.
|
||||||
|
* This is because lookups by ID are significantly faster than class selectors.
|
||||||
|
*/
|
||||||
|
private updateHoveredNodeClass = (prevHoveredId?: string) => {
|
||||||
|
if (!this.cy) {
|
||||||
|
throw new Error("Expected cytoscape, but there wasn't one!");
|
||||||
|
}
|
||||||
|
const { hoveringOver } = this.props;
|
||||||
|
|
||||||
|
if (!!prevHoveredId) {
|
||||||
|
this.cy.$id(prevHoveredId).removeClass("hovered");
|
||||||
|
}
|
||||||
|
if (!!hoveringOver) {
|
||||||
|
this.cy.$id(hoveringOver).addClass("hovered");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateSearchResultNodeClass = () => {
|
||||||
|
if (!this.cy) {
|
||||||
|
throw new Error("Expected cytoscape, but there wasn't one!");
|
||||||
|
}
|
||||||
|
const { searchResultIds } = this.props;
|
||||||
|
|
||||||
|
this.cy.batch(() => {
|
||||||
|
this.cy!.nodes().removeClass("searchResult");
|
||||||
|
|
||||||
|
if (!!searchResultIds && searchResultIds.length > 0) {
|
||||||
|
const currentResultSelector = searchResultIds.map(id => `node[id = "${id}"]`).join(", ");
|
||||||
|
this.cy!.$(currentResultSelector).addClass("searchResult");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Cytoscape;
|
export default Cytoscape;
|
||||||
|
|
|
@ -35,8 +35,10 @@ const StyledDescription = styled.div`
|
||||||
interface ISearchResultProps {
|
interface ISearchResultProps {
|
||||||
result: ISearchResultInstance;
|
result: ISearchResultInstance;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onMouseEnter: () => void;
|
||||||
|
onMouseLeave: () => void;
|
||||||
}
|
}
|
||||||
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick }) => {
|
const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick, onMouseEnter, onMouseLeave }) => {
|
||||||
let shortenedDescription;
|
let shortenedDescription;
|
||||||
if (result.description) {
|
if (result.description) {
|
||||||
shortenedDescription = result.description && sanitize(result.description);
|
shortenedDescription = result.description && sanitize(result.description);
|
||||||
|
@ -55,7 +57,14 @@ const SearchResult: React.FC<ISearchResultProps> = ({ result, onClick }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledCard elevation={Elevation.ONE} interactive={true} key={result.name} onClick={onClick}>
|
<StyledCard
|
||||||
|
elevation={Elevation.ONE}
|
||||||
|
interactive={true}
|
||||||
|
key={result.name}
|
||||||
|
onClick={onClick}
|
||||||
|
onMouseEnter={onMouseEnter}
|
||||||
|
onMouseLeave={onMouseLeave}
|
||||||
|
>
|
||||||
<StyledHeadingContainer>
|
<StyledHeadingContainer>
|
||||||
<StyledH4>{result.name}</StyledH4>
|
<StyledH4>{result.name}</StyledH4>
|
||||||
{typeIcon}
|
{typeIcon}
|
||||||
|
|
|
@ -20,7 +20,9 @@ interface IGraphProps {
|
||||||
fetchGraph: () => void;
|
fetchGraph: () => void;
|
||||||
graph?: IGraph;
|
graph?: IGraph;
|
||||||
graphLoadError: boolean;
|
graphLoadError: boolean;
|
||||||
|
hoveringOverResult?: string;
|
||||||
isLoadingGraph: boolean;
|
isLoadingGraph: boolean;
|
||||||
|
searchResultDomains: string[];
|
||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}
|
}
|
||||||
interface IGraphState {
|
interface IGraphState {
|
||||||
|
@ -52,8 +54,10 @@ class GraphImpl extends React.PureComponent<IGraphProps, IGraphState> {
|
||||||
colorScheme={this.state.colorScheme}
|
colorScheme={this.state.colorScheme}
|
||||||
currentNodeId={this.props.currentInstanceName}
|
currentNodeId={this.props.currentInstanceName}
|
||||||
elements={this.props.graph}
|
elements={this.props.graph}
|
||||||
|
hoveringOver={this.props.hoveringOverResult}
|
||||||
navigateToInstancePath={this.navigateToInstancePath}
|
navigateToInstancePath={this.navigateToInstancePath}
|
||||||
navigateToRoot={this.navigateToRoot}
|
navigateToRoot={this.navigateToRoot}
|
||||||
|
searchResultIds={this.props.searchResultDomains}
|
||||||
ref={this.cytoscapeComponent}
|
ref={this.cytoscapeComponent}
|
||||||
/>
|
/>
|
||||||
<GraphTools
|
<GraphTools
|
||||||
|
@ -99,7 +103,9 @@ const mapStateToProps = (state: IAppState) => {
|
||||||
currentInstanceName: match && match.params.domain,
|
currentInstanceName: match && match.params.domain,
|
||||||
graph: state.data.graph,
|
graph: state.data.graph,
|
||||||
graphLoadError: state.data.error,
|
graphLoadError: state.data.error,
|
||||||
isLoadingGraph: state.data.isLoadingGraph
|
hoveringOverResult: state.search.hoveringOverResult,
|
||||||
|
isLoadingGraph: state.data.isLoadingGraph,
|
||||||
|
searchResultDomains: state.search.results.map(r => r.name)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||||
|
|
|
@ -42,18 +42,20 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
||||||
|
|
||||||
public componentDidMount() {
|
public componentDidMount() {
|
||||||
// Load instance settings from server
|
// Load instance settings from server
|
||||||
getFromApi(`admin`, this.authToken!)
|
if (!!this.authToken) {
|
||||||
.then(response => {
|
getFromApi(`admin`, this.authToken!)
|
||||||
this.setState({ settings: response });
|
.then(response => {
|
||||||
})
|
this.setState({ settings: response });
|
||||||
.catch(() => {
|
})
|
||||||
AppToaster.show({
|
.catch(() => {
|
||||||
icon: IconNames.ERROR,
|
AppToaster.show({
|
||||||
intent: Intent.DANGER,
|
icon: IconNames.ERROR,
|
||||||
message: "Failed to load settings.",
|
intent: Intent.DANGER,
|
||||||
timeout: 0
|
message: "Failed to load settings.",
|
||||||
|
timeout: 0
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
|
@ -71,7 +73,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
||||||
<p>{`${settings.userCount} users with ${settings.statusCount || "(unknown)"} statuses.`}</p>
|
<p>{`${settings.userCount} users with ${settings.statusCount || "(unknown)"} statuses.`}</p>
|
||||||
<form onSubmit={this.updateSettings}>
|
<form onSubmit={this.updateSettings}>
|
||||||
{settings.userCount < 10 && (
|
{settings.userCount < 10 && (
|
||||||
<FormGroup helperText="Check this if you'd like your personal instance to be crawled by fediverse.space.">
|
<FormGroup helperText="Check this if you'd like your personal instance to be crawled by fediverse.space. This takes up to 24 hours to take effect.">
|
||||||
<Switch
|
<Switch
|
||||||
id="opt-in-switch"
|
id="opt-in-switch"
|
||||||
checked={!!settings.optIn}
|
checked={!!settings.optIn}
|
||||||
|
@ -82,7 +84,7 @@ class AdminScreen extends React.PureComponent<IAdminScreenProps, IAdminScreenSta
|
||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
)}
|
)}
|
||||||
<FormGroup helperText="Check this if you don't want to your instance to be crawled. You won't appear on fediverse.space.">
|
<FormGroup helperText="Check this if you don't want to your instance to be crawled. You won't appear on fediverse.space. The change is immediate.">
|
||||||
<Switch
|
<Switch
|
||||||
id="opt-out-switch"
|
id="opt-out-switch"
|
||||||
checked={!!settings.optOut}
|
checked={!!settings.optOut}
|
||||||
|
|
|
@ -385,9 +385,9 @@ class InstanceScreenImpl extends React.PureComponent<IInstanceScreenProps, IInst
|
||||||
title="No data"
|
title="No data"
|
||||||
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
|
description="This instance has fewer than 10 users. It was not crawled in order to protect their privacy, but if it's your instance you can opt in."
|
||||||
action={
|
action={
|
||||||
<AnchorButton icon={IconNames.CONFIRM} href="https://cursed.technology/@fediversespace" target="_blank">
|
<Link to={"/admin"} className={Classes.BUTTON} role="button">
|
||||||
Message @fediversespace to opt in
|
{"Opt in"}
|
||||||
</AnchorButton>
|
</Link>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,7 +5,7 @@ import React from "react";
|
||||||
import { connect } from "react-redux";
|
import { connect } from "react-redux";
|
||||||
import { Dispatch } from "redux";
|
import { Dispatch } from "redux";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { updateSearch } from "../../redux/actions";
|
import { setResultHover, updateSearch } from "../../redux/actions";
|
||||||
import { IAppState, ISearchResultInstance } from "../../redux/types";
|
import { IAppState, ISearchResultInstance } from "../../redux/types";
|
||||||
import { SearchResult } from "../molecules";
|
import { SearchResult } from "../molecules";
|
||||||
|
|
||||||
|
@ -34,6 +34,7 @@ interface ISearchScreenProps {
|
||||||
results: ISearchResultInstance[];
|
results: ISearchResultInstance[];
|
||||||
handleSearch: (query: string) => void;
|
handleSearch: (query: string) => void;
|
||||||
navigateToInstance: (domain: string) => void;
|
navigateToInstance: (domain: string) => void;
|
||||||
|
setIsHoveringOver: (domain?: string) => void;
|
||||||
}
|
}
|
||||||
interface ISearchScreenState {
|
interface ISearchScreenState {
|
||||||
currentQuery: string;
|
currentQuery: string;
|
||||||
|
@ -72,7 +73,13 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
||||||
{this.renderSearchBar()}
|
{this.renderSearchBar()}
|
||||||
<SearchResults>
|
<SearchResults>
|
||||||
{results.map(result => (
|
{results.map(result => (
|
||||||
<SearchResult result={result} key={result.name} onClick={this.selectInstanceFactory(result.name)} />
|
<SearchResult
|
||||||
|
result={result}
|
||||||
|
key={result.name}
|
||||||
|
onClick={this.selectInstanceFactory(result.name)}
|
||||||
|
onMouseEnter={this.onMouseEnterFactory(result.name)}
|
||||||
|
onMouseLeave={this.onMouseLeave}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
|
{isLoadingResults && <StyledSpinner size={Spinner.SIZE_SMALL} />}
|
||||||
{!isLoadingResults && hasMoreResults && (
|
{!isLoadingResults && hasMoreResults && (
|
||||||
|
@ -100,9 +107,18 @@ class SearchScreen extends React.PureComponent<ISearchScreenProps, ISearchScreen
|
||||||
};
|
};
|
||||||
|
|
||||||
private selectInstanceFactory = (domain: string) => () => {
|
private selectInstanceFactory = (domain: string) => () => {
|
||||||
|
this.props.setIsHoveringOver(undefined);
|
||||||
this.props.navigateToInstance(domain);
|
this.props.navigateToInstance(domain);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onMouseEnterFactory = (domain: string) => () => {
|
||||||
|
this.props.setIsHoveringOver(domain);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onMouseLeave = () => {
|
||||||
|
this.props.setIsHoveringOver(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
private renderSearchBar = () => (
|
private renderSearchBar = () => (
|
||||||
<SearchBarContainer className={`${Classes.INPUT_GROUP} ${Classes.LARGE}`}>
|
<SearchBarContainer className={`${Classes.INPUT_GROUP} ${Classes.LARGE}`}>
|
||||||
<span className={`${Classes.ICON} bp3-icon-${IconNames.SEARCH}`} />
|
<span className={`${Classes.ICON} bp3-icon-${IconNames.SEARCH}`} />
|
||||||
|
@ -128,7 +144,8 @@ const mapStateToProps = (state: IAppState) => ({
|
||||||
});
|
});
|
||||||
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
const mapDispatchToProps = (dispatch: Dispatch) => ({
|
||||||
handleSearch: (query: string) => dispatch(updateSearch(query) as any),
|
handleSearch: (query: string) => dispatch(updateSearch(query) as any),
|
||||||
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`))
|
navigateToInstance: (domain: string) => dispatch(push(`/instance/${domain}`)),
|
||||||
|
setIsHoveringOver: (domain?: string) => dispatch(setResultHover(domain))
|
||||||
});
|
});
|
||||||
export default connect(
|
export default connect(
|
||||||
mapStateToProps,
|
mapStateToProps,
|
||||||
|
|
|
@ -3,19 +3,21 @@ export const DESKTOP_WIDTH_THRESHOLD = 1000;
|
||||||
|
|
||||||
export const DEFAULT_NODE_COLOR = "#CED9E0";
|
export const DEFAULT_NODE_COLOR = "#CED9E0";
|
||||||
export const SELECTED_NODE_COLOR = "#48AFF0";
|
export const SELECTED_NODE_COLOR = "#48AFF0";
|
||||||
|
export const SEARCH_RESULT_COLOR = "#AD99FF";
|
||||||
|
export const HOVERED_NODE_COLOR = SEARCH_RESULT_COLOR;
|
||||||
|
|
||||||
// From https://blueprintjs.com/docs/#core/colors.qualitative-color-schemes
|
// From https://blueprintjs.com/docs/#core/colors.qualitative-color-schemes, but brightened
|
||||||
export const QUALITATIVE_COLOR_SCHEME = [
|
export const QUALITATIVE_COLOR_SCHEME = [
|
||||||
"#2965CC",
|
"#669EFF",
|
||||||
"#29A634",
|
"#62D96B",
|
||||||
"#D99E0B",
|
"#FFC940",
|
||||||
"#D13913",
|
"#FF6E4A",
|
||||||
"#8F398F",
|
"#C274C2",
|
||||||
"#00B3A4",
|
"#2EE6D6",
|
||||||
"#DB2C6F",
|
"#FF66A1",
|
||||||
"#9BBF30",
|
"#D1F26D",
|
||||||
"#96622D",
|
"#C99765",
|
||||||
"#7157D9"
|
"#AD99FF"
|
||||||
];
|
];
|
||||||
|
|
||||||
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
|
export const INSTANCE_DOMAIN_PATH = "/instance/:domain";
|
||||||
|
|
|
@ -71,6 +71,13 @@ const resetSearch = () => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const setResultHover = (domain?: string) => {
|
||||||
|
return {
|
||||||
|
payload: domain,
|
||||||
|
type: ActionType.SET_SEARCH_RESULT_HOVER
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
/** Async actions: https://redux.js.org/advanced/asyncactions */
|
/** Async actions: https://redux.js.org/advanced/asyncactions */
|
||||||
|
|
||||||
export const loadInstance = (instanceName: string | null) => {
|
export const loadInstance = (instanceName: string | null) => {
|
||||||
|
@ -98,9 +105,11 @@ export const updateSearch = (query: string) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isNewQuery = getState().search.query !== query;
|
||||||
|
|
||||||
const next = getState().search.next;
|
const next = getState().search.next;
|
||||||
let url = `search/?query=${query}`;
|
let url = `search/?query=${query}`;
|
||||||
if (next) {
|
if (!isNewQuery && next) {
|
||||||
url += `&after=${next}`;
|
url += `&after=${next}`;
|
||||||
}
|
}
|
||||||
dispatch(requestSearchResult(query));
|
dispatch(requestSearchResult(query));
|
||||||
|
|
|
@ -86,6 +86,7 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
|
||||||
...state,
|
...state,
|
||||||
error: false,
|
error: false,
|
||||||
isLoadingResults: true,
|
isLoadingResults: true,
|
||||||
|
next: isNewQuery ? "" : state.next,
|
||||||
query,
|
query,
|
||||||
results: isNewQuery ? [] : state.results
|
results: isNewQuery ? [] : state.results
|
||||||
};
|
};
|
||||||
|
@ -108,6 +109,11 @@ const search = (state = initialSearchState, action: IAction): ISearchState => {
|
||||||
};
|
};
|
||||||
case ActionType.RESET_SEARCH:
|
case ActionType.RESET_SEARCH:
|
||||||
return initialSearchState;
|
return initialSearchState;
|
||||||
|
case ActionType.SET_SEARCH_RESULT_HOVER:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
hoveringOverResult: action.payload
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,9 +15,9 @@ export enum ActionType {
|
||||||
REQUEST_SEARCH_RESULTS = "REQUEST_SEARCH_RESULTS",
|
REQUEST_SEARCH_RESULTS = "REQUEST_SEARCH_RESULTS",
|
||||||
RECEIVE_SEARCH_RESULTS = "RECEIVE_SEARCH_RESULTS",
|
RECEIVE_SEARCH_RESULTS = "RECEIVE_SEARCH_RESULTS",
|
||||||
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
|
SEARCH_RESULTS_ERROR = "SEARCH_RESULTS_ERROR",
|
||||||
RESET_SEARCH = "RESET_SEARCH"
|
RESET_SEARCH = "RESET_SEARCH",
|
||||||
// REQUEST_INSTANCES = "REQUEST_INSTANCES",
|
// Search -- hovering over results
|
||||||
// RECEIVE_INSTANCES = "RECEIVE_INSTANCES",
|
SET_SEARCH_RESULT_HOVER = "SET_SEARCH_RESULT_HOVER"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAction {
|
export interface IAction {
|
||||||
|
@ -102,6 +102,7 @@ export interface ISearchState {
|
||||||
next: string;
|
next: string;
|
||||||
query: string;
|
query: string;
|
||||||
results: ISearchResultInstance[];
|
results: ISearchResultInstance[];
|
||||||
|
hoveringOverResult?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IAppState {
|
export interface IAppState {
|
||||||
|
|
Binary file not shown.
Loading…
Reference in a new issue