mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d01a1a94d0 | ||
|
|
bda4212249 | ||
|
|
694809fedf | ||
|
|
3cd1893b51 | ||
|
|
21415dcff3 | ||
|
|
e868cdce86 | ||
|
|
d66dc2cd93 | ||
|
|
5e0405f1ec | ||
|
|
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 | ||
|
|
9db1ffd8eb |
@@ -1,3 +0,0 @@
|
||||
/ui/public
|
||||
/db/
|
||||
/conf/
|
||||
293
README.md
293
README.md
@@ -1,102 +1,185 @@
|
||||
<img src="https://github.com/orangecoding/fredy/blob/master/doc/logo.png" width="400">
|
||||
|
||||
 [](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.
|
||||
|
||||
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).
|
||||
|
||||
# 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.
|
||||
|
||||
[](https://jb.gg/OpenSourceSupport)
|
||||
|
||||
_Fredy_ is supported by JetBrains under Open Source Support Program
|
||||
|
||||
## Demo
|
||||
|
||||
If you want to try out _Fredy_, you can access the demo version [here](https://fredy.orange-coding.net) 🤘
|
||||
|
||||
## Usage
|
||||
|
||||
- Make sure to use Node.js 20 or above
|
||||
- Run the following commands:
|
||||
|
||||
```ssh
|
||||
yarn
|
||||
yarn run start:backend
|
||||
yarn run start:frontend
|
||||
```
|
||||
|
||||
_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.
|
||||
|
||||
<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">
|
||||
|
||||
<a href="https://fredy.orange-coding.net/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://github.com/orangecoding/fredy/blob/master/doc/logo_white.png" width="400">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://github.com/orangecoding/fredy/blob/master/doc/logo.png" width="400">
|
||||
<img alt="Jetbrains Open Source" src="https://github.com/orangecoding/fredy/blob/master/doc/logo.png">
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Understanding the fundamentals
|
||||

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

|
||||

|
||||
|
||||
There are 3 important parts in Fredy, that you need to understand to leverage the full power of _Fredy_.
|
||||
|
||||
#### Provider
|
||||
# Fredy 🏡 – Your Self-Hosted Real Estate Finder for Germany
|
||||
|
||||
_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!**
|
||||
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.
|
||||
|
||||
#### Adapter
|
||||
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.
|
||||
|
||||
_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.
|
||||
|
||||
#### Jobs
|
||||
|
||||
A Job wraps adapters and providers. _Fredy_ runs the configured jobs in a specific interval (can be configured in `/conf/config.json`).
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## Creating your first job
|
||||
## ✨ Key Features
|
||||
|
||||
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.
|
||||
- 🏠 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
|
||||
|
||||
## User management
|
||||
------------------------------------------------------------------------
|
||||
|
||||
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.
|
||||
## 🤝 Sponsorship [](https://github.com/sponsors/orangecoding)
|
||||
|
||||
# Development
|
||||
I maintain Fredy and other open-source projects in my free time.\
|
||||
If you find it useful, consider supporting the project 💙
|
||||
|
||||
### Running Fredy in development mode
|
||||
Fredy is proudly backed by the **JetBrains Open Source Support Program**.
|
||||
|
||||
Start the backend with:
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://www.jetbrains.com/company/brand/img/logo_jb_dos_3.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg">
|
||||
<img alt="Jetbrains Open Source" src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg">
|
||||
</picture>
|
||||
|
||||
```shell
|
||||
yarn run start:backend:dev
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 👨🏫 Demo
|
||||
You can try out Fredy here: [Fredy Demo](https://fredy-demo.orange-coding.net/)
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 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
|
||||
```
|
||||
|
||||
For the frontend, run:
|
||||
Logs:
|
||||
|
||||
```shell
|
||||
``` bash
|
||||
docker logs fredy -f
|
||||
```
|
||||
|
||||
### Manual (Node.js)
|
||||
|
||||
- Requirement: **Node.js 20 or higher**
|
||||
- Install dependencies and start:
|
||||
|
||||
``` bash
|
||||
yarn
|
||||
yarn run start:backend # in one terminal
|
||||
yarn run start:frontend # in another terminal
|
||||
```
|
||||
|
||||
👉 Open <http://localhost:9998>
|
||||
|
||||
**Default Login:**
|
||||
- Username: `admin`
|
||||
- Password: `admin`
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
| Job Configuration | Job Analytics | Job Overview |
|
||||
|-------------------|--------------|--------------|
|
||||
|  |  |  |
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 🧩 Core Concepts
|
||||
|
||||
Fredy is built around three simple concepts:
|
||||
|
||||
### Provider 🌐
|
||||
|
||||
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.
|
||||
|
||||
### Adapter 📡
|
||||
|
||||
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.
|
||||
|
||||
### Job 📅
|
||||
|
||||
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`).
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 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)
|
||||
|
||||
## 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
|
||||
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 +192,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' } },
|
||||
},
|
||||
];
|
||||
|
||||
15
index.js
15
index.js
@@ -6,9 +6,10 @@ import * as jobStorage from './lib/services/storage/jobStorage.js';
|
||||
import FredyRuntime from './lib/FredyRuntime.js';
|
||||
import { duringWorkingHoursOrNotSet } from './lib/utils.js';
|
||||
import './lib/api/api.js';
|
||||
import { track } from './lib/services/tracking/Tracker.js';
|
||||
import { handleDemoUser } from './lib/services/storage/userStorage.js';
|
||||
import { cleanupDemoAtMidnight } from './lib/services/demoCleanup.js';
|
||||
import { initTrackerCron } from './lib/services/tracking/Tracker-Cron.js';
|
||||
import logger from './lib/services/logger.js';
|
||||
//if db folder does not exist, ensure to create it before loading anything else
|
||||
if (!fs.existsSync('./db')) {
|
||||
fs.mkdirSync('./db');
|
||||
@@ -17,25 +18,23 @@ const path = './lib/provider';
|
||||
const provider = fs.readdirSync(path).filter((file) => file.endsWith('.js'));
|
||||
//assuming interval is always in minutes
|
||||
const INTERVAL = config.interval * 60 * 1000;
|
||||
/* eslint-disable no-console */
|
||||
console.log(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
||||
logger.info(`Started Fredy successfully. Ui can be accessed via http://localhost:${config.port}`);
|
||||
if (config.demoMode) {
|
||||
console.info('Running in demo mode');
|
||||
logger.info('Running in demo mode');
|
||||
cleanupDemoAtMidnight();
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
const fetchedProvider = await Promise.all(
|
||||
provider.filter((provider) => provider.endsWith('.js')).map(async (pro) => import(`${path}/${pro}`)),
|
||||
);
|
||||
|
||||
handleDemoUser();
|
||||
await initTrackerCron();
|
||||
|
||||
setInterval(
|
||||
(function exec() {
|
||||
const isDuringWorkingHoursOrNotSet = duringWorkingHoursOrNotSet(config, Date.now());
|
||||
if (!config.demoMode) {
|
||||
if (isDuringWorkingHoursOrNotSet) {
|
||||
track();
|
||||
config.lastRun = Date.now();
|
||||
jobStorage
|
||||
.getJobs()
|
||||
@@ -51,9 +50,7 @@ setInterval(
|
||||
});
|
||||
});
|
||||
} else {
|
||||
/* eslint-disable no-console */
|
||||
console.debug('Working hours set. Skipping as outside of working hours.');
|
||||
/* eslint-enable no-console */
|
||||
logger.debug('Working hours set. Skipping as outside of working hours.');
|
||||
}
|
||||
}
|
||||
return exec;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { setKnownListings, getKnownListings } from './services/storage/listingsS
|
||||
import * as notify from './notification/notify.js';
|
||||
import Extractor from './services/extractor/extractor.js';
|
||||
import urlModifier from './services/queryStringMutator.js';
|
||||
import logger from './services/logger.js';
|
||||
|
||||
class FredyRuntime {
|
||||
/**
|
||||
@@ -59,7 +60,7 @@ class FredyRuntime {
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
console.error(err);
|
||||
logger.error(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -102,20 +103,18 @@ 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);
|
||||
/* eslint-enable no-console */
|
||||
logger.debug(`Filtering similar entry for title: ${listing.title} and address ${listing.address}`);
|
||||
}
|
||||
return !similar;
|
||||
});
|
||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(this._jobKey, filter.title));
|
||||
filteredList.forEach((filter) => this._similarityCache.addCacheEntry(filter.title, listings.address));
|
||||
return filteredList;
|
||||
}
|
||||
|
||||
_handleError(err) {
|
||||
if (err.name !== 'NoNewListingsWarning') console.error(err);
|
||||
if (err.name !== 'NoNewListingsWarning') logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import files from 'serve-static';
|
||||
import path from 'path';
|
||||
import { getDirName } from '../utils.js';
|
||||
import { demoRouter } from './routes/demoRouter.js';
|
||||
import logger from '../services/logger.js';
|
||||
const service = restana();
|
||||
const staticService = files(path.join(getDirName(), '../ui/public'));
|
||||
const PORT = config.port || 9998;
|
||||
@@ -34,7 +35,6 @@ service.use('/api/login', loginRouter);
|
||||
//this route is unsecured intentionally as it is being queried from the login page
|
||||
service.use('/api/demo', demoRouter);
|
||||
|
||||
/* eslint-disable no-console */
|
||||
service.start(PORT).then(() => {
|
||||
console.info(`Started API service on port ${PORT}`);
|
||||
logger.debug(`Started API service on port ${PORT}`);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import restana from 'restana';
|
||||
import { config, getDirName, readConfigFromStorage, refreshConfig } from '../../utils.js';
|
||||
import fs from 'fs';
|
||||
import { handleDemoUser } from '../../services/storage/userStorage.js';
|
||||
import logger from '../../services/logger.js';
|
||||
const service = restana();
|
||||
const generalSettingsRouter = service.newRouter();
|
||||
generalSettingsRouter.get('/', async (req, res) => {
|
||||
@@ -20,7 +21,7 @@ generalSettingsRouter.post('/', async (req, res) => {
|
||||
await refreshConfig();
|
||||
handleDemoUser();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
logger.error(err);
|
||||
res.send(new Error('Error while trying to write settings.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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';
|
||||
import logger from '../../services/logger.js';
|
||||
const service = restana();
|
||||
const jobRouter = service.newRouter();
|
||||
function doesJobBelongsToUser(job, req) {
|
||||
@@ -44,13 +44,8 @@ jobRouter.post('/', async (req, res) => {
|
||||
});
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
trackDemoJobCreated({
|
||||
name,
|
||||
provider,
|
||||
adapter: notificationAdapter,
|
||||
});
|
||||
res.send();
|
||||
});
|
||||
jobRouter.delete('', async (req, res) => {
|
||||
@@ -64,7 +59,7 @@ jobRouter.delete('', async (req, res) => {
|
||||
}
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
res.send();
|
||||
});
|
||||
@@ -83,7 +78,7 @@ jobRouter.put('/:jobId/status', async (req, res) => {
|
||||
}
|
||||
} catch (error) {
|
||||
res.send(new Error(error));
|
||||
console.error(error);
|
||||
logger.error(error);
|
||||
}
|
||||
res.send();
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as userStorage from '../../services/storage/userStorage.js';
|
||||
import * as hasher from '../../services/security/hash.js';
|
||||
import { config } from '../../utils.js';
|
||||
import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
|
||||
import logger from '../../services/logger.js';
|
||||
const service = restana();
|
||||
const loginRouter = service.newRouter();
|
||||
loginRouter.get('/user', async (req, res) => {
|
||||
@@ -27,7 +28,7 @@ loginRouter.post('/', async (req, res) => {
|
||||
}
|
||||
if (user.password === hasher.hash(password)) {
|
||||
if (config.demoMode) {
|
||||
trackDemoAccessed();
|
||||
await trackDemoAccessed();
|
||||
}
|
||||
|
||||
req.session.currentUser = user.id;
|
||||
@@ -35,7 +36,7 @@ loginRouter.post('/', async (req, res) => {
|
||||
res.send(200);
|
||||
return;
|
||||
} else {
|
||||
console.error(`User ${username} tried to login, but password was wrong.`);
|
||||
logger.error(`User ${username} tried to login, but password was wrong.`);
|
||||
}
|
||||
res.send(401);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import Handlebars from 'handlebars';
|
||||
import fetch from 'node-fetch';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getDirName, normalizeImageUrl } from '../../utils.js';
|
||||
import logger from '../../services/logger.js';
|
||||
|
||||
const __dirname = getDirName();
|
||||
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||
@@ -24,7 +25,7 @@ const toBase64 = async (url) => {
|
||||
const ab = await res.arrayBuffer();
|
||||
return Buffer.from(ab).toString('base64');
|
||||
} catch (error) {
|
||||
console.error(`Error fetching image from ${url}:`, error.message);
|
||||
logger.error(`Error fetching image from ${url}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -62,7 +63,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
||||
item.hasImage = true;
|
||||
item.imageCid = cid;
|
||||
} catch (error) {
|
||||
console.warn(`Skipping image for listing ${i} due to error: ${error.message}`);
|
||||
logger.warn(`Skipping image for listing ${i} due to error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -11,7 +11,7 @@ let appliedBlackList = [];
|
||||
function normalize(o) {
|
||||
const size = o.size != null ? o.size.replace('Wohnfläche ', '') : 'N/A m²';
|
||||
const price = o.price.replace('Kaufpreis ', '');
|
||||
const address = o.address.split(' • ')[o.address.split(' • ').length - 1];
|
||||
const address = o.address?.split(' • ')?.pop() ?? null;
|
||||
const title = o.title || 'No title available';
|
||||
const link = config.url;
|
||||
const id = buildHash(title, price);
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
|
||||
import utils, { buildHash } from '../utils.js';
|
||||
import { convertWebToMobile } from '../services/immoscout/immoscout-web-translator.js';
|
||||
import logger from '../services/logger.js';
|
||||
let appliedBlackList = [];
|
||||
|
||||
async function getListings(url) {
|
||||
@@ -52,7 +53,7 @@ async function getListings(url) {
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error('Error fetching data from ImmoScout Mobile API:', response.statusText);
|
||||
logger.error('Error fetching data from ImmoScout Mobile API:', response.statusText);
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { setInterval } from 'node:timers';
|
||||
import { removeJobsByUserName } from './storage/jobStorage.js';
|
||||
import { config } from '../utils.js';
|
||||
import { getUsers } from './storage/userStorage.js';
|
||||
import logger from './logger.js';
|
||||
|
||||
/**
|
||||
* if we are running in demo environment, we have to cleanup the db files (specifically the jobs table)
|
||||
@@ -29,7 +30,7 @@ function cleanup() {
|
||||
if (config.demoMode) {
|
||||
const demoUser = getUsers(false).find((user) => user.username === 'demo');
|
||||
if (demoUser == null) {
|
||||
console.error('Demo user not found, cannot remove Jobs');
|
||||
logger.error('Demo user not found, cannot remove Jobs');
|
||||
return;
|
||||
}
|
||||
removeJobsByUserName(demoUser.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { setDebug } from './utils.js';
|
||||
import puppeteerExtractor from './puppeteerExtractor.js';
|
||||
import { loadParser, parse } from './parser/parser.js';
|
||||
import logger from '../logger.js';
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
debug: false,
|
||||
@@ -32,7 +33,7 @@ export default class Extractor {
|
||||
loadParser(this.responseText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error trying to load page.', error);
|
||||
logger.error('Error trying to load page.', error);
|
||||
}
|
||||
return this;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
import logger from '../../logger.js';
|
||||
|
||||
let $ = null;
|
||||
|
||||
@@ -8,19 +9,19 @@ export function loadParser(text) {
|
||||
|
||||
export function parse(crawlContainer, crawlFields, text, url) {
|
||||
if (!text) {
|
||||
console.warn('No content found for ', url);
|
||||
logger.warn('No content found for ', url);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!crawlContainer || !crawlFields) {
|
||||
console.warn('Cannot parse, selector was empty for url ', url);
|
||||
logger.warn('Cannot parse, selector was empty for url ', url);
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = [];
|
||||
|
||||
if ($(crawlContainer).length === 0) {
|
||||
console.warn('No elements in crawl container found for url ', url);
|
||||
logger.warn('No elements in crawl container found for url ', url);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -58,7 +59,7 @@ export function parse(crawlContainer, crawlFields, text, url) {
|
||||
|
||||
parsedObject[key] = value || null;
|
||||
} catch (error) {
|
||||
console.error(`Error parsing field '${key}' with selector '${fieldSelector}':`, error);
|
||||
logger.error(`Error parsing field '${key}' with selector '${fieldSelector}':`, error);
|
||||
parsedObject[key] = null;
|
||||
}
|
||||
}
|
||||
@@ -66,7 +67,7 @@ export function parse(crawlContainer, crawlFields, text, url) {
|
||||
if (parsedObject.id != null) {
|
||||
result.push(parsedObject);
|
||||
} else {
|
||||
console.warn('ID not found. Not relaying object.');
|
||||
logger.debug('ID not found. Not relaying object.');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -89,7 +90,7 @@ function applyModifiers(value, modifiers) {
|
||||
value = value.replace(/\n/g, ' ');
|
||||
break;
|
||||
default:
|
||||
console.warn(`Unknown modifier: ${modifier}`);
|
||||
logger.warn(`Unknown modifier: ${modifier}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import puppeteer from 'puppeteer-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import { debug, DEFAULT_HEADER, botDetected } from './utils.js';
|
||||
import logger from '../logger.js';
|
||||
|
||||
puppeteer.use(StealthPlugin());
|
||||
|
||||
@@ -33,13 +34,13 @@ export default async function execute(url, waitForSelector, options) {
|
||||
const statusCode = response.status();
|
||||
|
||||
if (botDetected(pageSource, statusCode)) {
|
||||
console.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
||||
logger.warn('We have been detected as a bot :-/ Tried url: => ', url);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await page.content();
|
||||
} catch (error) {
|
||||
console.error('Error executing with puppeteer executor', error);
|
||||
logger.error('Error executing with puppeteer executor', error);
|
||||
return null;
|
||||
} finally {
|
||||
if (browser != null) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logger from '../logger.js';
|
||||
|
||||
let debuggingOn = false;
|
||||
|
||||
export const DEFAULT_HEADER = {
|
||||
@@ -15,9 +17,7 @@ export const setDebug = (options) => {
|
||||
|
||||
export const debug = (message) => {
|
||||
if (debuggingOn) {
|
||||
/* eslint-disable no-console */
|
||||
console.debug(message);
|
||||
/* eslint-enable no-console */
|
||||
logger.debug(message);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
61
lib/services/logger.js
Normal file
61
lib/services/logger.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const COLORS = {
|
||||
debug: '\x1b[36m',
|
||||
info: '\x1b[32m',
|
||||
warn: '\x1b[33m',
|
||||
error: '\x1b[31m',
|
||||
reset: '\x1b[0m',
|
||||
};
|
||||
|
||||
const useColor = process.stdout.isTTY || process.stderr.isTTY;
|
||||
|
||||
function ts() {
|
||||
const d = new Date();
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
const hh = String(d.getHours()).padStart(2, '0');
|
||||
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`;
|
||||
}
|
||||
|
||||
function lvl(level) {
|
||||
const upper = level.toUpperCase();
|
||||
if (!useColor) return upper;
|
||||
return `${COLORS[level] || ''}${upper}${COLORS.reset}`;
|
||||
}
|
||||
|
||||
/* eslint-disable no-console */
|
||||
function log(level, ...args) {
|
||||
const prefix = `[${ts()}] ${lvl(level)}:`;
|
||||
switch (level) {
|
||||
case 'debug':
|
||||
console.debug(prefix, ...args);
|
||||
break;
|
||||
case 'info':
|
||||
console.info(prefix, ...args);
|
||||
break;
|
||||
case 'warn':
|
||||
console.warn(prefix, ...args);
|
||||
break;
|
||||
case 'error':
|
||||
console.error(prefix, ...args);
|
||||
break;
|
||||
default:
|
||||
console.log(prefix, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
debug: (...a) => log('debug', ...a),
|
||||
info: (...a) => log('info', ...a),
|
||||
warn: (...a) => log('warn', ...a),
|
||||
error: (...a) => log('error', ...a),
|
||||
};
|
||||
|
||||
// Beispiel:
|
||||
// import logger from './logger.js';
|
||||
// const a = 'fick';
|
||||
// const b = { tr: 'lolo' };
|
||||
// logger.info('hallo', a, b);
|
||||
// -> In IntelliJ siehst du das Objekt wie bei console.info, plus Prefix
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as listingStorage from './listingsStorage.js';
|
||||
import { getDirName } from '../../utils.js';
|
||||
import path from 'path';
|
||||
import LowdashAdapter from './LowDashAdapter.js';
|
||||
import logger from '../logger.js';
|
||||
|
||||
const file = path.join(getDirName(), '../', 'db/jobs.json');
|
||||
const adapter = new JSONFileSync(file);
|
||||
@@ -91,9 +92,7 @@ export const removeJobsByUserName = (userId) => {
|
||||
.value();
|
||||
db.write();
|
||||
if (removedDemoJobs > 0) {
|
||||
/* eslint-disable no-console */
|
||||
console.log(`Removed ${removedDemoJobs} demo jobs`);
|
||||
/* eslint-enable no-console */
|
||||
logger.info(`Removed ${removedDemoJobs} demo jobs`);
|
||||
}
|
||||
};
|
||||
export const getJobs = () => {
|
||||
|
||||
17
lib/services/tracking/Tracker-Cron.js
Normal file
17
lib/services/tracking/Tracker-Cron.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import cron from 'node-cron';
|
||||
import { config, inDevMode } from '../../utils.js';
|
||||
import { trackMainEvent } from './Tracker.js';
|
||||
|
||||
async function runTask() {
|
||||
//make sure to only send tracking events if the user gave us the green light and we are not in dev mode
|
||||
if (config.analyticsEnabled && !inDevMode()) {
|
||||
await trackMainEvent();
|
||||
}
|
||||
}
|
||||
|
||||
export async function initTrackerCron() {
|
||||
//run directly on start
|
||||
await runTask();
|
||||
// then every 6 hours
|
||||
cron.schedule('0 */6 * * *', runTask);
|
||||
}
|
||||
@@ -1,65 +1,66 @@
|
||||
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';
|
||||
import logger from '../logger.js';
|
||||
|
||||
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();
|
||||
export const trackMainEvent = async () => {
|
||||
try {
|
||||
if (config.analyticsEnabled && !inDevMode()) {
|
||||
const activeProvider = new Set();
|
||||
const activeAdapter = new Set();
|
||||
|
||||
const jobs = getJobs();
|
||||
const jobs = getJobs();
|
||||
|
||||
if (jobs != null && jobs.length > 0) {
|
||||
jobs.forEach((job) => {
|
||||
job.provider.forEach((provider) => {
|
||||
activeProvider.add(provider.id);
|
||||
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),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await fetch(`${FREDY_TRACKING_URL}/main`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(trackingObj),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.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) {
|
||||
logger.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 +68,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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,7 +85,7 @@ async function getPackageVersion() {
|
||||
const json = JSON.parse(packageJson);
|
||||
return json.version;
|
||||
} catch (error) {
|
||||
console.error('Error reading version from package.json', error);
|
||||
logger.error('Error reading version from package.json', error);
|
||||
}
|
||||
return 'N/A';
|
||||
}
|
||||
|
||||
25
lib/utils.js
25
lib/utils.js
@@ -3,6 +3,13 @@ import { fileURLToPath } from 'node:url';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { createHash } from 'crypto';
|
||||
import { DEFAULT_CONFIG } from './defaultConfig.js';
|
||||
import fs from 'fs';
|
||||
import logger from './services/logger.js';
|
||||
|
||||
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 +60,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 +75,19 @@ export async function refreshConfig() {
|
||||
config.demoMode ??= false;
|
||||
} catch (error) {
|
||||
config = { ...DEFAULT_CONFIG };
|
||||
console.error('Error reading config file', error);
|
||||
logger.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`)) {
|
||||
logger.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;
|
||||
|
||||
36
package.json
36
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "11.4.3",
|
||||
"version": "11.6.4",
|
||||
"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,60 @@
|
||||
"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-cron": "^4.2.1",
|
||||
"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