mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
251de1e42d | ||
|
|
edc91291b6 | ||
|
|
ac0ea64c07 | ||
|
|
9f7506a1b3 | ||
|
|
85cea66051 | ||
|
|
05c2df917c | ||
|
|
4ad2895eec | ||
|
|
7372e5313f | ||
|
|
637a54e01e | ||
|
|
04265eaec7 | ||
|
|
fa76821f7d | ||
|
|
09c6ce1d0b | ||
|
|
7fa9a265ef | ||
|
|
f201090b56 | ||
|
|
dda5b5fbcb | ||
|
|
a93c7ffee5 | ||
|
|
79a2d967e8 | ||
|
|
c264e11c26 | ||
|
|
9f8d189f47 | ||
|
|
bed0843f30 | ||
|
|
947e895de6 | ||
|
|
da848fcca1 | ||
|
|
74c3edd635 | ||
|
|
154043bed1 | ||
|
|
1854b421af | ||
|
|
b29fc4b183 | ||
|
|
47afa5659e | ||
|
|
3d87aeb5f9 | ||
|
|
f8e0376ddd | ||
|
|
29026ccad8 | ||
|
|
9774989eeb |
@@ -1,3 +0,0 @@
|
||||
/ui/public
|
||||
/db/
|
||||
/conf/
|
||||
248
README.md
248
README.md
@@ -1,102 +1,168 @@
|
||||
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
||||
|
||||
Finding an apartment or house in Germany can be stressful and
|
||||
time-consuming.\
|
||||
**Fredy** makes it easier: it automatically scrapes **ImmoScout24,
|
||||
Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht** and notifies you
|
||||
instantly via **Slack, Telegram, Email, ntfy, and more** when new
|
||||
listings appear.
|
||||
|
||||
With a modern architecture, Fredy provides a **clean Web UI**, removes
|
||||
duplicates across platforms, and stores results so you never see the
|
||||
same listing twice.
|
||||
|
||||
<img src="https://github.com/orangecoding/fredy/blob/master/doc/logo.png" width="400">
|
||||
|
||||
 [](https://github.com/orangecoding/fredy/actions/workflows/docker.yml) 
|
||||

|
||||
[](https://github.com/orangecoding/fredy/actions/workflows/docker.yml)
|
||||

|
||||

|
||||
|
||||
Searching an apartment in Germany can be a frustrating task. Not any longer though, as _Fredy_ will take over and will only notify you once new listings have been found that match your requirements.
|
||||
------------------------------------------------------------------------
|
||||
|
||||
_Fredy_ scrapes multiple services (Immonet, Immowelt etc.) and send new listings to you once they become available. The list of available services can easily be extended. For your convenience, _Fredy_ has a UI to help you configure your search jobs.
|
||||
## ✨ Key Features
|
||||
|
||||
If _Fredy_ finds matching results, it will send them to you via Slack, Email, Telegram etc. (More adapters can be configured.) As _Fredy_ stores the listings it has found, new results will not be sent to you twice (and as a side-effect, _Fredy_ can show some statistics). Furthermore, _Fredy_ checks duplicates per scraping so that the same listings are not being sent twice or more when posted on various platforms (which happens more often than one might think).
|
||||
- 🏠 Scrapes **ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen,
|
||||
WG-Gesucht**
|
||||
- ⚡ Instant notifications: Slack, Telegram, Email (SendGrid,
|
||||
Mailjet), ntfy
|
||||
- 🔎 Uses the **ImmoScout Mobile API** (reverse engineered)
|
||||
- 🌍 Runs anywhere: Docker, Node.js, self-hosted
|
||||
- 🖥️ Intuitive **Web UI** to manage searches
|
||||
- 🎯 Easy to use thanks to a user-friendly Web UI
|
||||
- 🔄 Deduplication across platforms
|
||||
- ⏱️ Customizable search intervals
|
||||
|
||||
# Sponsorship [](https://github.com/sponsors/orangecoding)
|
||||
------------------------------------------------------------------------
|
||||
|
||||
If you like my work, consider becoming a sponsor. I'm not expecting anybody to pay for _Fredy_ or any other Open Source Project I'm maintaining, however keep in mind, I'm doing all of this in my spare time :) Thanks.
|
||||
## 🤝 Sponsorship [](https://github.com/sponsors/orangecoding)
|
||||
|
||||
[](https://jb.gg/OpenSourceSupport)
|
||||
I maintain Fredy and other open-source projects in my free time.\
|
||||
If you find it useful, consider supporting the project 💙
|
||||
|
||||
_Fredy_ is supported by JetBrains under Open Source Support Program
|
||||
Fredy is proudly backed by the **JetBrains Open Source Support Program**.
|
||||
[<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" alt="JetBrains" width="120"/>](https://jb.gg/OpenSourceSupport)
|
||||
|
||||
## Demo
|
||||
------------------------------------------------------------------------
|
||||
|
||||
If you want to try out _Fredy_, you can access the demo version [here](https://fredy.orange-coding.net) 🤘
|
||||
## 👨🏫 Demo
|
||||
You can try out Fredy here: [Fredy Demo](https://fredy-demo.orange-coding.net/)
|
||||
|
||||
## Usage
|
||||
------------------------------------------------------------------------
|
||||
|
||||
- Make sure to use Node.js 20 or above
|
||||
- Run the following commands:
|
||||
## 🚀 Quick Start
|
||||
|
||||
```ssh
|
||||
### With Docker
|
||||
|
||||
> [!NOTE]
|
||||
> In order to start Fredy, you must provide a config.json. As a start, use the one in this repo: https://github.com/orangecoding/fredy/blob/master/conf/config.json
|
||||
|
||||
``` bash
|
||||
docker run -d --name fredy \
|
||||
-v fredy_conf:/conf \
|
||||
-v fredy_db:/db \
|
||||
-p 9998:9998 \
|
||||
ghcr.io/orangecoding/fredy:master
|
||||
```
|
||||
|
||||
Logs:
|
||||
|
||||
``` bash
|
||||
docker logs fredy -f
|
||||
```
|
||||
|
||||
### Manual (Node.js)
|
||||
|
||||
- Requirement: **Node.js 20 or higher**
|
||||
- Install dependencies and start:
|
||||
|
||||
``` bash
|
||||
yarn
|
||||
yarn run start:backend
|
||||
yarn run start:frontend
|
||||
yarn run start:backend # in one terminal
|
||||
yarn run start:frontend # in another terminal
|
||||
```
|
||||
|
||||
_Fredy_ will start with the default port, set to `9998`. You can access _Fredy_ by opening your browser at `http://localhost:9998`. The default login is `admin`, both for username and password. You should change the password as soon as possible when you plan to run Fredy on a server.
|
||||
👉 Open <http://localhost:9998>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Job Configuration" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot1.png" width="30%">
|
||||
|
||||
<img alt="Job Analytics" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_2.png" width="30%">
|
||||
|
||||
<img alt="Job Overview" width="30%" src="https://github.com/orangecoding/fredy/blob/master/doc/screenshot_3.png">
|
||||
</p>
|
||||
**Default Login:**
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
## Understanding the fundamentals
|
||||
------------------------------------------------------------------------
|
||||
|
||||
There are 3 important parts in Fredy, that you need to understand to leverage the full power of _Fredy_.
|
||||
## 📸 Screenshots
|
||||
|
||||
#### Provider
|
||||
| Job Configuration | Job Analytics | Job Overview |
|
||||
|-------------------|--------------|--------------|
|
||||
|  |  |  |
|
||||
|
||||
_Fredy_ supports multiple services. Immonet, Immowelt and Ebay are just a few examples. Those services are called providers within _Fredy_. When creating a new job, you can choose one or more providers.
|
||||
A provider contains the URL that points to the search results for the respective service. If you go to immonet.de and search for something, the displayed URL in the browser is what the provider needs to do its magic.
|
||||
**It is important that you order the search results by date, so that _Fredy_ always picks the latest results first!**
|
||||
------------------------------------------------------------------------
|
||||
|
||||
#### Adapter
|
||||
## 🧩 Core Concepts
|
||||
|
||||
_Fredy_ supports multiple adapters, such as Slack, SendGrid, Telegram etc. A search job can have as many adapters as supported by _Fredy_. Each adapter needs different configuration values, which you have to provide when using them. An adapter dictates how the frontend renders by telling the frontend what information it needs in order to send listings to the user.
|
||||
Fredy is built around three simple concepts:
|
||||
|
||||
#### Jobs
|
||||
### Provider 🌐
|
||||
|
||||
A Job wraps adapters and providers. _Fredy_ runs the configured jobs in a specific interval (can be configured in `/conf/config.json`).
|
||||
A **provider** is a real-estate platform (e.g. ImmoScout24, Immowelt,
|
||||
Immonet, eBay Kleinanzeigen, WG-Gesucht).\
|
||||
When you create a job, you paste the search URL from the platform into
|
||||
Fredy.\
|
||||
⚠️ Always make sure the search results are sorted by **date**, so Fredy
|
||||
picks up the newest listings first.
|
||||
|
||||
## Creating your first job
|
||||
### Adapter 📡
|
||||
|
||||
To create your first job, click on the button "Create New Job" on the job table. The job creation dialog should be self-explanatory, however there is one important thing.
|
||||
When configuring providers, before copying the URL from your browser, make sure that you have sorted the results by date to make sure _Fredy_ always picks the latest results first.
|
||||
An **adapter** is the channel through which Fredy notifies you (Slack,
|
||||
Telegram, Email, ntfy, ...).\
|
||||
Each adapter has its own configuration (e.g. API keys, webhook URLs).\
|
||||
You can use multiple adapters at once --- Fredy will send new listings
|
||||
through all of them.
|
||||
|
||||
## User management
|
||||
### Job 📅
|
||||
|
||||
As an administrator, you can create, edit and remove users from _Fredy_. Be careful, each job is connected to the user that has created the job. If you remove the user, their jobs will also be removed.
|
||||
A **job** combines providers and adapters.\
|
||||
Example: "Search apartments on ImmoScout24 + Immowelt and send results
|
||||
to Slack + Telegram."\
|
||||
Jobs run automatically at the interval you configure (see
|
||||
`/conf/config.json`).
|
||||
|
||||
# Development
|
||||
------------------------------------------------------------------------
|
||||
|
||||
### Running Fredy in development mode
|
||||
## Immoscout
|
||||
|
||||
Start the backend with:
|
||||
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
|
||||
|
||||
```shell
|
||||
## Analytics
|
||||
|
||||
Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
|
||||
Before you freak out, let me explain...
|
||||
If you agree, Fredy will send a ping once every 6 hours to my internal tracking project (Will be open sourced soon).
|
||||
The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The information is entirely anonymous and helps me understand which adapters/providers are most frequently used.</p>
|
||||
|
||||
**Thanks**🤘
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Development Mode
|
||||
|
||||
``` bash
|
||||
yarn run start:backend:dev
|
||||
```
|
||||
|
||||
For the frontend, run:
|
||||
|
||||
```shell
|
||||
yarn run start:frontend:dev
|
||||
```
|
||||
|
||||
You should now be able to access _Fredy_ from your browser. Check your Terminal to see what port the frontend is running on.
|
||||
|
||||
### Running Tests
|
||||
### Run Tests
|
||||
|
||||
To run the tests, run
|
||||
|
||||
```shell
|
||||
``` bash
|
||||
yarn run test
|
||||
```
|
||||
|
||||
# Architecture
|
||||
------------------------------------------------------------------------
|
||||
|
||||
```mermaid
|
||||
## 📐 Architecture
|
||||
|
||||
``` mermaid
|
||||
flowchart TD
|
||||
subgraph Jobs["Jobs"]
|
||||
A1["Job 1"]
|
||||
@@ -109,80 +175,36 @@ flowchart TD
|
||||
C3["Provider 3"]
|
||||
end
|
||||
subgraph NotificationAdapters["Notification Adapters"]
|
||||
F1["Notification Adapter 1"]
|
||||
F2["Notification Adapter 2"]
|
||||
F1["Adapter 1"]
|
||||
F2["Adapter 2"]
|
||||
end
|
||||
|
||||
A1 --> B["FredyRuntime"]
|
||||
A2 --> B
|
||||
A3 --> B
|
||||
B --> C1 & C2 & C3
|
||||
C1 --> D["Similarity-Check"]
|
||||
C1 --> D["Similarity Check"]
|
||||
C2 --> D
|
||||
C3 --> D
|
||||
D --> E{"Found<br>similarity?"}
|
||||
D --> E{"Duplicate?"}
|
||||
E -- No --> F1
|
||||
F1 --> F2
|
||||
|
||||
style A1 fill:#fde9a0,stroke:#333333,color:#333333
|
||||
style A2 fill:#fde9a0,stroke:#333333,color:#333333
|
||||
style A3 fill:#fde9a0,stroke:#333333,color:#333333
|
||||
style C1 fill:#c4c9f1,stroke:#333333,color:#333333
|
||||
style C2 fill:#c4c9f1,stroke:#333333,color:#333333
|
||||
style C3 fill:#c4c9f1,stroke:#333333,color:#333333
|
||||
style F1 fill:#d2edba,stroke:#333333,color:#333333
|
||||
style F2 fill:#d2edba,stroke:#333333,color:#333333
|
||||
style B fill:#abd8f9,stroke:#333333,color:#333333
|
||||
style D fill:#fab4a8,stroke:#333333,color:#333333
|
||||
style E fill:#fffbb4,stroke:#333333,color:#333333
|
||||
```
|
||||
|
||||
### Immoscout
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Immoscout has implemented advanced bot detection. In order to work around this, we are using a reversed engineered version of their mobile api. See [Immoscout Reverse Engineering Documentation](https://github.com/orangecoding/fredy/blob/master/reverse-engineered-immoscout.md)
|
||||
## 👐 Contributing
|
||||
|
||||
# Analytics
|
||||
Thanks to everyone who has contributed!
|
||||
|
||||
Fredy is completely free (and will always remain free). However, it would be a huge help if you’d allow me to collect some analytical data.
|
||||
Before you freak out, let me explain...
|
||||
If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
|
||||
The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The information is entirely anonymous and helps me understand which adapters/providers are most frequently used.</p>
|
||||
**Thanks**🤘
|
||||
<a href="https://github.com/orangecoding/fredy/graphs/contributors"><img src="https://contrib.rocks/image?repo=orangecoding/fredy" /></a>
|
||||
|
||||
# Docker
|
||||
See the [Contributing
|
||||
Guide](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md).
|
||||
|
||||
Use the Dockerfile in this repository to build an image.
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Example: `docker build -t fredy/fredy /path/to/your/Dockerfile`
|
||||
## ⭐ Star History
|
||||
|
||||
Or use docker-compose:
|
||||
|
||||
Example `docker-compose build`
|
||||
|
||||
Or use the container that will be built automatically.
|
||||
|
||||
`docker pull ghcr.io/orangecoding/fredy:master`
|
||||
|
||||
## Create & run a container
|
||||
|
||||
Put your config.json into a path of your choice, such as `/path/to/your/conf/`.
|
||||
|
||||
Example: `docker create --name fredy -v /path/to/your/conf/:/conf -p 9998:9998 fredy/fredy`
|
||||
|
||||
## Logs
|
||||
|
||||
You can browse the logs with `docker logs fredy -f`.
|
||||
|
||||
### 👐 Contributing
|
||||
|
||||
Thanks to all the people who already contributed!
|
||||
|
||||
<a href="https://github.com/orangecoding/fredy/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=orangecoding/fredy" />
|
||||
</a>
|
||||
|
||||
See [Contributing](https://github.com/orangecoding/fredy/blob/master/CONTRIBUTING.md)
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#orangecoding/fredy&Date)
|
||||
[](https://www.star-history.com/#orangecoding/fredy&Date)
|
||||
|
||||
8
conf/config.json
Executable file → Normal file
8
conf/config.json
Executable file → Normal file
@@ -1,7 +1 @@
|
||||
{
|
||||
"interval": "60",
|
||||
"port": 9998,
|
||||
"workingHours": { "from": "", "to": "" },
|
||||
"demoMode": false,
|
||||
"analyticsEnabled": null
|
||||
}
|
||||
{"interval":"60","port":9998,"workingHours":{"from":"","to":""},"demoMode":false,"analyticsEnabled":null}
|
||||
@@ -6,6 +6,10 @@ import react from 'eslint-plugin-react';
|
||||
import babelParser from '@babel/eslint-parser';
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
ignores: ['**/node_modules/**', '**/dist/**', '**/build/**', '**/public/**', 'db/**', 'conf/**'],
|
||||
},
|
||||
js.configs.recommended,
|
||||
prettier,
|
||||
{
|
||||
@@ -23,70 +27,34 @@ export default [
|
||||
after: 'readonly',
|
||||
it: 'readonly',
|
||||
},
|
||||
parserOptions: {
|
||||
requireConfigFile: false,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
react,
|
||||
parserOptions: { requireConfigFile: false },
|
||||
},
|
||||
plugins: { react },
|
||||
rules: {
|
||||
eqeqeq: [2, 'allow-null'],
|
||||
|
||||
// Semantics / Performance impacting
|
||||
strict: 0,
|
||||
'no-redeclare': [2, { builtinGlobals: false }],
|
||||
'class-methods-use-this': 'off',
|
||||
|
||||
// Style
|
||||
indent: ['off', 2],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: true }],
|
||||
semi: ['error', 'always'],
|
||||
'no-console': ['error', { allow: ['warn', 'error'] }],
|
||||
|
||||
// React
|
||||
'jsx-quotes': ['error', 'prefer-double'],
|
||||
'react/display-name': 'off',
|
||||
'react/forbid-prop-types': 'off',
|
||||
'react/jsx-closing-bracket-location': 'off',
|
||||
'react/jsx-curly-spacing': 'off',
|
||||
'react/jsx-handler-names': [
|
||||
'off',
|
||||
{
|
||||
eventHandlerPrefix: 'handle',
|
||||
eventHandlerPropPrefix: 'on',
|
||||
},
|
||||
],
|
||||
'react/jsx-handler-names': ['off', { eventHandlerPrefix: 'handle', eventHandlerPropPrefix: 'on' }],
|
||||
'react/jsx-indent-props': 'off',
|
||||
'react/jsx-key': 'off',
|
||||
'react/jsx-max-props-per-line': 'off',
|
||||
'react/jsx-no-bind': [
|
||||
'error',
|
||||
{
|
||||
ignoreRefs: true,
|
||||
allowArrowFunctions: true,
|
||||
allowBind: false,
|
||||
},
|
||||
],
|
||||
'react/jsx-no-bind': ['error', { ignoreRefs: true, allowArrowFunctions: true, allowBind: false }],
|
||||
'react/jsx-no-duplicate-props': ['error', { ignoreCase: true }],
|
||||
'react/jsx-no-literals': 'off',
|
||||
'react/jsx-no-undef': 'error',
|
||||
'react/jsx-pascal-case': [
|
||||
'error',
|
||||
{
|
||||
allowAllCaps: true,
|
||||
ignore: [],
|
||||
},
|
||||
],
|
||||
'react/sort-prop-types': [
|
||||
'off',
|
||||
{
|
||||
ignoreCase: true,
|
||||
callbacksLast: false,
|
||||
requiredFirst: false,
|
||||
},
|
||||
],
|
||||
'react/jsx-pascal-case': ['error', { allowAllCaps: true, ignore: [] }],
|
||||
'react/sort-prop-types': ['off', { ignoreCase: true, callbacksLast: false, requiredFirst: false }],
|
||||
'react/jsx-sort-prop-types': 'off',
|
||||
'react/jsx-sort-props': 'off',
|
||||
'react/jsx-uses-react': 'error',
|
||||
@@ -106,14 +74,7 @@ export default [
|
||||
'react/require-render-return': 'error',
|
||||
'react/self-closing-comp': 'warn',
|
||||
'react/sort-comp': 'off',
|
||||
'react/jsx-wrap-multilines': [
|
||||
'warn',
|
||||
{
|
||||
declaration: true,
|
||||
assignment: true,
|
||||
return: true,
|
||||
},
|
||||
],
|
||||
'react/jsx-wrap-multilines': ['warn', { declaration: true, assignment: true, return: true }],
|
||||
'react/wrap-multilines': 'off',
|
||||
'react/jsx-first-prop-new-line': 'off',
|
||||
'react/jsx-equals-spacing': ['warn', 'never'],
|
||||
@@ -126,20 +87,10 @@ export default [
|
||||
'react/no-find-dom-node': 'warn',
|
||||
'react/forbid-component-props': ['off', { forbid: [] }],
|
||||
'react/no-danger-with-children': 'error',
|
||||
'react/no-unused-prop-types': [
|
||||
'warn',
|
||||
{
|
||||
customValidators: [],
|
||||
skipShapeProps: true,
|
||||
},
|
||||
],
|
||||
'react/no-unused-prop-types': ['warn', { customValidators: [], skipShapeProps: true }],
|
||||
'react/style-prop-object': 'error',
|
||||
'react/no-children-prop': 'warn',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
settings: { react: { version: 'detect' } },
|
||||
},
|
||||
];
|
||||
|
||||
@@ -102,15 +102,15 @@ class FredyRuntime {
|
||||
|
||||
_filterBySimilarListings(listings) {
|
||||
const filteredList = listings.filter((listing) => {
|
||||
const similar = this._similarityCache.hasSimilarEntries(this._jobKey, listing.title);
|
||||
const similar = this._similarityCache.hasSimilarEntries(listing.title, listing.address);
|
||||
if (similar) {
|
||||
/* eslint-disable no-console */
|
||||
console.debug(`Filtering similar entry for job with id ${this._jobKey} with title: `, listing.title);
|
||||
console.debug(`Filtering similar entry for title: ${listing.title} and address ${listing.address}`);
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
return !similar;
|
||||
});
|
||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(this._jobKey, filter.title));
|
||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, listings.address));
|
||||
return filteredList;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||
import * as userStorage from '../../services/storage/userStorage.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { isAdmin } from '../security.js';
|
||||
import { trackDemoJobCreated } from '../../services/tracking/Tracker.js';
|
||||
const service = restana();
|
||||
const jobRouter = service.newRouter();
|
||||
function doesJobBelongsToUser(job, req) {
|
||||
@@ -46,11 +45,6 @@ jobRouter.post('/', async (req, res) => {
|
||||
res.send(new Error(error));
|
||||
console.error(error);
|
||||
}
|
||||
trackDemoJobCreated({
|
||||
name,
|
||||
provider,
|
||||
adapter: notificationAdapter,
|
||||
});
|
||||
res.send();
|
||||
});
|
||||
jobRouter.delete('', async (req, res) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ loginRouter.post('/', async (req, res) => {
|
||||
}
|
||||
if (user.password === hasher.hash(password)) {
|
||||
if (config.demoMode) {
|
||||
trackDemoAccessed();
|
||||
await trackDemoAccessed();
|
||||
}
|
||||
|
||||
req.session.currentUser = user.id;
|
||||
|
||||
@@ -63,31 +63,41 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
|
||||
const throttledCall = getThrottled(chatId, async function (endpoint, body) {
|
||||
await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, {
|
||||
const res = await fetch(`https://api.telegram.org/bot${token}/${endpoint}`, {
|
||||
method: 'post',
|
||||
body: JSON.stringify(body),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return res;
|
||||
});
|
||||
|
||||
const promises = newListings.map(async (o) => {
|
||||
const img = normalizeImageUrl(o.image);
|
||||
const textPayload = {
|
||||
chat_id: chatId,
|
||||
text: buildText(jobName, serviceName, o),
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
};
|
||||
|
||||
if (img) {
|
||||
return throttledCall('sendPhoto', {
|
||||
if (!img) {
|
||||
return throttledCall('sendMessage', textPayload);
|
||||
}
|
||||
|
||||
try {
|
||||
return await throttledCall('sendPhoto', {
|
||||
chat_id: chatId,
|
||||
photo: img,
|
||||
caption: buildCaption(jobName, serviceName, o),
|
||||
parse_mode: 'HTML',
|
||||
});
|
||||
} catch (e) {
|
||||
// If we see a timeout due to sending an image, try sending it without
|
||||
if (e && (e.code === 'ETIMEDOUT' || e.errno === 'ETIMEDOUT')) {
|
||||
return throttledCall('sendMessage', textPayload);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
return throttledCall('sendMessage', {
|
||||
chat_id: chatId,
|
||||
text: buildText(jobName, serviceName, o),
|
||||
parse_mode: 'HTML',
|
||||
disable_web_page_preview: true,
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
|
||||
@@ -66,7 +66,9 @@ export function parse(crawlContainer, crawlFields, text, url) {
|
||||
if (parsedObject.id != null) {
|
||||
result.push(parsedObject);
|
||||
} else {
|
||||
console.warn('ID not found. Not relaying object.');
|
||||
/* eslint-disable no-console */
|
||||
console.debug('ID not found. Not relaying object.');
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import stringSimilarity from 'string-similarity';
|
||||
//if the score is higher than this, it will be considered a match
|
||||
const MAX_DICE_INDEX = 0.7;
|
||||
export default (class SimilarityCacheEntry {
|
||||
constructor(time) {
|
||||
this.time = time;
|
||||
this.values = [];
|
||||
}
|
||||
setCacheEntry = (entry) => {
|
||||
this.values.push(entry);
|
||||
};
|
||||
getTime = () => {
|
||||
return this.time;
|
||||
};
|
||||
hasSimilarEntries = (value) => {
|
||||
if (this.values.length > 0) {
|
||||
for (let i = 0; i < this.values.length; i++) {
|
||||
const index = stringSimilarity.compareTwoStrings(value, this.values[i]);
|
||||
if (index >= MAX_DICE_INDEX) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
});
|
||||
@@ -1,40 +1,116 @@
|
||||
import SimilarityCacheEntry from './SimilarityCacheEntry.js';
|
||||
import { config } from '../../utils.js';
|
||||
//5 minutes
|
||||
let retention = 5 * 60 * 1000;
|
||||
const intervalInMs = config.interval * 60 * 1000;
|
||||
//an interval below 5 mins sounds crazy, but there are ppl out there doing crazy shit.
|
||||
if (intervalInMs <= retention) {
|
||||
retention = Math.floor(intervalInMs / 2);
|
||||
}
|
||||
//jobid -> SimilarityCacheEntry
|
||||
const cache = {};
|
||||
let intervalId;
|
||||
import crypto from 'crypto';
|
||||
|
||||
const retention = 60 * 60 * 1000;
|
||||
/**
|
||||
* cleanup
|
||||
* Internal cache storage.
|
||||
* Maps a SHA-256 hash (string) to its expiry timestamp (number in ms).
|
||||
* @type {Map<string, number>}
|
||||
*/
|
||||
intervalId = setInterval(() => {
|
||||
const keysToBeRemoved = [];
|
||||
const entries = new Map();
|
||||
|
||||
/**
|
||||
* Reference to the currently scheduled cleanup timer.
|
||||
* @type {NodeJS.Timeout | null}
|
||||
*/
|
||||
let timer = null;
|
||||
|
||||
/**
|
||||
* Generate a SHA-256 hash from a list of input strings.
|
||||
* Null or undefined values are ignored.
|
||||
*
|
||||
* @param {...(string|null|undefined)} strings - Input values to hash
|
||||
* @returns {string} Hexadecimal hash
|
||||
*/
|
||||
function toHash(...strings) {
|
||||
return crypto.createHash('sha256').update(strings.filter(Boolean).join('|')).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup expired cache entries and schedule the next cleanup run.
|
||||
* This function is invoked automatically by scheduled timers.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function runCleanup() {
|
||||
const now = Date.now();
|
||||
Object.keys(cache).forEach((key) => {
|
||||
if (cache[key].getTime() + retention < now) {
|
||||
keysToBeRemoved.push(key);
|
||||
}
|
||||
});
|
||||
if (keysToBeRemoved.length > 0) {
|
||||
keysToBeRemoved.forEach((key) => delete cache[key]);
|
||||
for (const [hash, expiry] of entries) {
|
||||
if (expiry <= now) entries.delete(hash);
|
||||
}
|
||||
}, 10000);
|
||||
export const addCacheEntry = (jobId, value) => {
|
||||
cache[jobId] = cache[jobId] || new SimilarityCacheEntry(Date.now());
|
||||
cache[jobId].setCacheEntry(value);
|
||||
};
|
||||
export const hasSimilarEntries = (jobId, value) => {
|
||||
if (cache[jobId] == null) {
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the soonest expiry timestamp among all cache entries
|
||||
* and schedule a one-shot timer that will trigger at that time.
|
||||
* Cancels any existing timer before scheduling a new one.
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
function scheduleNext() {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
let next = Infinity;
|
||||
const now = Date.now();
|
||||
for (const expiry of entries.values()) {
|
||||
if (expiry > now && expiry < next) next = expiry;
|
||||
}
|
||||
if (next !== Infinity) {
|
||||
timer = setTimeout(runCleanup, Math.max(0, next - now));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or refresh a cache entry for the given title and address.
|
||||
* The entry will automatically expire after the configured retention window.
|
||||
*
|
||||
* @param {string} title - The title used to build the cache key
|
||||
* @param {string} address - The address used to build the cache key
|
||||
*/
|
||||
export function addCacheEntry(title, address) {
|
||||
const hash = toHash(title, address);
|
||||
const expiry = Date.now() + retention;
|
||||
entries.set(hash, expiry);
|
||||
scheduleNext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cache entry with the same title and address exists
|
||||
* and is still valid (not expired).
|
||||
*
|
||||
* @param {string} title - The title used to build the cache key
|
||||
* @param {string} address - The address used to build the cache key
|
||||
* @returns {boolean} True if a valid cache entry exists, false otherwise
|
||||
*/
|
||||
export function hasSimilarEntries(title, address) {
|
||||
const hash = toHash(title, address);
|
||||
const expiry = entries.get(hash);
|
||||
if (expiry == null) return false;
|
||||
if (expiry <= Date.now()) {
|
||||
entries.delete(hash);
|
||||
scheduleNext();
|
||||
return false;
|
||||
}
|
||||
return cache[jobId].hasSimilarEntries(value);
|
||||
};
|
||||
export const stopCacheCleanup = () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any scheduled cleanup timers and prevent further automatic cleanup.
|
||||
* Entries that are already in the cache will remain until removed manually
|
||||
* or until cleanup is started again by adding new entries.
|
||||
*/
|
||||
export function stopCacheCleanup() {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* this is only for test purposes
|
||||
*/
|
||||
export function invalidateAllForTest() {
|
||||
for (const key of entries.keys()) {
|
||||
entries.set(key, 0);
|
||||
}
|
||||
runCleanup();
|
||||
}
|
||||
|
||||
@@ -1,65 +1,77 @@
|
||||
import Mixpanel from 'mixpanel';
|
||||
import { getJobs } from '../storage/jobStorage.js';
|
||||
import { getUniqueId } from './uniqueId.js';
|
||||
import { config, inDevMode } from '../../utils.js';
|
||||
import os from 'os';
|
||||
import { readFileSync } from 'fs';
|
||||
import { packageUp } from 'package-up';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
const mixpanelTracker = Mixpanel.init('718670ef1c58c0208256c1e408a3d75e');
|
||||
const distinct_id = getUniqueId() || 'N/A';
|
||||
const deviceId = getUniqueId() || 'N/A';
|
||||
const version = await getPackageVersion();
|
||||
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
||||
|
||||
export const track = function () {
|
||||
//only send tracking information if the user allowed to do so.
|
||||
if (config.analyticsEnabled && !inDevMode()) {
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
let cached = null;
|
||||
let lastSent = 0;
|
||||
const SIX_HOURS = 6 * 3_600_000;
|
||||
|
||||
const jobs = getJobs();
|
||||
export const track = async () => {
|
||||
try {
|
||||
if (config.analyticsEnabled && !inDevMode()) {
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => {
|
||||
activeProvider.add(provider.id);
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => activeProvider.add(provider.id));
|
||||
job.notificationAdapter.forEach((adapter) => activeAdapter.add(adapter.id));
|
||||
});
|
||||
job.notificationAdapter.forEach((adapter) => {
|
||||
activeAdapter.add(adapter.id);
|
||||
});
|
||||
});
|
||||
|
||||
mixpanelTracker.track(
|
||||
'fredy_tracking',
|
||||
enrichTrackingObject({
|
||||
const trackingObj = enrichTrackingObject({
|
||||
adapter: Array.from(activeAdapter),
|
||||
provider: Array.from(activeProvider),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const stringify = JSON.stringify(trackingObj);
|
||||
const now = Date.now();
|
||||
|
||||
// send if changed OR six hours passed since last send
|
||||
if (stringify !== cached || now - lastSent >= SIX_HOURS) {
|
||||
await fetch(`${FREDY_TRACKING_URL}/main`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: stringify,
|
||||
});
|
||||
cached = stringify;
|
||||
lastSent = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Error sending tracking data', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Note, this will only be used when Fredy runs in demo mode
|
||||
*/
|
||||
export function trackDemoJobCreated(jobData) {
|
||||
export async function trackDemoAccessed() {
|
||||
if (config.analyticsEnabled && !inDevMode() && config.demoMode) {
|
||||
mixpanelTracker.track('demoJobCreated', enrichTrackingObject(jobData));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note, this will only be used when Fredy runs in demo mode
|
||||
*/
|
||||
export function trackDemoAccessed() {
|
||||
if (config.analyticsEnabled && !inDevMode() && config.demoMode) {
|
||||
mixpanelTracker.track('demoAccessed', enrichTrackingObject({}));
|
||||
try {
|
||||
await fetch(`${FREDY_TRACKING_URL}/demo/accessed`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('Error sending tracking data', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enrichTrackingObject(trackingObject) {
|
||||
const operating_system = os.platform();
|
||||
const os_version = os.release();
|
||||
const operatingSystem = os.platform();
|
||||
const osVersion = os.release();
|
||||
const arch = process.arch;
|
||||
const language = process.env.LANG || 'en';
|
||||
const nodeVersion = process.version || 'N/A';
|
||||
@@ -67,13 +79,13 @@ function enrichTrackingObject(trackingObject) {
|
||||
return {
|
||||
...trackingObject,
|
||||
isDemo: config.demoMode,
|
||||
operating_system,
|
||||
os_version,
|
||||
operatingSystem,
|
||||
osVersion,
|
||||
arch,
|
||||
nodeVersion,
|
||||
language,
|
||||
distinct_id,
|
||||
fredy_version: version,
|
||||
deviceId,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
25
lib/utils.js
25
lib/utils.js
@@ -3,6 +3,12 @@ import { fileURLToPath } from 'node:url';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { createHash } from 'crypto';
|
||||
import { DEFAULT_CONFIG } from './defaultConfig.js';
|
||||
import fs from 'fs';
|
||||
|
||||
const RE_GT = />/g;
|
||||
const RE_WEBP = /\/format\/webp/gi;
|
||||
const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i;
|
||||
const HTTPS_PREFIX = 'https://';
|
||||
|
||||
function inDevMode() {
|
||||
return process.env.NODE_ENV == null || process.env.NODE_ENV !== 'production';
|
||||
@@ -53,11 +59,14 @@ function buildHash(...inputs) {
|
||||
}
|
||||
|
||||
let config = {};
|
||||
|
||||
export async function readConfigFromStorage() {
|
||||
return JSON.parse(await readFile(new URL('../conf/config.json', import.meta.url)));
|
||||
}
|
||||
|
||||
export async function refreshConfig() {
|
||||
checkIfConfigExistsAndWriteIfNot();
|
||||
|
||||
try {
|
||||
config = await readConfigFromStorage();
|
||||
//backwards compatability...
|
||||
@@ -65,14 +74,20 @@ export async function refreshConfig() {
|
||||
config.demoMode ??= false;
|
||||
} catch (error) {
|
||||
config = { ...DEFAULT_CONFIG };
|
||||
console.error('Error reading config file', error);
|
||||
/* eslint-disable no-console */
|
||||
console.info('Error reading config file.', error);
|
||||
}
|
||||
}
|
||||
|
||||
const RE_GT = />/g;
|
||||
const RE_WEBP = /\/format\/webp/gi;
|
||||
const RE_EXT = /\.(jpe?g|png|gif)(\?.*)?$/i;
|
||||
const HTTPS_PREFIX = 'https://';
|
||||
/**
|
||||
* If the config file does not exist, we will create it.
|
||||
*/
|
||||
const checkIfConfigExistsAndWriteIfNot = () => {
|
||||
if (!fs.existsSync(`${getDirName()}/../conf/config.json`)) {
|
||||
console.info('Could not find config file. Will create one with default values now');
|
||||
fs.writeFileSync(`${getDirName()}/../conf/config.json`, JSON.stringify({ ...DEFAULT_CONFIG }));
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeImageUrl = (url) => {
|
||||
if (typeof url !== 'string' || url.length === 0) return null;
|
||||
|
||||
35
package.json
35
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "11.4.4",
|
||||
"version": "11.6.2",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -11,7 +11,7 @@
|
||||
"build:frontend": "vite build",
|
||||
"format": "prettier --write \"**/*.js\"",
|
||||
"format:check": "prettier --check \"**/*.js\"",
|
||||
"test": "mocha --loader=esmock --timeout 60000 test/**/*.test.js",
|
||||
"test": "node --import ./test/esmock-loader.mjs ./node_modules/mocha/bin/mocha.js --timeout 60000 test/**/*.test.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "yarn lint --fix"
|
||||
},
|
||||
@@ -54,60 +54,59 @@
|
||||
"Firefox ESR"
|
||||
],
|
||||
"dependencies": {
|
||||
"@douyinfe/semi-ui": "2.85.0",
|
||||
"@douyinfe/semi-ui": "2.86.0",
|
||||
"@rematch/core": "2.2.0",
|
||||
"@rematch/loading": "2.1.2",
|
||||
"@sendgrid/mail": "8.1.5",
|
||||
"@visactor/react-vchart": "^2.0.4",
|
||||
"@visactor/vchart": "^2.0.4",
|
||||
"@visactor/vchart-semi-theme": "^1.12.2",
|
||||
"@vitejs/plugin-react": "5.0.2",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"body-parser": "2.2.0",
|
||||
"cheerio": "^1.1.2",
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.8",
|
||||
"highcharts": "12.3.0",
|
||||
"highcharts-react-official": "3.2.2",
|
||||
"lodash": "4.17.21",
|
||||
"lowdb": "7.0.1",
|
||||
"markdown": "^0.5.0",
|
||||
"mixpanel": "^0.18.1",
|
||||
"nanoid": "5.1.5",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.9",
|
||||
"p-throttle": "^8.0.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.17.1",
|
||||
"puppeteer": "^24.19.0",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
||||
"query-string": "9.2.2",
|
||||
"query-string": "9.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-redux": "9.2.0",
|
||||
"react-router": "5.2.1",
|
||||
"react-router-dom": "5.3.0",
|
||||
"react-router": "7.8.2",
|
||||
"react-router-dom": "7.8.2",
|
||||
"redux": "5.0.1",
|
||||
"redux-thunk": "3.1.0",
|
||||
"restana": "5.1.0",
|
||||
"serve-static": "2.2.0",
|
||||
"slack": "11.0.2",
|
||||
"string-similarity": "^4.0.4",
|
||||
"vite": "7.1.3",
|
||||
"vite": "7.1.5",
|
||||
"x-var": "^2.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.28.3",
|
||||
"@babel/eslint-parser": "7.28.0",
|
||||
"@babel/core": "7.28.4",
|
||||
"@babel/eslint-parser": "7.28.4",
|
||||
"@babel/preset-env": "7.28.3",
|
||||
"@babel/preset-react": "7.27.1",
|
||||
"chai": "6.0.1",
|
||||
"eslint": "9.34.0",
|
||||
"eslint": "9.35.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"esmock": "2.7.1",
|
||||
"esmock": "2.7.2",
|
||||
"history": "5.3.0",
|
||||
"husky": "9.1.7",
|
||||
"less": "4.4.1",
|
||||
"lint-staged": "16.1.5",
|
||||
"mocha": "11.7.1",
|
||||
"lint-staged": "16.1.6",
|
||||
"mocha": "11.7.2",
|
||||
"nodemon": "^3.1.10",
|
||||
"prettier": "3.6.2",
|
||||
"redux-logger": "3.0.6"
|
||||
|
||||
4
test/esmock-loader.mjs
Normal file
4
test/esmock-loader.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
import { register } from 'node:module';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
register('esmock', pathToFileURL('./'));
|
||||
@@ -1,40 +1,30 @@
|
||||
import SimilarityCacheEntry from '../../lib/services/similarity-check/SimilarityCacheEntry.js';
|
||||
import { expect } from 'chai';
|
||||
import * as similarityCache from '../../lib/services/similarity-check/similarityCache.js';
|
||||
|
||||
describe('similarityCheck', () => {
|
||||
describe('#similarityCheck()', () => {
|
||||
it('should be false', () => {
|
||||
const check = new SimilarityCacheEntry(0);
|
||||
check.setCacheEntry('Hallo');
|
||||
expect(check.hasSimilarEntries('Welt')).to.be.false;
|
||||
});
|
||||
it('should be true', () => {
|
||||
const check = new SimilarityCacheEntry(0);
|
||||
check.setCacheEntry('Hallo');
|
||||
expect(check.hasSimilarEntries('hallo')).to.be.true;
|
||||
});
|
||||
it('should be true', () => {
|
||||
const check = new SimilarityCacheEntry(0);
|
||||
check.setCacheEntry('Selling an incredible house in san francisco');
|
||||
expect(check.hasSimilarEntries('incredible house in san francisco for sale')).to.be.true;
|
||||
});
|
||||
it('should be true', () => {
|
||||
const check = new SimilarityCacheEntry(0);
|
||||
check.setCacheEntry('a');
|
||||
check.setCacheEntry('b');
|
||||
check.setCacheEntry('c');
|
||||
check.setCacheEntry('d');
|
||||
expect(check.hasSimilarEntries('b')).to.be.true;
|
||||
});
|
||||
it('should be false', () => {
|
||||
const check = new SimilarityCacheEntry(0);
|
||||
check.setCacheEntry(
|
||||
'The index is known by several other names, especially Sørensen–Dice index,[3] Sørensen index and Dice\'s coefficient. Other variations include the "similarity coefficient" or "index", such as Dice similarity coefficient (DSC). Common alternate spellings for Sørensen are Sorenson, Soerenson and Sörenson, and all three can also be seen with the –sen ending.',
|
||||
);
|
||||
check.setCacheEntry(
|
||||
'where |X| and |Y| are the cardinalities of the two sets (i.e. the number of elements in each set). The Sørensen index equals twice the number of elements common to both sets divided by the sum of the number of elements in each set.',
|
||||
);
|
||||
expect(check.hasSimilarEntries('unrelated text')).to.be.false;
|
||||
});
|
||||
it('should return true on duplicate', () => {
|
||||
similarityCache.addCacheEntry('Hello World', 'Test');
|
||||
expect(similarityCache.hasSimilarEntries('Hello World', 'Test')).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true even if one value is null', () => {
|
||||
similarityCache.addCacheEntry('Hello World', null);
|
||||
expect(similarityCache.hasSimilarEntries('Hello World', null)).to.be.true;
|
||||
});
|
||||
|
||||
it('should return true even if one value is an obj', () => {
|
||||
similarityCache.addCacheEntry('Hello World', [{ TR: 'OLOLO' }]);
|
||||
expect(similarityCache.hasSimilarEntries('Hello World', [{ TR: 'OLOLO' }])).to.be.true;
|
||||
});
|
||||
|
||||
it('should return false when no duplicate', () => {
|
||||
similarityCache.addCacheEntry('Hello World__', 'Test');
|
||||
expect(similarityCache.hasSimilarEntries('Hello World___', 'Test')).to.be.false;
|
||||
});
|
||||
|
||||
it('should return false when no duplicate', () => {
|
||||
expect(similarityCache.hasSimilarEntries('Hello World', 'Test')).to.be.true;
|
||||
similarityCache.invalidateAllForTest();
|
||||
expect(similarityCache.hasSimilarEntries('Hello World', 'Test')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,14 +7,13 @@ import JobMutation from './views/jobs/mutation/JobMutation';
|
||||
import UserMutator from './views/user/mutation/UserMutator';
|
||||
import JobInsight from './views/jobs/insights/JobInsight.jsx';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Switch, Redirect } from 'react-router-dom';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Logout from './components/logout/Logout';
|
||||
import Logo from './components/logo/Logo';
|
||||
import Menu from './components/menu/Menu';
|
||||
import Login from './views/login/Login';
|
||||
import Users from './views/user/Users';
|
||||
import Jobs from './views/jobs/Jobs';
|
||||
import { Route } from 'react-router';
|
||||
|
||||
import './App.less';
|
||||
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||
@@ -49,10 +48,10 @@ export default function FredyApp() {
|
||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||
|
||||
const login = () => (
|
||||
<Switch>
|
||||
<Route name="Login" path={'/login'} component={Login} />
|
||||
<Redirect from="*" to={'/login'} />
|
||||
</Switch>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
return loading ? null : needsLogin() ? (
|
||||
@@ -77,34 +76,49 @@ export default function FredyApp() {
|
||||
</>
|
||||
)}
|
||||
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
|
||||
<Switch>
|
||||
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
|
||||
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
|
||||
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
|
||||
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
|
||||
<Route name="Job overview" path={'/jobs'} component={Jobs} />
|
||||
<PermissionAwareRoute
|
||||
name="Create new User"
|
||||
<Routes>
|
||||
<Route path="/403" element={<InsufficientPermission />} />
|
||||
<Route path="/jobs/new" element={<JobMutation />} />
|
||||
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
|
||||
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
|
||||
{/* Permission-aware routes */}
|
||||
<Route
|
||||
path="/users/new"
|
||||
component={<UserMutator />}
|
||||
currentUser={currentUser}
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<PermissionAwareRoute
|
||||
name="Edit a user"
|
||||
<Route
|
||||
path="/users/edit/:userId"
|
||||
component={<UserMutator />}
|
||||
currentUser={currentUser}
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<UserMutator />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
|
||||
<PermissionAwareRoute
|
||||
name="General Settings"
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<Users />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/generalSettings"
|
||||
component={<GeneralSettings />}
|
||||
currentUser={currentUser}
|
||||
element={
|
||||
<PermissionAwareRoute currentUser={currentUser}>
|
||||
<GeneralSettings />
|
||||
</PermissionAwareRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
<Redirect from="/" to={'/jobs'} />
|
||||
</Switch>
|
||||
<Route path="/" element={<Navigate to="/jobs" replace />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,23 +2,24 @@ import React from 'react';
|
||||
|
||||
import { reduxStore } from './services/rematch/store';
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { createHashHistory } from 'history';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US';
|
||||
import { LocaleProvider } from '@douyinfe/semi-ui';
|
||||
import { initVChartSemiTheme } from '@visactor/vchart-semi-theme';
|
||||
import App from './App';
|
||||
import './Index.less';
|
||||
|
||||
const container = document.getElementById('fredy');
|
||||
const root = createRoot(container);
|
||||
const history = createHashHistory();
|
||||
|
||||
import App from './App';
|
||||
|
||||
import './Index.less';
|
||||
initVChartSemiTheme({
|
||||
defaultMode: 'dark',
|
||||
});
|
||||
|
||||
root.render(
|
||||
<Provider store={reduxStore}>
|
||||
<HashRouter history={history}>
|
||||
<HashRouter>
|
||||
<LocaleProvider locale={en_US}>
|
||||
<App />
|
||||
</LocaleProvider>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tabs, TabPane } from '@douyinfe/semi-ui';
|
||||
|
||||
import { useLocation } from 'react-router';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { IconUser, IconTerminal, IconSetting } from '@douyinfe/semi-icons';
|
||||
import './Menu.less';
|
||||
|
||||
@@ -12,15 +12,10 @@ function parsePathName(name) {
|
||||
}
|
||||
|
||||
const TopMenu = function TopMenu({ isAdmin }) {
|
||||
const history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return (
|
||||
<Tabs
|
||||
className="menu"
|
||||
type="line"
|
||||
activeKey={parsePathName(location.pathname)}
|
||||
onTabClick={(key) => history.push(key)}
|
||||
>
|
||||
<Tabs className="menu" type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => navigate(key)}>
|
||||
<TabPane
|
||||
itemKey="/jobs"
|
||||
tab={
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { Route } from 'react-router';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
export default function PermissionAwareRoute({ currentUser, name, path, component }) {
|
||||
/**
|
||||
* Checks if given component should be rendered if current user has given permission enabled. If that's not the case,
|
||||
* user is redirected to '/403'.
|
||||
*
|
||||
* @param permission
|
||||
* @param component
|
||||
* @param path
|
||||
* @returns {*}
|
||||
*/
|
||||
const checkIfAdmin = (component, path) => {
|
||||
return currentUser != null && currentUser.isAdmin ? component : <Redirect from={path} to="/403" />;
|
||||
};
|
||||
|
||||
return <Route name={name} path={path} render={() => checkIfAdmin(component, path)} />;
|
||||
export default function PermissionAwareRoute({ currentUser, children }) {
|
||||
const isAdmin = currentUser != null && currentUser.isAdmin;
|
||||
return isAdmin ? children : <Navigate to="/403" replace />;
|
||||
}
|
||||
|
||||
@@ -43,7 +43,8 @@ export default function TrackingModal() {
|
||||
</p>
|
||||
<p>
|
||||
However, it would be a huge help if you’d allow me to collect some analytical data. Wait, before you click
|
||||
"no", let me explain. If you agree, Fredy will send a ping to my Mixpanel project each time it runs.
|
||||
"no", let me explain. If you agree, Fredy will send a ping once every 6 hours to my internal tracking project.
|
||||
(Will be open-sourced soon)
|
||||
</p>
|
||||
<p>
|
||||
The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The
|
||||
|
||||
@@ -8,4 +8,4 @@ export function format(ts) {
|
||||
second: 'numeric',
|
||||
}).format(ts);
|
||||
}
|
||||
export const roundToNext5Minute = (ts) => Math.ceil(ts / (1000 * 60 * 5)) * (1000 * 60 * 5);
|
||||
export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60);
|
||||
|
||||
@@ -121,11 +121,11 @@ const GeneralSettings = function GeneralSettings() {
|
||||
<div>
|
||||
<SegmentPart
|
||||
name="Interval"
|
||||
helpText="Interval in minutes for running queries against the configured services."
|
||||
helpText="Interval in minutes for running queries against the configured services. Do NOT go under 5 minutes as with a lower interval, your instance might be detected as a bot."
|
||||
Icon={IconRefresh}
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
min={5}
|
||||
max={1440}
|
||||
placeholder="Interval in minutes"
|
||||
value={interval}
|
||||
|
||||
@@ -3,7 +3,7 @@ import React from 'react';
|
||||
import JobTable from '../../components/table/JobTable';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { xhrDelete, xhrPut } from '../../services/xhr';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ProcessingTimes from './ProcessingTimes';
|
||||
import { Button, Toast } from '@douyinfe/semi-ui';
|
||||
import { IconPlusCircle } from '@douyinfe/semi-icons';
|
||||
@@ -12,7 +12,7 @@ import './Jobs.less';
|
||||
export default function Jobs() {
|
||||
const jobs = useSelector((state) => state.jobs.jobs);
|
||||
const processingTimes = useSelector((state) => state.jobs.processingTimes);
|
||||
const history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const onJobRemoval = async (jobId) => {
|
||||
@@ -43,7 +43,7 @@ export default function Jobs() {
|
||||
type="primary"
|
||||
icon={<IconPlusCircle />}
|
||||
className="jobs__newButton"
|
||||
onClick={() => history.push('/jobs/new')}
|
||||
onClick={() => navigate('/jobs/new')}
|
||||
>
|
||||
New Job
|
||||
</Button>
|
||||
@@ -53,8 +53,8 @@ export default function Jobs() {
|
||||
jobs={jobs || []}
|
||||
onJobRemoval={onJobRemoval}
|
||||
onJobStatusChanged={onJobStatusChanged}
|
||||
onJobInsight={(jobId) => history.push(`/jobs/insights/${jobId}`)}
|
||||
onJobEdit={(jobId) => history.push(`/jobs/edit/${jobId}`)}
|
||||
onJobInsight={(jobId) => navigate(`/jobs/insights/${jobId}`)}
|
||||
onJobEdit={(jobId) => navigate(`/jobs/edit/${jobId}`)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
import { roundToNext5Minute } from '../../../services/time/timeService';
|
||||
import { roundToHour } from '../../../services/time/timeService';
|
||||
import Headline from '../../../components/headline/Headline';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import Linechart from './Linechart';
|
||||
|
||||
const JobInsight = function JobInsight() {
|
||||
@@ -20,27 +20,47 @@ const JobInsight = function JobInsight() {
|
||||
|
||||
const getData = () => {
|
||||
const data = insights[params.jobId] || {};
|
||||
const providers = Object.keys(data);
|
||||
|
||||
const result = [];
|
||||
Object.keys(data).forEach((key) => {
|
||||
const series = {
|
||||
name: key[0].toUpperCase() + key.substring(1),
|
||||
data: [],
|
||||
};
|
||||
const countsByProvider = {};
|
||||
const allTimes = new Set();
|
||||
|
||||
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : 'Unknown');
|
||||
|
||||
providers.forEach((key) => {
|
||||
const providerName = cap(key);
|
||||
const tmpTimeObj = {};
|
||||
|
||||
Object.values(data[key] || {}).forEach((listingTs) => {
|
||||
const time = roundToNext5Minute(listingTs);
|
||||
const time = roundToHour(listingTs);
|
||||
tmpTimeObj[time] = tmpTimeObj[time] == null ? 1 : tmpTimeObj[time] + 1;
|
||||
allTimes.add(time);
|
||||
});
|
||||
|
||||
Object.keys(tmpTimeObj)
|
||||
.sort()
|
||||
.forEach((timeKey) => {
|
||||
series.data.push([parseInt(timeKey), tmpTimeObj[timeKey]]);
|
||||
countsByProvider[providerName] = tmpTimeObj;
|
||||
});
|
||||
|
||||
const sortedTimes = Array.from(allTimes).sort((a, b) => a - b);
|
||||
|
||||
const result = [];
|
||||
providers.forEach((key) => {
|
||||
const providerName = cap(key);
|
||||
const bucket = countsByProvider[providerName] || {};
|
||||
|
||||
sortedTimes.forEach((t) => {
|
||||
result.push({
|
||||
listings: new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(parseInt(t))),
|
||||
listingsNumber: bucket[t] || 0, // y value
|
||||
provider: providerName, // series key
|
||||
});
|
||||
result.push(series);
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
@@ -1,337 +1,49 @@
|
||||
import React from 'react';
|
||||
|
||||
import Placeholder from '../../../components/placeholder/Placeholder';
|
||||
import HighchartsReact from 'highcharts-react-official';
|
||||
import Highcharts from 'highcharts/highcharts.src.js';
|
||||
import { VChart } from '@visactor/react-vchart';
|
||||
|
||||
import './Linechart.less';
|
||||
|
||||
Highcharts.theme = {
|
||||
colors: [
|
||||
'#2b908f',
|
||||
'#90ee7e',
|
||||
'#f45b5b',
|
||||
'#7798BF',
|
||||
'#aaeeee',
|
||||
'#ff0066',
|
||||
'#eeaaee',
|
||||
'#55BF3B',
|
||||
'#DF5353',
|
||||
'#7798BF',
|
||||
'#aaeeee',
|
||||
const commonSpec = {
|
||||
type: 'line',
|
||||
xField: 'listings',
|
||||
yField: 'listingsNumber',
|
||||
seriesField: 'provider',
|
||||
legends: { visible: true },
|
||||
line: {
|
||||
style: {
|
||||
lineWidth: 2,
|
||||
},
|
||||
},
|
||||
point: {
|
||||
visible: false,
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
field: 'listings',
|
||||
zero: false,
|
||||
},
|
||||
],
|
||||
chart: {
|
||||
backgroundColor: {
|
||||
linearGradient: {
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 1,
|
||||
y2: 1,
|
||||
},
|
||||
stops: [
|
||||
[0, '#2a2a2b'],
|
||||
[1, '#3e3e40'],
|
||||
],
|
||||
},
|
||||
style: {
|
||||
fontFamily: "'Unica One', sans-serif",
|
||||
},
|
||||
plotBorderColor: '#606063',
|
||||
},
|
||||
title: {
|
||||
style: {
|
||||
color: '#E0E0E3',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '20px',
|
||||
},
|
||||
},
|
||||
subtitle: {
|
||||
style: {
|
||||
color: '#E0E0E3',
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
gridLineColor: '#707073',
|
||||
labels: {
|
||||
style: {
|
||||
color: '#E0E0E3',
|
||||
},
|
||||
},
|
||||
lineColor: '#707073',
|
||||
minorGridLineColor: '#505053',
|
||||
tickColor: '#707073',
|
||||
title: {
|
||||
style: {
|
||||
color: '#A0A0A3',
|
||||
},
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
gridLineColor: '#707073',
|
||||
labels: {
|
||||
style: {
|
||||
color: '#E0E0E3',
|
||||
},
|
||||
},
|
||||
lineColor: '#707073',
|
||||
minorGridLineColor: '#505053',
|
||||
tickColor: '#707073',
|
||||
tickWidth: 1,
|
||||
title: {
|
||||
style: {
|
||||
color: '#A0A0A3',
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
style: {
|
||||
color: '#F0F0F0',
|
||||
},
|
||||
},
|
||||
plotOptions: {
|
||||
series: {
|
||||
dataLabels: {
|
||||
color: '#F0F0F3',
|
||||
style: {
|
||||
fontSize: '13px',
|
||||
},
|
||||
},
|
||||
marker: {
|
||||
lineColor: '#333',
|
||||
},
|
||||
},
|
||||
boxplot: {
|
||||
fillColor: '#505053',
|
||||
},
|
||||
candlestick: {
|
||||
lineColor: 'white',
|
||||
},
|
||||
errorbar: {
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
itemStyle: {
|
||||
color: '#E0E0E3',
|
||||
},
|
||||
itemHoverStyle: {
|
||||
color: '#FFF',
|
||||
},
|
||||
itemHiddenStyle: {
|
||||
color: '#606063',
|
||||
},
|
||||
title: {
|
||||
style: {
|
||||
color: '#C0C0C0',
|
||||
},
|
||||
},
|
||||
},
|
||||
credits: {
|
||||
style: {
|
||||
color: '#666',
|
||||
},
|
||||
},
|
||||
labels: {
|
||||
style: {
|
||||
color: '#707073',
|
||||
},
|
||||
},
|
||||
|
||||
drilldown: {
|
||||
activeAxisLabelStyle: {
|
||||
color: '#F0F0F3',
|
||||
},
|
||||
activeDataLabelStyle: {
|
||||
color: '#F0F0F3',
|
||||
},
|
||||
},
|
||||
|
||||
navigation: {
|
||||
buttonOptions: {
|
||||
symbolStroke: '#DDDDDD',
|
||||
theme: {
|
||||
fill: '#505053',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// scroll charts
|
||||
rangeSelector: {
|
||||
buttonTheme: {
|
||||
fill: '#505053',
|
||||
stroke: '#000000',
|
||||
style: {
|
||||
color: '#CCC',
|
||||
},
|
||||
states: {
|
||||
hover: {
|
||||
fill: '#707073',
|
||||
stroke: '#000000',
|
||||
style: {
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
select: {
|
||||
fill: '#000003',
|
||||
stroke: '#000000',
|
||||
style: {
|
||||
color: 'white',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
inputBoxBorderColor: '#505053',
|
||||
inputStyle: {
|
||||
backgroundColor: '#333',
|
||||
color: 'silver',
|
||||
},
|
||||
labelStyle: {
|
||||
color: 'silver',
|
||||
},
|
||||
},
|
||||
|
||||
navigator: {
|
||||
handles: {
|
||||
backgroundColor: '#666',
|
||||
borderColor: '#AAA',
|
||||
},
|
||||
outlineColor: '#CCC',
|
||||
maskFill: 'rgba(255,255,255,0.1)',
|
||||
series: {
|
||||
color: '#7798BF',
|
||||
lineColor: '#A6C7ED',
|
||||
},
|
||||
xAxis: {
|
||||
gridLineColor: '#505053',
|
||||
},
|
||||
},
|
||||
|
||||
scrollbar: {
|
||||
barBackgroundColor: '#808083',
|
||||
barBorderColor: '#808083',
|
||||
buttonArrowColor: '#CCC',
|
||||
buttonBackgroundColor: '#606063',
|
||||
buttonBorderColor: '#606063',
|
||||
rifleColor: '#FFF',
|
||||
trackBackgroundColor: '#404043',
|
||||
trackBorderColor: '#404043',
|
||||
},
|
||||
};
|
||||
|
||||
// Apply the theme
|
||||
Highcharts.setOptions(Highcharts.theme);
|
||||
|
||||
const defaultOptions = {
|
||||
title: {
|
||||
text: null,
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
},
|
||||
xAxis: {
|
||||
//most of the time (if not everytime), the x axis is time
|
||||
type: 'datetime',
|
||||
crosshair: {
|
||||
snap: false,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
title: {
|
||||
text: null,
|
||||
},
|
||||
//do not show float numbers
|
||||
allowDecimals: false,
|
||||
},
|
||||
chart: {
|
||||
type: 'line',
|
||||
zoomType: 'x',
|
||||
plotBackgroundColor: null,
|
||||
plotBorderWidth: null,
|
||||
},
|
||||
exporting: {
|
||||
enabled: false,
|
||||
},
|
||||
tooltip: {
|
||||
shared: true,
|
||||
formatter: null,
|
||||
},
|
||||
plotOptions: {
|
||||
line: {
|
||||
animation: false,
|
||||
marker: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
series: {
|
||||
lineWidth: 1.5,
|
||||
connectNulls: true,
|
||||
marker: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
series: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Usage of this chart:
|
||||
* title: optional (show a title, if null, no title is shown)
|
||||
* zoom: optional (if true, zooming in x axis is possible)
|
||||
* legend: optional (show / hide the legend)
|
||||
* series: mandatory (an array of data to be shown)
|
||||
*
|
||||
* <Linechart
|
||||
* title="something"
|
||||
* legend={true}
|
||||
* timeframe={week/month/all} --> If this is not set, we assume the timeframe is 'all'
|
||||
* //everything that is "subscribed" to this topic will receive this update
|
||||
* highlightTopic="someTopic"
|
||||
* height={"500px"}
|
||||
* zoom={true}
|
||||
* series={[
|
||||
* {
|
||||
* name: 'something',
|
||||
* data: [x,y],
|
||||
* dashStyle: (OPTIONAL) | solid / 'shortdot'
|
||||
* }
|
||||
* ]}
|
||||
* />
|
||||
*/
|
||||
const Linechart = function Linechart({ title, series, height, isLoading = false }) {
|
||||
const options = () => {
|
||||
return {
|
||||
...defaultOptions,
|
||||
title: {
|
||||
text: title,
|
||||
},
|
||||
time: {
|
||||
useUTC: false,
|
||||
},
|
||||
legend: {
|
||||
enabled: true,
|
||||
},
|
||||
series: series.map((series) => {
|
||||
return {
|
||||
...series,
|
||||
};
|
||||
}),
|
||||
chart: {
|
||||
type: 'line',
|
||||
zoomType: 'x',
|
||||
height: height || '400px',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const Linechart = function Linechart({ title, series, isLoading = false }) {
|
||||
return (
|
||||
<Placeholder ready={!isLoading} rows={6}>
|
||||
{series == null || series.length === 0 ? (
|
||||
<div className="linechart__no__data">No Data for selected timeframe :-/</div>
|
||||
) : (
|
||||
<HighchartsReact highcharts={Highcharts} options={options()} />
|
||||
<VChart
|
||||
spec={{
|
||||
...commonSpec,
|
||||
title: {
|
||||
visible: true,
|
||||
text: title,
|
||||
},
|
||||
data: { values: series },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Placeholder>
|
||||
);
|
||||
|
||||
@@ -7,8 +7,7 @@ import ProviderMutator from './components/provider/ProviderMutator';
|
||||
import Headline from '../../../components/headline/Headline';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { xhrPost } from '../../../services/xhr';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useParams } from 'react-router';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Divider, Input, Switch, Button, TagInput, Toast } from '@douyinfe/semi-ui';
|
||||
import './JobMutation.less';
|
||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||
@@ -34,7 +33,7 @@ export default function JobMutator() {
|
||||
const [blacklist, setBlacklist] = useState(defaultBlacklist);
|
||||
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
|
||||
const [enabled, setEnabled] = useState(defaultEnabled);
|
||||
const history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isSavingEnabled = () => {
|
||||
@@ -53,7 +52,7 @@ export default function JobMutator() {
|
||||
});
|
||||
await dispatch.jobs.getJobs();
|
||||
Toast.success('Job successfully saved...');
|
||||
history.push('/jobs');
|
||||
navigate('/jobs');
|
||||
} catch (Exception) {
|
||||
console.error(Exception.json.message);
|
||||
Toast.error(Exception.json != null ? Exception.json.message : Exception);
|
||||
@@ -177,7 +176,7 @@ export default function JobMutator() {
|
||||
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => history.push('/jobs')}>
|
||||
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/jobs')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" icon={<IconPlusCircle />} disabled={!isSavingEnabled()} onClick={mutateJob}>
|
||||
|
||||
@@ -30,7 +30,8 @@ export default function ProviderMutator({ onVisibilityChanged, visible = false,
|
||||
if (selectedProvider.baseUrl.indexOf(url.origin) === -1) {
|
||||
return 'The url you have copied is not valid.';
|
||||
}
|
||||
} catch (Exception) {
|
||||
/* eslint-disable no-unused-vars */
|
||||
} catch (ignored) {
|
||||
return 'The url you have copied is not valid.';
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useEffect } from 'react';
|
||||
import cityBackground from '../../assets/city_background.jpg';
|
||||
import Logo from '../../components/logo/Logo';
|
||||
import { xhrPost } from '../../services/xhr';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Input, Button, Banner, Toast } from '@douyinfe/semi-ui';
|
||||
|
||||
@@ -16,7 +16,7 @@ export default function Login() {
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [error, setError] = React.useState(null);
|
||||
const demoMode = useSelector((state) => state.demoMode.demoMode || false);
|
||||
const history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
@@ -38,7 +38,8 @@ export default function Login() {
|
||||
username: username.trim(),
|
||||
password,
|
||||
});
|
||||
} catch (Exception) {
|
||||
/* eslint-disable no-unused-vars */
|
||||
} catch (ignored) {
|
||||
Toast.error('Login unsuccessful…');
|
||||
return;
|
||||
}
|
||||
@@ -46,7 +47,7 @@ export default function Login() {
|
||||
Toast.success('Login successful!');
|
||||
|
||||
await dispatch.user.getCurrentUser();
|
||||
history.push('/jobs');
|
||||
navigate('/jobs');
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { IconPlus } from '@douyinfe/semi-icons';
|
||||
import { Button } from '@douyinfe/semi-ui';
|
||||
import UserRemovalModal from './UserRemovalModal';
|
||||
import { xhrDelete } from '../../services/xhr';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import './Users.less';
|
||||
|
||||
@@ -16,7 +16,7 @@ const Users = function Users() {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const users = useSelector((state) => state.user.users);
|
||||
const [userIdToBeRemoved, setUserIdToBeRemoved] = React.useState(null);
|
||||
const history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
async function init() {
|
||||
@@ -50,7 +50,7 @@ const Users = function Users() {
|
||||
type="primary"
|
||||
className="users__newButton"
|
||||
icon={<IconPlus />}
|
||||
onClick={() => history.push('/users/new')}
|
||||
onClick={() => navigate('/users/new')}
|
||||
>
|
||||
Create new User
|
||||
</Button>
|
||||
@@ -58,7 +58,7 @@ const Users = function Users() {
|
||||
<UserTable
|
||||
user={users}
|
||||
onUserEdit={(userId) => {
|
||||
history.push(`/users/edit/${userId}`);
|
||||
navigate(`/users/edit/${userId}`);
|
||||
}}
|
||||
onUserRemoval={(userId) => {
|
||||
setUserIdToBeRemoved(userId);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
import { xhrGet, xhrPost } from '../../../services/xhr';
|
||||
import { useHistory, useParams } from 'react-router';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui';
|
||||
import './UserMutator.less';
|
||||
@@ -15,7 +15,7 @@ const UserMutator = function UserMutator() {
|
||||
const [password2, setPassword2] = React.useState('');
|
||||
const [isAdmin, setIsAdmin] = React.useState(false);
|
||||
|
||||
const history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -50,7 +50,7 @@ const UserMutator = function UserMutator() {
|
||||
});
|
||||
await dispatch.user.getUsers();
|
||||
Toast.success('User successfully saved...');
|
||||
history.push('/users');
|
||||
navigate('/users');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Toast.error(error.json.message);
|
||||
@@ -98,7 +98,7 @@ const UserMutator = function UserMutator() {
|
||||
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => history.push('/users')}>
|
||||
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/users')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" icon={<IconPlusCircle />} onClick={saveUser}>
|
||||
|
||||
Reference in New Issue
Block a user